Simple policy checks with relationships

Hello, friends!

So on the website documentation, we have an example of a simple policy check:

  # we're inside of a module here
  def match?(%MyApp.User{age: age} = _actor, %{resource: MyApp.Beer} = _context, _opts) do
    age >= 21
  end

All well and good, but in a more real world example we’d probably have:

  • Roles for Users
  • Many permissions for those roles
  • Potentially many permissions for users

So let’s say we have something like the following:

A User resource:

defmodule MyApp.Accounts.User do
  @moduledoc """
  Close to a real world example of a basic users, but 
  obviously there's a lot more stuff here. 
  Shortened for the sake of brevity.
  """
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication]

  postgres do
    table "app_users"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :first_name, :ci_string, allow_nil?: false
    attribute :last_name, :ci_string, allow_nil?: false
    attribute :email, :ci_string, allow_nil?: false, sensitive?: true
    attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
    
    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  relationships do
    has_one :role, MyApp.Accounts.Role
    many_to_many :permissions, MyApp.Accounts.User do
      through MyApp.Accounts.UserPermission
      source_attribute_on_join_resource :user_id
      destination_attribute_on_join_resource :permission_id
    end
  end

And of course…

A Role resource:

defmodule MyApp.Accounts.Role do
  use Ash.Resource,
      data_layer: AshPostgres.DataLayer

  postgres do
    table "roles"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string do
      allow_nil? false
    end
    attribute :description, :string do
      allow_nil? false
    end
  end

  relationships do
    has_many :users, MyApp.Accounts.User
    many_to_many :permissions, MyApp.Accounts.Role do
      through MyApp.Accounts.RolePermission
      source_attribute_on_join_resource :role_id
      destination_attribute_on_join_resource :permission_id
    end
  end
end

A Permission resource:

defmodule MyApp.Accounts.Permission do
  use Ash.Resource,
      data_layer: AshPostgres.DataLayer

  postgres do
    table "permissiones"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string do
      allow_nil? false
    end
    attribute :description, :string do
      allow_nil? false
    end
  end

  relationships do
    many_to_many :roles, MyApp.Accounts.Permission do
      through MyApp.Accounts.RolePermission
      source_attribute_on_join_resource :permission_id
      destination_attribute_on_join_resource :role_id
    end
    many_to_many :users, MyApp.Accounts.Permission do
      through MyApp.Accounts.UserPermission
      source_attribute_on_join_resource :permission_id
      destination_attribute_on_join_resource :user_id
    end
  end
end

A RolePermission resource:

defmodule MyApp.Accounts.RolePermission do
  use Ash.Resource,
      data_layer: AshPostgres.DataLayer

  postgres do
    table "role_permissions"
    repo MyApp.Repo
  end

  relationships do
    belongs_to :role, MyApp.Accounts.Role do
      primary_key? true
      allow_nil? false
    end
    belongs_to :permission, MyApp.Accounts.Permission do
      primary_key? true
      allow_nil? false
    end
  end
end

A UserPermission resource:

defmodule MyApp.Accounts.UserPermission do
  use Ash.Resource,
      data_layer: AshPostgres.DataLayer

  postgres do
    table "user_permissions"
    repo MyApp.Repo
  end

  relationships do
    belongs_to :user, MyApp.Accounts.User do
      primary_key? true
      allow_nil? false
    end
    belongs_to :permission, MyApp.Accounts.Permission do
      primary_key? true
      allow_nil? false
    end
  end
end

And you want to check both the role and user permissions.

My question would be how do we go about doing the simple policy checks when we have a role-permission setup like this? Because the check itself isn’t complex, it’s more of a “how do we get there” question.

1 Like

You can probably achieve this with a custom SimpleCheck.

For example:

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

  @impl true
  def describe(opts) do
    "Actor has #{opts[:permission]}"
  end

  @impl true
  def match?(nil, _, _), do: false

  def match?(actor, _context, opts) do
    # Return true if the actor has opts[:permission]
  end
end

You can thsn use this in a policy, for example:

bypass action_type(:create) do
   authorize_if {ActorHasPermission, permission: :your_permission}
end

I understand that part, but how do one access a has_one relationship for an actor, and then access the attribute of that relationship?

So a User has one Role and a Role has a name, which is the simplest thing to check to see if they should have access to something.

So you have

%MyApp.User{age: age}

To access the attribute age but in this case Role isn’t an attribute. So does one load the relationship first, then go from there?

Your only options currently are to load the information your policies may need up front on the actor (like in a plug or wherever the user logs in/is fetched) or load it on-demand in the policy (i.e Accounts.load!(user, :role).role.role. At some point I would like to add a system where policies can declare what data must be loaded on the actor and will do so dynamically, loading the minimal set of actor data.

Yes, that’s right. In this case, something like this will load all user and role permissions:

actor =
    actor
    |> YourApi.load!([:permissions, role: [:permissions]])

As Zach mentioned, a good optimisation is to do this once in a plug, so that all permissions are always available to you without needing to load in every check.

So where does one go about adding plugs? I see there’s Ash.set_actor but in the case of an JSON API, where do we go about calling that function? I assume after the user is authenticated and “logged in”?

Yep! You’d add it in your phoenix router after the plug responsible for logging in the user.