How to use Ash Policies when users have a different role for each organization?

So we have an application where users fundamentally belong to many organizations, and as such, they have different roles for each organization. Organizations can have child organizations as well, however is needed to organize and control their flow of data.

I want to use an ash policy to restrict the actions of users based on the role they have for the organization they are trying to perform the action on.

So I have the following table which associates a user to an organization and has their role for that organization on it

defmodule UsersOrganizations do

  use Ash.Resource,
    otp_app: :my_app,
    domain: User.Domain,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  attributes do
    uuid_primary_key(:id)

    attribute(:user_role, :atom,
      default: :default,
      allow_nil?: false,
      always_select?: true,
    )
  end

  relationships do
    belongs_to(:user, User, allow_nil?: false)

    belongs_to(:organization, Organization, allow_nil?: false)
  end
end

But I don’t know understand how to grab this relationship in a policy, such as this simplified situation below

defmodule MyResource do
    use Ash.Resource,
      otp_app: :my_app,
      domain: SomeResource.Domain,
      data_layer: AshPostgres.DataLayer,
      authorizers: [Ash.Policy.Authorizer]

    attributes do
     attribute :message_body, :string
    end

    relationships do
      belongs_to :sender, User
      belongs_to :organization, Organization
    end

    actions do 
      read :get_sender_information do
        #read sensitive user information, like a name and email.
     end
    end

    policies do
      policy action(:get_sender_information) do
        authorize_if #The reading user's role in the organization the message was sent in is :admin
      end
    end
 end

So my question is, what is the best approach to building out this sort of policy?

Loosely related question, is it possible to export Ash Policies as an Access Control List (ACL)?

Im on the train so I can’t really type but can’t you do

authorize_if expr(exists(:actor, organisation.role == :admin))

For ref: Policies — ash v3.4.67
Expressions — ash v3.4.67

You can’t use the actor with exists like that unfortunately. We have an issue for using arbitrary resources in exists: Support resources as aggregate targets in expressions · Issue #939 · ash-project/ash · GitHub

What you would likely do is relate the organization to the roles, and then use exists through it, something like:

authorize_if exists(organization.roles, user_id == ^actor(:id) and something_about_the_role)

Possible? Yes. But nothing we have will do that for you automatically currently, would probably be a pretty big lift.

Oh man, I was not expecting a reply so fast. Thanks for the response.
That looks incredibly straightforward. There’s this piece in the documentation that makes me wonder whether that would also work for a create - ie, a user can only create messages in an org they are part of.

policy action_type(:create) do
  # This check is fine, as we only reference the actor
  authorize_if expr(^actor(:admin) == true)
  # This check is not, because it contains a reference to a field
  authorize_if expr(status == :active)
end

Is this saying I can’t reference any data being passed into an action at all in a policy, or can I still reference an argument in the exists like I would in an action?
exists(^arg(organization.roles), user_id == ^actor(:id) and something_about_the_role)

It’s entirely possible I just need more coffee on my next read through of the docs

Nope, so for creating data it’s unfortunately basically the Wild West because the data doesn’t yet exist for us to query. You will need to write a custom check.

defmodule MyApp.Checks.HasRoleInOrganization do
  use Ash.Policy.SimpleCheck

  def match?(actor, %{subject: changeset}, context) do
    # run queries here to determine the result
    {:ok, false}
  end
end
1 Like

Ahhh there it is. I was assuming you couldn’t use a custom check there either. Thanks for the help!

For anyone who finds this page later, I used Zach’s approach above in combination with the following aggregates and calculations on my Organizations resource

  aggregates do
    list :admins, :users_join_assoc, :user_id, filter: [user_role: :admin]

    list :editors, :users_join_assoc, :user_id, filter: [user_role: :editor]

    list :guests, :users_join_assoc, :user_id, filter: [user_role: :guest]
  end

  calculations do
    calculate :actor_is_admin?, :boolean, expr(^actor(:id) in admins)

    calculate :actor_is_editor?, :boolean, expr(^actor(:id) in editors)

    calculate :actor_is_guest?, :boolean, expr(^actor(:id) in guests)
  end
4 Likes

I used a Filter check with different filter/3 clauses for each resource. Of course, I have an OrgMembership join resource that has the actor’s org role on it. It seems to work fine.

You can probably add an option to pass in the role so it can be used for any org role.

defmodule PetalProAsh.Orgs.Checks.IsOrgAdmin do
  @moduledoc false
  use Ash.Policy.FilterCheck

  alias MyApp.MyDomain.Foo
  alias PetalProAsh.Orgs.Org

  @impl true
  def describe(_options) do
    "actor is an org admin"
  end

  @impl true
  def filter(actor, %{resource: Org}, _options) when not is_nil(actor) do
    expr(exists(memberships, user_id == ^actor.id and role == :admin))
  end

  @impl true
  def filter(actor, %{resource: Foo}, _options) when not is_nil(actor) do
    expr(exists(org.memberships, user_id == ^actor.id and role == :admin))
  end

  @impl true
  def filter(_actor, _context, _options) do
    false
  end
end
1 Like