How to use generated Ash policy can? predicates

I’m starting to dig into policies for my ash resources and I’m having some difficulty calling the can_action?/2 functions generated for a resource with policies. I have the following policy on a task resource with is multitenant with the context strategy.

  policies do
    policy action(:by_id) do
      authorize_if relates_to_actor_via(:assigned_to)
      authorize_if relates_to_actor_via(:assigned_by)
    end

    policy action(:assigned_to_actor) do
      authorize_if relates_to_actor_via(:assigned_to)
    end

    policy action_type(:destroy) do
      authorize_if relates_to_actor_via(:assigned_by)
    end
  end

When I call Task.can_destroy? with the current actor and the task I’m trying to delete I get an error that the tasks table does not exist. Two things confuse me about this, first why doesn’t it look for tenant.tasks when checking the policy. If I explicitly set the tenant in the options for can_destroy?/3 I get the same error. The other thing that confuses me is this check could be completed by looking only at the ids of the actor and task resource, so why does it need to query the db for this check? Any help would be appreciated

The first part sounds like a bug, that it isn’t honoring the tenant option provided to can?. Can you open an issue on the ash GitHub repository please?

The second case is a bit more complicated. The implementation of relates_to_actor_via needs to be made smarter to do can? without making a query in the case that it is a single relationship. relates_to_actor_via is effectively a synonym for exists(assigned_to, id == ^actor(:id)), which might make it clearer why it is trying to do a query. When the relationship is a belongs_to relationship, we could actually turn that under the hood to assigned_to_id == ^actor(:id) and that is something that could be evaluated in Elixir on the spot. If you changed your policies to this:

  policies do
    policy action(:by_id) do
      authorize_if expr(assigned_to_id == ^actor(:id))
      authorize_if expr(assigned_by_id == ^actor(:id))
    end

    policy action(:assigned_to_actor) do
      authorize_if expr(assigned_to_id == ^actor(:id))
    end

    policy action_type(:destroy) do
      authorize_if expr(assigned_by_id == ^actor(:id))
    end
  end

I believe that would solve your issue. Could you also please open an issue on ash Github for this as well? This would allow more calls to can? to be done without making a query, and would also serve as an optimization in cases where we build queries (no joins would need to happen in the data layer).

I tried changing the policy to use expr as suggested but I still get a similar error where it queries for the task table which doesn’t exist because its a multi tenant resource. I understand the tenant option not being honored is a bug but how come after changing to use expr and only look at the ids, Task.can_destroy/2 still does a query in the first place?

I would have to take a look at the code to refresh my memory on the internals. I believe it should be using a preflight check that doesn’t require a query, but there it should be considered a possibility that that call might run queries. Can you show me your new policies just to confirm?

Here is the new policy and also the relationships section if that illuminates anything, thanks for the quick reply too.

  policies do
    policy action(:by_id) do
      authorize_if expr(assigned_to_id == ^actor(:id))
      authorize_if expr(assigned_by_id == ^actor(:id))
    end

    policy action_type(:read) do
      authorize_if actor_present()
    end

    policy action_type(:destroy) do
      authorize_if expr(assigned_by_id == ^actor(:id))
    end
  end

  relationships do
    belongs_to :assigned_to, NaviClient.Accounts.User do
      allow_nil? false
      attribute_writable? true
    end

    belongs_to :assigned_by, NaviClient.Accounts.User do
      allow_nil? false
      attribute_writable? true
    end
  end

  multitenancy do
    strategy :context
  end

Edit here is the actual test where I try and call the can_destroy action too

    test "destroy allows the assigner to delete the task", %{task: task, assigner: assigner, organization: organization} do
      tenant = NaviClient.Accounts.Organization.schema_name!(organization)
      assert Task.can_destroy?(assigner, task, tenant: tenant)
    end

Can I see your code interface definition for can_destroy?

There is no code interface defined for any of the can_* style functions. It seems they are generated entirely from the action names. For example I have another action named update_microsoft_task_id which generates a can_update_microsoft_task_id/4. Here is the destroy action I’m using though, the only thing the change does is insert an Oban job and nothing else.

    destroy :destroy do
      change {NaviClient.Microsoft.ResourceChange, type: :destroy}
    end

The can_ functions are of arity 4 where the parameters are the actor, record, params_or_opts and opts. Passing the tenant option as params_or_opts or as opts doesn’t change the result either.

I was able to find a workaround by writing a SimpleCheck, it’d be nice to use the expr because its much more expressive but for anyone else the below should work.

  defmodule TaskAssignedBy do
    @moduledoc false
    use Ash.Policy.SimpleCheck

    def describe(_) do
      "Was the task assigned by the actor"
    end

    def match?(%User{id: user_id}, %{resource: NaviClient.Activities.Task, changeset: changeset}, _opts) do
      assigned_by_id = get_data(changeset, :assigned_by_id)
      user_id == assigned_by_id
    end

    def match?(_, _, _), do: true
  end

And in the policy

authorize_if NaviClient.Checks.TaskAssignedBy

Edit: I ended up making the check more generic to check if the value of a resources field matches the actors id.

  defmodule BelongsTo do
    @moduledoc false
    use Ash.Policy.SimpleCheck

    def describe(opts) do
      field = Keyword.fetch!(opts, :field)
      "Check that the actors id matches the value of the field #{field}"
    end

    def match?(%User{id: user_id}, %{changeset: %Ash.Changeset{} = changeset}, opts) do
      field = Keyword.fetch!(opts, :field)
      value = Ash.Changeset.get_data(changeset, field)

      user_id == value
    end

    def match?(_, _, _), do: true
  end

authorize_if {NaviClient.Checks.BelongsTo, field: :assigned_by_id}

If you wouldn’t mind, it would be great if you could open an issue RE: having to write this simple check. This particular set of policies should have been doable without running queries :slight_smile: