-
-
Save kamaroly/986c40d1b950373f6577d973a414f01b to your computer and use it in GitHub Desktop.
| 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 |
A few suggestions:
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
endThis is a bit redundant.
policies do
# since you want the policies to always apply, you can use `always()` as the condition.
policy always() do
authorize_if MyApp.Accounts.Checks.Can
end
endAdditionally, instead of using a custom can? from your policies, Ash has an Ash.can? function. For read actions, the default behavior of a policy is to filter, so you will want to do this:
policies do
policy always() do
access_type :strict
authorize_if MyApp.Accounts.Checks.Can
end
endThen, instead of this:
if user |> can?("read", "department") do
socket
|> assign(:departments, MyApp.Organisations.get_departments!(actor: user))
|> ok(layout: {HrWeb.Layouts, :organisation})
else
unauthorised(socket)
endYou can do:
if Ash.can?(user, {Department, :read}) do
socket
|> assign(:departments, MyApp.Organisations.get_departments!(actor: user))
|> ok(layout: {HrWeb.Layouts, :organisation})
else
unauthorised(socket)
endAnd since you are using the code interface, there is a generated can_* function for each code interface function.
if MyApp.Organisations.can_get_departments?(user) do
socket
|> assign(:departments, MyApp.Organisations.get_departments!(actor: user))
|> ok(layout: {HrWeb.Layouts, :organisation})
else
unauthorised(socket)
endBut since the very next thing we do is call the action, I'd do this instead:
case MyApp.Organisations.get_departments(actor: user) do
{:ok, departments} ->
socket
|> assign(:departments, )
|> ok(layout: {HrWeb.Layouts, :organisation})
{:error, %Ash.Error.Forbidden{}} ->
unauthorized(socket)
{:error, error} ->
raise error # you could handle these or raise them
endAdditionally, 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
...
endYou can now do
def match?(actor, %{subject: subject}, _opts) do
...
endThe 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
endSo 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 adminThen, 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?: truedoesn'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.
@zachdaniel, I think Ash.can?(user, {Department, :read}) should be Ash.can?({Department, :read}, user)/ The later seems to work an not the former.
Also, the doc suggests so: https://hexdocs.pm/ash/Ash.html#can?/3
Yep, you’re right :)
How To Use It?
Also in your liveview you can check if the user has permissions like the following. In this example, I used
get_departmentscode interface defined on theMyApp.Organisationsdomain.ok/2andunauthorised/1are liview helpers I wrote to simplify my workflow. They look like the following.