Created
October 6, 2024 09:37
-
-
Save kamaroly/986c40d1b950373f6577d973a414f01b to your computer and use it in GitHub Desktop.
How To Use Ash framework in Phoenix with user permission example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
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{}]
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 🙇
I like this. Thanks @zachdaniel.
-
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. -
Also, would this work if one manually authorizes the action by
authorize?: true
- right now you can't access relationships via
actor_attribute_equals
. Anything that does that will have to be a custom check. authorize?: true
doesn't make the action authorized. It determines if authorization occurs. The default value istrue
. To bypass authorization, you'd need to doauthorize?: false
.
Thanks for the clarificatin, @zachdaniel.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Additionally, there is now a
subject
key in the authorizer passed to policy functions, so instead of:You can now do