-
-
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
end
This 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
end
Additionally, 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
end
Then, 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)
end
You 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)
end
And 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)
end
But 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
end
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
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.
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_departments
code interface defined on theMyApp.Organisations
domain.ok/2
andunauthorised/1
are liview helpers I wrote to simplify my workflow. They look like the following.