Enforce a constraint on the number of Resources created in an Ash Policy

I’m trying to enforce the following policy: a User can only create up to 10 related Resources. When the 11th Resources is being created the policy should disallow this (another Resource should first be destroyed before creating this new one).

This is a simplification of a real-world problem, but the gist is that I want to enforce a rule that needs to take into account “sibling” Resources that already exist, and are not necessarily loaded. 10 is just an arbitrary number, it might as wel be 1000, in which case loading them all would be not be feasible anyway.

My best guess now would be to define an aggregate on the User resource that counts the number of related Resources, and use that in a policy. But I’m not sure aggregates are available in policy checks. I’m also not sure if that’s the correct way to do it, since the aggregate count will be stale by the time the it’s being used to enforce the policy. Or will the aggregate count be part of the atomic action somehow and be reloaded just-in-time?

An alternative might be to query the existing Resources (or at least query the count) in a custom policy check. But in the Ash Framework book I read this advice:

Beware the policy check that performs queries! […] For a LiveView app, if that related data isn’t pre-loaded and stored in memory, it’ll be re-fetched to recalculate the authorization on every page render, which would be disastrous for performance! Ash make a best guess about authorization using data already loaded, if you use run_queries?: false when calling Ash.can?/can_*?. If a decision can’t be made definitively, Ash will use the value of the maybe_is option — by default this is true, but you can fail closed by setting it to false.

Which makes me think this is not a good idea. Or maybe it can be done, but then I don’t understand how this works exactly.

I want to use the can_*? functions to drive the UI in a LiveView. If the policy doesn’t allow creating new Resources, because a limit is reached, I don’t want to make that possibility available in the UI. I’m perfectly fine with some trade-offs: the UI could be on best-effort basis, deciding to offert create functionality based on (somewhat) stale data. In the unfortunate event that the event did offer the create functionality, but a Resource was created out-of-band, then a suitable error should be shown (and the UI should be updated to reflect the new situation, now without the possibility to create a new Resource). It makes me think of Ecto’s unsafe_validate_unique/4 functionality that also works on a best-effort basis, but still enforces strict uniqueness with a unique constraint when using it in combination with unique_constraint/3.

Maybe I’m not thinking about this straight, and maybe this functionality shouldn’t be in a policy in the first place. I’m curious if I overlooked something obvious, or if this pattern of checking an invariant relative to other data (not the actor, nor the request parameters/context for the Resource being created) can be solved in a elegant way.

What if you had a cache counter to keep track or already created resources?

1 Like

:thinking: Could work. I would worry about having an incorrect cache. But it could be an efficient way to handle this.

So you can do it in a policy, but there are concurrency concerns (as you’ve pointed out) that come into play here that will likely require at least one other thing. Either locking, or some application logic for handling there being more rows than expected. You’ll regardless want a relationship from a resource to all “other” resources for that user

That relationship:

relationships do
  has_many :other_resources_for_user, __MODULE__ do
    source_attribute :user_id
    destination_attribute :user_id
    filter expr(parent(id) != id) # where they aren't me
  end
end

With Locking

changes do
  change TakeAdvisoryLock, on: [:create]
end

You could add a change that takes an advisory lock in a before action hook, and apply it above. Then a policy like this would work:

authorize_if expr(count(other_resources_for_user) < 10)

The lock ensures that any create action is serialized. This does not handle cases where you directly insert a row i.e via a database console concurrently because likely whoever is doing that is not taking the same advisory lock. Easy solve there is just not to do that in a production environment :smiley:

With app logic

You could, for example, have just the policy I showed above, and somewhere in your UI you could see “do they have too many of X? If so, show a banner saying you’ve got too many, please upgrade or your newest ones may be deleted” for example. This “overage” application state is often necessary to handle things like canceled plans, failed payments, downgrades etc. Unless they can manage to create a whole bunch of these things in the same instant, there isn’t any significant risk of them having more than they should, except maybe at most one or two. But your application will be modeled to handle that situation.

3 Likes

Another thing you could do is materialize the sum into another row somewhere. Like if you had a Project with Resources then you could keep a project.resource_count and transactionally increment/decrement it when you insert/delete Resources. This has the convenient benefit of locking the Project row on every update which would serialize the transactions similar to the advisory lock. But it also has the benefit of keeping track of the number of Resources so that you don’t have to scan that index to count them on every insert. If you had a lot of resources maybe that would be worth it (it really just depends).

This is similar to the cache suggestion above, but note that this is not a cache per se but an aggregate index, and it can be kept transactionally up-to-date (modulo bugs). Some databases (e.g. fdb record layer) actually support aggregate index types (sum, count), but I don’t think Postgres does outside of defining a materialized view.

Note that you would have to either SELECT FOR UPDATE or upsert the Project row (don’t you just love READ COMMITTED?), or just take an advisory lock anyway.

I’m not sure how you would do this idiomatically in Ash but maybe Zach can explain :slight_smile:

It really is amazing how frequently this comes up on here lol. And yet database vendors continue to claim strict serializability is unnecessary!

1 Like

These are really interesting points of advice! The has_many relationship in combination with the policy sounds good. I’ll experiment with that.

I’m not too concerned about some overage. In the domain I’m trying to apply this (a specific kind of library where digital content can be issued to lenders for a limited period of time), there is no physical implication when there is overage. This would be a different story if it physical resources would need to be fitted in a container somehow with space constraints.

A materialized view also sounds promising, although I’ve never used them before. How this interacts with the isolation levels is a bit difficult to reason about though. Luckily this isn’t going to be a banking system with strict all-or-nothing constraints.

My main concern remains the usage of a policy that checks a count like this, in combination with checking the policy when rendering specific LiveView UI functionality (hide the possibility to associated the 11th Resource). If this results in a database query for each re-render, then this won’t scale well.

Correct, it would cause a query to be made on every time you check the policies if done that way. But you can also assign the value on mount. So it would be checked on page load, and once again on submit.

2 Likes

Of course :folded_hands:

The only thing that remains a mystery to me is this quote from the Ash Framework book:

Ash make a best guess about authorization using data already loaded, if you use run_queries?: false when calling Ash.can?/can_*?. If a decision can’t be made definitively, Ash will use the value of the maybe_is option — by default this is true, but you can fail closed by setting it to false.

I more or less understand the last part: maybe_is lets you decide how uncertainty is handled.

From the docs on Ash.can?/3:

:run_queries? (boolean/0) - Whether or not to run queries. If set to true, :maybe will not be returned. The default value is true.

Yeah, if you wanted behavior like “if I’d need to run a query to find out if they can do it, let’s not and instead treat that as true” run_queries?: false, maybe_is: true.

Often this is fine for super cheap checks because permissions get run before actually doing anything.

1 Like

After some experimentation I conclude that this expression doesn’t play nicely with create actions. A CannotFilterCreates error is raised. I think it relates to the warning about “Why can’t we reference data in creates?” in the documentation about policies.

I can implement a SimpleCheck instead and use the actor to count the associated Resources through a read action, which is nice.

def match?(actor, _context, opts) do
  limit = opts[:limit]
  {:ok, Ash.count!(Resource, actor: actor, action: :read_active) < limit}
end

Now I can use that check like this:

policy action_type(:create) do
  authorize_if {ResourceLimitCheck, limit: 10}
end

Ahhh right you are :slight_smile: you do need a simple check for creates, sorry about that.