AF
Ash Framework•3mo ago
EAJ

Use policies to limit allowed values of Enum in create action

This is a follow-up to my question yesterday. Again, I have this Enum:
defmodule Dreng.Accounts.Role do
use Ash.Type.Enum, values: [:superadmin, :admin, :farmer, :farmhand]
end
defmodule Dreng.Accounts.Role do
use Ash.Type.Enum, values: [:superadmin, :admin, :farmer, :farmhand]
end
I realized what I really wanted to build was an invitational system, where :superadmins can invite people with any role, wheras :admins can only invite users with a role of :farmer and :farmhand. This is my invitation resource so far:
defmodule Dreng.Accounts.Invitation do
use Ash.Resource,
otp_app: :dreng,
domain: Dreng.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]

@token_bytes_length 32

postgres do
table "invitations"
repo Dreng.Repo

references do
reference :inviting_user, on_delete: :delete
end
end

actions do
defaults [:read, :destroy]

create :create do
accept [:email, :role]

# 👇 this should only apply if the inviting user isn't a :superadmin
validate attribute_in(:role, [:farmer, :farmhand])
end
end

policies do
policy action_type(:read) do
authorize_if always()
end

policy action_type(:create) do
authorize_if actor_attribute_equals(:role, :admin)
authorize_if actor_attribute_equals(:role, :superadmin)
end
end

attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
attribute :role, Dreng.Accounts.Role

attribute :token, :string do
allow_nil? false
sensitive? true

default fn ->
:crypto.strong_rand_bytes(@token_bytes_length)
|> Base.url_encode64()
|> binary_part(0, @token_bytes_length)
end
end
end

relationships do
belongs_to :inviting_user, Dreng.Accounts.User
end
end
defmodule Dreng.Accounts.Invitation do
use Ash.Resource,
otp_app: :dreng,
domain: Dreng.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]

@token_bytes_length 32

postgres do
table "invitations"
repo Dreng.Repo

references do
reference :inviting_user, on_delete: :delete
end
end

actions do
defaults [:read, :destroy]

create :create do
accept [:email, :role]

# 👇 this should only apply if the inviting user isn't a :superadmin
validate attribute_in(:role, [:farmer, :farmhand])
end
end

policies do
policy action_type(:read) do
authorize_if always()
end

policy action_type(:create) do
authorize_if actor_attribute_equals(:role, :admin)
authorize_if actor_attribute_equals(:role, :superadmin)
end
end

attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
attribute :role, Dreng.Accounts.Role

attribute :token, :string do
allow_nil? false
sensitive? true

default fn ->
:crypto.strong_rand_bytes(@token_bytes_length)
|> Base.url_encode64()
|> binary_part(0, @token_bytes_length)
end
end
end

relationships do
belongs_to :inviting_user, Dreng.Accounts.User
end
end
(Continued in comments)
Solution:
Give the SimpleCheck a go and let us know if it helps
Jump to solution
12 Replies
EAJ
EAJOP•3mo ago
The relevant line is the validation of the :create action. Can I conditionally apply the validation given the actor performing the action? Would it instead be more appropriate to define a second action, e.g. :create_admin_invitation which doesn't have the limitation but is restricted to :superadmin users with policies? And, as an aside, does it even make sense to have this as a separate resource, or would I be better off hooking into the token system of ash_auth somehow?
barnabasj
barnabasj•3mo ago
You can do it conditionally if you create your own Custom Validation what exacly are you doing with the invitation? I would probably combine invitations with magic links. Like you can disable registration for magic link in ash_authentication and then you would just need to create a user send a link and they would just sign in with magic link then. You don't really control the time between when you send the invitation and when the user does something with it, so having tokens that live that long might not be the best idea
Chaz Watkins
Chaz Watkins•3mo ago
You probably want two policies If you want admins and super_admins to do anything, use a bypass
bypass actor_attribute_equals(:role, :super_admin) do
authorize_if always()
end

policy action_type(:create) do
authorize_if expr(role in [:farmer, :farmhand])
end
bypass actor_attribute_equals(:role, :super_admin) do
authorize_if always()
end

policy action_type(:create) do
authorize_if expr(role in [:farmer, :farmhand])
end
I think this should work @barnabasj What do you think?
barnabasj
barnabasj•3mo ago
expr don't work with creates
Chaz Watkins
Chaz Watkins•3mo ago
oh damn, I forgot lol Have to use the SimpleCheck
barnabasj
barnabasj•3mo ago
That would work
Chaz Watkins
Chaz Watkins•3mo ago
Yeah, create a simple check module that takes the role or role as opts. Then you can return true or false
barnabasj
barnabasj•3mo ago
validation vs policy, you could argue both ways I think
Chaz Watkins
Chaz Watkins•3mo ago
defmodule MyApp.Checks.InvitationRoleIs do
use Ash.Checks.SimpleCheck

@impl Ash.Checks.SimpleCheck
def match?(_actor, %{subject: %Ash.Changeset{} = changeset}, opts) do
roles = List.wrap(opts[:roles])

invitation_role = Ash.Changeset.get_attribute(changeset, :role)

{:ok, invitation_role in roles}
end
end
defmodule MyApp.Checks.InvitationRoleIs do
use Ash.Checks.SimpleCheck

@impl Ash.Checks.SimpleCheck
def match?(_actor, %{subject: %Ash.Changeset{} = changeset}, opts) do
roles = List.wrap(opts[:roles])

invitation_role = Ash.Changeset.get_attribute(changeset, :role)

{:ok, invitation_role in roles}
end
end
policy [action_type(:create), actor_attribute_equals(:role, :admin)] do
authorize_if {MyApp.Checks.InvitationRoleIs, roles: [:farmer, :farmhand]}
end
policy [action_type(:create), actor_attribute_equals(:role, :admin)] do
authorize_if {MyApp.Checks.InvitationRoleIs, roles: [:farmer, :farmhand]}
end
Yeah, either can work. I like going with Policies whenever possible because then you can leverage Ash.can? for showing actions in the UI
Solution
Chaz Watkins
Chaz Watkins•3mo ago
Give the SimpleCheck a go and let us know if it helps
EAJ
EAJOP•3mo ago
Thanks! Walking the dog right now, will report back later 🙂 Alright, so I ended up with a SimpleCheck like this:
defmodule Dreng.Checks.OnlyAllowedRoles do
use Ash.Policy.SimpleCheck

@impl true
def describe(_) do
"Verify that only allowed roles are being assigned."
end

@impl true
def match?(_actor, %{subject: %Ash.Changeset{} = changeset}, opts) do
allowed_roles = opts[:roles]
{:ok, changeset.attributes.role in allowed_roles}
end

@impl true
def match?(_, _, _), do: {:ok, true}
end
defmodule Dreng.Checks.OnlyAllowedRoles do
use Ash.Policy.SimpleCheck

@impl true
def describe(_) do
"Verify that only allowed roles are being assigned."
end

@impl true
def match?(_actor, %{subject: %Ash.Changeset{} = changeset}, opts) do
allowed_roles = opts[:roles]
{:ok, changeset.attributes.role in allowed_roles}
end

@impl true
def match?(_, _, _), do: {:ok, true}
end
And I removed the validation from the create action and updated the policies to the following:
policies do
bypass actor_attribute_equals(:role, :superadmin) do
authorize_if always()
end

policy action_type(:read) do
authorize_if always()
end

policy action_type(:create) do
forbid_unless actor_attribute_equals(:role, :admin)
authorize_if {Dreng.Checks.OnlyAllowedRoles, roles: [:farmer, :farmhand]}
end
end
policies do
bypass actor_attribute_equals(:role, :superadmin) do
authorize_if always()
end

policy action_type(:read) do
authorize_if always()
end

policy action_type(:create) do
forbid_unless actor_attribute_equals(:role, :admin)
authorize_if {Dreng.Checks.OnlyAllowedRoles, roles: [:farmer, :farmhand]}
end
end
Seems to work like it should! This is a good point. I could probably add an expires_at that defaults to 72 hours or something, but maybe hooking into the magic link is easier? It is an invite-only service, and invitations should be sent by mail with, yeah, a magic link. When clicking it the user will be prompted to fill out their profile and register.
Chaz Watkins
Chaz Watkins•3mo ago
Glad you got it working!

Did you find this page helpful?