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, :reads 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.