Ash Authorization: How to Permit Record Owners to Bypass Permission Check on Some Actions?

I’m trying to authorize actions in Ash Policy Authorizer while ensuring that the owner of a record (the person who created it) can always access or modify it. However, my current policy setup still results in a :forbidden error unless MyApp.Auth.Checks.Can passes.

Scenario:

  1. Each User has an Employee (Person) profile.
  2. A Person has many Leave Requests.
  3. A Person should be able to cancel their own Leave Requests, even if they don’t have the broader permissions granted by MyApp.Auth.Checks.Can.

Current Issue:

The following policy throws :forbidden unless MyApp.Auth.Checks.Can passes. I want the policy to allow access if either MyApp.Auth.Checks.Can or expr(person.user_id == ^actor(:id)) is true

  policies do
    policy action_type(:read) do
      description "Users with permission can read leave request or the request's owner."
      authorize_if MyApp.Auth.Checks.Can
      authorize_if expr(person.user_id == ^actor(:id))
    end

    policy action_type([:create, :update, :destroy]) do
      description "People with permission can ceate, update or destory"
      authorize_if MyApp.Auth.Checks.Can
      access_type :strict
    end

    policy action(:cancel) do
      description "A person can cancel a request belonging to him"
      authorize_if expr(person.user_id == ^actor(:id))
    end
  end

Expected Behavior:

  • Users with the correct permissions (MyApp.Auth.Checks.Can) should be able to manage leave requests.
  • The owner of a leave request (person.user_id == ^actor(:id)) should also be able to access or cancel their own requests, even without additional permissions.

How can I properly configure this policy so that the record owner is always authorized without requiring MyApp.Auth.Checks.Can to pass?

Additional details on the forbidden error

{:error, %Ash.Error.Forbidden{
  bread_crumbs: ["Error returned from: MyApp.TimeOffs.TimeOffRequest.cancel"],
  errors: [
    %Ash.Error.Forbidden.Policy{
      facts: %{
        {Ash.Policy.Check.Action, [action: [:cancel], access_type: :filter]} => true,
        {Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]} => false,
        {Ash.Policy.Check.Expression, [expr: person.user_id == {:_actor, :id}, access_type: :filter]} => :unknown,
        {MyApp.Auth.Checks.Can, [access_type: :filter]} => false
      },
      actor: %MyApp.Auth.User{
        id: "0194dc51-62ac-70f7-9bc9-c7021121d463",
        email: "tester@example.com",
        current_tenant: "test_org"
      },
      subject: %Ash.Changeset{
        domain: MyApp.TimeOffs,
        action: :cancel,
        tenant: "test_org",
        attributes: %{status: :cancelled},
        data: %MyApp.TimeOffs.TimeOffRequest{
          id: "0194e540-be3a-7481-a88e-e4afe8c4124f",
          starts_at: ~D[2025-02-08],
          ends_at: ~D[2025-02-08],
          description: "Annual Leave",
          status: :pending
        }
      }
    }
  ]
}}

It sounds like you want to add another check to each of your policies, eg.

    policy action_type([:create, :update, :destroy]) do
      description "People with permission can ceate, update or destory"
      authorize_if MyApp.Auth.Checks.Can
      authorize_if expr(person.user_id == ^actor(:id))
      access_type :strict
    end

Policy checks cascade - if the first doesn’t pass, then the next one will be attempted.

By the by, there’s also the relates_to_actor_via which sounds like it would fit in here as well :slight_smile:

2 Likes

Here’s how I ended up defining my policy

  policies do
    policy action(:cancel) do
      description "Person can cancel their own leave request"
      authorize_if relates_to_actor_via([:person, :user])
    end

    policy always() do
      description "All other request must go through normal authorization"
      # :strict requires all to pass. So removed so that it authorizes on one passing condition
      # access_type :strict
      authorize_if relates_to_actor_via([:person, :user])
      authorize_if Zippiker.Auth.Checks.Can
    end
  end

access_type :strict does not require all to pass. That just means that authorization results must be known before making the query. relates_to_actor_via requires access_type :filter (the default) because it filters the query.

1 Like

Now I understand why with it was failing when access type is set to strict.

Thanks @zachdaniel

1 Like