Skip to content

Instantly share code, notes, and snippets.

@kamaroly
Created October 6, 2024 09:37
Show Gist options
  • Save kamaroly/986c40d1b950373f6577d973a414f01b to your computer and use it in GitHub Desktop.
Save kamaroly/986c40d1b950373f6577d973a414f01b to your computer and use it in GitHub Desktop.
How To Use Ash framework in Phoenix with user permission example
defmodule MyApp.Accounts.Checks.Can do
use Ash.Policy.SimpleCheck
def describe(_opts) do
"Check if a user/ actor has permission on a specific resource"
end
# @impl true
def match?(nil, _, _), do: false
def match?(actor, %{query: query, changeset: changeset}, _opts) do
# for read actions, the query will be populated
# for create/update/destroy actions, the changeset will be populated
subject = query || changeset
resource =
subject.resource
|> Phoenix.Naming.resource_name()
|> String.downcase()
# replace `.type` with `.name` for action name
action_type = convert_action_name(subject.action.type)
{:ok, actor |> can?(action_type, resource)}
end
@doc false
def can?(user, action_type, resource) do
# Gather all permissions from the user's groups
# Check if any user permission matches the action and resource
Enum.flat_map(user.groups || [], & &1.permissions)
|> Enum.map(&%{action: &1.name, resource: &1.resource})
|> Enum.any?(&(&1.action == action_type and &1.resource == resource))
end
@doc false
def cannot?(user, action_type, resource) do
can?(user, action_type, resource) == false
end
defp convert_action_name(action_name) when is_binary(action_name) do
String.downcase(action_name)
end
defp convert_action_name(action_type) when is_atom(action_type) do
Atom.to_string(action_type)
|> String.downcase()
end
end
defmodule MyApp.Organisations.Department do
use Ash.Resource,
domain: MyApp.Organisations,
authorizers: [Ash.Policy.Authorizer],
data_layer: AshPostgres.DataLayer
postgres do
table "departments"
repo MyApp.Repo
end
actions do
default_accept [:name, :description, :status, :type]
defaults [:read, :destroy, :create, :update]
create :seed do
description "Seed default departments"
accept []
manual Hr.Organisations.Departments.Actions.SeedDepartment
end
read :root do
filter expr(is_nil(parent_id))
end
read :by_parent do
argument :parent_id, :uuid
filter expr(parent_id == ^arg(:parent_id))
end
read :get_divisions do
argument :department_id, :uuid
filter expr(parent_id == ^arg(:parent_id))
end
end
policies do
policy action_type(:create), authorize_if: MyApp.Accounts.Checks.Can
policy action_type(:read), authorize_if: MyApp.Accounts.Checks.Can
policy action_type(:update), authorize_if: MyApp.Accounts.Checks.Can
policy action_type(:destroy), authorize_if: MyApp.Accounts.Checks.Can
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false
attribute :description, :string, allow_nil?: true
attribute :type, :atom do
default :department
constraints one_of: [:department, :division, :others]
end
attribute :status, :atom do
default :active
constraints one_of: [:active, :inactive]
end
timestamps()
end
identities do
identity :unique_name, :name
end
end
defmodule MyApp.Accounts.Group do
require Ash.Resource.Change.Builtins
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "groups"
repo Hr.Repo
references do
reference :permissions, on_delete: :nilify
reference :users, on_delete: :nilify
end
end
actions do
default_accept [:name, :description]
defaults [:read, :destroy]
create :seed do
accept []
manual MyApp.Accounts.Actions.SeedGroup
end
update :update do
argument :permission_ids, {:array, :uuid}, allow_nil?: true
manual MyApp.Accounts.Actions.UpdateGroup
primary? true
end
create :create do
argument :permission_ids, {:array, :uuid}, allow_nil?: true
primary? true
change after_action(&MyApp.Accounts.Group.Permission.add_permissions/3)
end
update :add_permissions do
argument :permission_ids, {:array, :uuid}
require_atomic? false
change manage_relationship(:permission_ids, :permissions,
type: :append_and_remove,
on_no_match: :create
)
end
update :add_users do
require_atomic? false
argument :user_ids, {:array, :uuid}
change manage_relationship(:user_ids, :users,
type: :append_and_remove,
on_no_match: :create
)
end
read :search do
argument :keyword, :string, allow_nil?: false
filter expr(
ilike(name, "%#{^arg(:keyword)}%") or
ilike(description, "%#{^arg(:keyword)}%")
)
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false
attribute :description, :string
timestamps()
end
relationships do
many_to_many :permissions, MyApp.Accounts.Permission do
through Hr.Accounts.GroupPermission
source_attribute_on_join_resource :group_id
destination_attribute_on_join_resource :permission_id
end
many_to_many :users, MyApp.Accounts.User do
through Hr.Accounts.UserGroup
source_attribute_on_join_resource :group_id
destination_attribute_on_join_resource :user_id
end
end
identities do
identity :unique_name, :name
end
end
defmodule MyApp.Accounts.GroupPermission do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "group_permissions"
repo Hr.Repo
end
actions do
defaults [:read, :destroy, :create, :update]
end
multitenancy do
strategy :context
end
attributes do
timestamps()
end
relationships do
belongs_to :group, MyApp.Accounts.Group do
writable? true
allow_nil? false
primary_key? true
end
belongs_to :permission, MyApp.Accounts.Permission do
writable? true
allow_nil? false
primary_key? true
end
end
end
defmodule MyApp.Accounts.Permission do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "permissions"
repo MyApp.Repo
end
actions do
default_accept [:name, :resource, :description]
defaults [:create, :read, :update, :destroy]
create :seed_permissions do
description "Seed all application permissions as configured"
accept []
manual MyApp.Accounts.Actions.SeedPermission
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false
attribute :resource, :string, allow_nil?: false
attribute :description, :string
timestamps()
end
relationships do
many_to_many :groups, MyApp.Accounts.Group do
through MyApp.Accounts.GroupPermission
source_attribute_on_join_resource :permission_id
destination_attribute_on_join_resource :group_id
end
end
identities do
identity :unique_action_resource, [:name, :resource]
end
end
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: Hr.Accounts,
data_layer: AshPostgres.DataLayer,
# If using policies, enable the policy authorizer:
# authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
postgres do
table "users"
repo MyApp.Repo
end
authentication do
strategies do
password :password do
identity_field :email
end
end
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret MyApp.Accounts.Secrets
end
end
actions do
default_accept [:email, :hashed_password]
defaults [:create, :read, :destroy, update: :*]
update :add_groups do
description "Adds Groups to a user"
argument :group_ids, {:array, :uuid}
require_atomic? false
change manage_relationship(:group_ids, :groups,
type: :append_and_remove,
on_no_match: :create
)
end
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string do
allow_nil? false
public? true
end
attribute :current_tenant, :string, allow_nil?: true
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
timestamps()
end
relationships do
many_to_many :groups, MyApp.Accounts.Group do
through MyApp.Accounts.UserGroup
source_attribute_on_join_resource :user_id
destination_attribute_on_join_resource :group_id
end
end
identities do
identity :unique_email, [:email]
end
end
defmodule MyApp.Accounts.UserGroup do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "user_groups"
repo MyApp.Repo
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
timestamps()
end
relationships do
belongs_to :group, MyApp.Accounts.Group, allow_nil?: false, primary_key?: true
belongs_to :user, MyApp.Accounts.User, allow_nil?: false, primary_key?: true
end
end
@zachdaniel
Copy link

Additionally, there is now a subject key in the authorizer passed to policy functions, so instead of:

  def match?(actor, %{query: query, changeset: changeset}, _opts) do
    subject = query || changeset

    ...
  end

You can now do

  def match?(actor, %{subject: subject}, _opts) do
    ...
  end

@zachdaniel
Copy link

The simplest example of a policy would likely be something that requires no custom checks.

defmodule MyApp.Blog do
  use Ash.Domain

  resources do
     resource MyApp.Blog.Post
  end
end

defmodule MyApp.Blog.Post do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: MyApp.Blog

  attributes do
    uuid_v7_primary_key :id

    attribute :text, :string, allow_nil?: false
    attribute :published, :boolean, default: false, allow_nil?: false
  end

  actions do
    # default read, create, and update actions
    defaults [:read, create: [:text], update: [:text]]

    # custom update for publishing
    update :publish do
       change set_attribute(:published, true)
    end
  end

  policies do
    policy action_type(:read) do
      description "Admins and writers can see all posts, and everyone else can only see published posts"
      authorize_if actor_attribute_equals(:admin, true)
      authorize_if actor_attribute_equals(:writer, true)

      authorize_if expr(published == true)
    end

    policy action_type([:create, :update]) do 
      description "Admins and writers can create and update posts"  
      authorize_if actor_attribute_equals(:admin, true)
      authorize_if actor_attribute_equals(:writer, true)
    end

    policy action(:publish) do
      description "Only admins can publish posts"
      authorize_if actor_attribute_equals(:admin, true)
    end
  end
end

So now if you were calling actions on this resource:

Ash.create!(MyApp.Blog.Post, %{text: "text"}) # forbidden, no user
Ash.create!(MyApp.Blog.Post, %{text: "text"}, %{id: :fake}) # forbidden, not an admin or a writer

post1 = Ash.create!(MyApp.Blog.Post, %{text: "text"}, %{id: :fake, admin: true}) # authorized, an admin
post2 = Ash.create!(MyApp.Blog.Post, %{text: "text"}, %{id: :fake, writer: true}) # authorized, a writer

Ash.update!(post1, %{text: "new text"})  # forbidden, no user
Ash.update!(post1, %{text: "new text"}, actor: %{id: :fake})  # forbidden, no user
Ash.update!(post1, %{text: "new text"}, %{id: :fake, admin: true}) # authorized, an admin
Ash.update!(post1, %{text: "text text again"}, %{id: :fake, writer: true}) # authorized, a writer

Ash.update!(post2, action: :publish, actor: %{id: :fake, writer: true) # forbidden, a writer
Ash.update!(post2, action: :publish, actor: %{id: :fake, admin: true) # authorized, an admin

Then, for read policies, you can see how they are applied as filters:

# this only returns the published posts for non-logged-in-users
Ash.read!(MyApp.Blog.Post)
# => [%MyApp.Blog.Post{}]

# but for an admin or writer, it returns both
Ash.read!(MyApp.Blog.Post, actor: %{id: :fake, admin: true})
# => [%MyApp.Blog.Post{}]

@zachdaniel
Copy link

To be clear: there is nothing wrong with your original example, it's a good one! Just chiming in with some feedback to potentially make it even better 🙇

@kamaroly
Copy link
Author

kamaroly commented Oct 6, 2024

I like this. Thanks @zachdaniel.

  1. I wonder if it would work for attribute on a relationship of the actor. If yes, how would one access the relationship from actor_attribute_equals.

  2. Also, would this work if one manually authorizes the action by authorize?: true

@zachdaniel
Copy link

  1. right now you can't access relationships via actor_attribute_equals. Anything that does that will have to be a custom check.
  2. authorize?: true doesn't make the action authorized. It determines if authorization occurs. The default value is true. To bypass authorization, you'd need to do authorize?: false.

@kamaroly
Copy link
Author

kamaroly commented Oct 6, 2024

Thanks for the clarificatin, @zachdaniel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment