Ash Policies Gotchas
Policies are unexpectedly tricky to get right.
Here are some of the Ash policies issues that I've had to work my way through.
Fall through if not forbidden
A forbid_if
will only forbid the whole policy if the check is true. If that check is not false, but you still want those actors to be able to execute the action, you need a final authorize_if always()
, which acts as a default "yes, authorize".
policies do
policy always() do
forbid_if MyProject.ASimpleCheck
authorize_if always()
end
end
Cover all action types
If you divide your policies by action types, you will need to include as policies the action types that you "don't care" about, including an authorize_if always()
check. Otherwise, in this example, :read
s would be forbidden automatically.
policies do
policy action_type([:create, :update, :destroy]) do
forbid_if MyProject.ASimpleCheck
authorize_if always()
end
# Added so :reads won't automatically be forbidden
policy action_type(:read) do
authorize_if always()
end
end
Check intersections
If you put all your authorize_if
checks into one policy
, they act as a union. Any one check returning true marks the whole policy as true, authorized:
policies do
# union
policy always() do
authorize_if MyProject.DoThisCheck
authorize_if MyProject.DoAnotherCheck
end
end
To make it a two-key authorize, where both checks must be true before the action is executed, separate policies must be defined. These act as an intersection. Both checks must return true in order to pass.
policies do
# intersection
policy always() do
authorize_if MyProject.DoThisCheck
end
policy always() do
authorize_if MyProject.DoAnotherCheck
end
end
What other Ash policy gotchas do you run into?
OriginalDataNotAvailable
Sometimes a policy will want to access the changeset or the original record that is the subject of an action. So it was when I was implementing a policy fora destroy action on a soft-deleted resource. The soft delete was implemented in AshArchival. It worked great, but the policy was incompatible.
Logging the changeset, this is the relevant part of what we had:
...
changeset: #Ash.Changeset<
domain: MyProject.MyDomain,
action_type: :destroy,
action: :destroy,
attributes: %{},
atomics: [
archived_at: ~U[2025-09-09 16:04:06.276485Z],
updated_at: if ~U[2025-09-09 16:04:06.276485Z] != archived_at do
now()
else
updated_at
end
],
...
data: %Ash.Changeset.OriginalDataNotAvailable{reason: :atomic_query_update},
>,
Notably the changeset didn't have data, so this kind of thing failed:
resource_id = Ash.Changeset.get_argument_or_attribute(context.changeset, :id)
So what does %Ash.Changeset.OriginalDataNotAvailable{reason: :atomic_query_update}
mean exactly. Still not 100% sure. Looks like the data is missing because of an atomic update. My current thinking is that the destroy action, because it was a soft delete, could happen atomically because all it is is updating the row with an archived_at
value. Trying to change this in the action declaration didn't work:
destroy :destroy do
primary? true
require_atomic? false
end
require_atomic?
did not affect this issue. But I seemed to be able to force it to load the data by putting a noop after_action
on it:
destroy :destroy do
primary? true
require_atomic? false
change after_action(&MyApp.MyDomain.MyResource.AfterDestroy.load_resource/3)
end
# ...
defmodule MyApp.MyDomain.MyResource.AfterDestroy do
require Logger
def load_resource(_changeset, record, _context) do
Logger.debug("Record loaded #{inspect(record)}")
{:ok, record}
end
end
Now, the changeset has all its data loaded. I'm still looking for a better solution on this one.