Mix.install(
[
{:ash, "~> 3.0"},
{:simple_sat, "~> 0.1"},
{:kino, "~> 0.12"}
],
consolidate_protocols: false
)
Logger.configure(level: :warning)
Application.put_env(:ash, :policies, show_policy_breakdowns?: true)
A key feature of Ash is the ability to build security directly into your resources. We do this with policies.
Because how you write policies is extremely situational, this how-to guide provides a list of "considerations" as opposed to "instructions".
For more context, read the policies guide.
- Consider whether or not you want to adopt a specific style of authorization, like ACL, or RBAC. For standard RBAC, look into AshRbac, and you may not need to write any of your own policies at that point
- Determine if there are any
bypass
policies to add (admin users, super users, etc.). Consider placing this on the domain, instead of the resource - Begin by making an inventory of each action on your resource, and under what conditions a given actor may be allowed to perform them. If all actions of a given type have the same criteria, we will typically use the
action_type(:type)
condition - Armed with this inventory, begin to write policies. Start simple, write a policy per action type, and add a description of what your policy accomplishes.
- Find patterns, like cross-cutting checks that exist in all policies, that can be expressed as smaller, simpler policies
- Determine if any field policies are required to prohibit access to attributes/calculations/aggregates
- Finally, you can confirm your understanding of the authorization flow for a given resource by generating policy charts with
mix ash.generate_policy_charts
(field policies are not currently included in the generated charts)
defmodule User do
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read, create: [:admin?]]
end
attributes do
uuid_primary_key :id
attribute :admin?, :boolean do
allow_nil? false
default false
end
end
end
defmodule Tweet do
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
attributes do
uuid_primary_key :id
attribute :text, :string do
allow_nil? false
constraints max_length: 144
public? true
end
attribute :hidden?, :boolean do
allow_nil? false
default false
public? true
end
attribute :private_note, :string do
sensitive? true
public? true
end
end
calculations do
calculate :tweet_length, :integer, expr(string_length(text)) do
public? true
end
end
relationships do
belongs_to :user, User, allow_nil?: false
end
actions do
defaults [:read, update: [:text, :hidden?, :private_note]]
create :create do
primary? true
accept [:text, :hidden?, :private_note]
change relate_actor(:user)
end
end
policies do
# action_type-based policies
policy action_type(:read) do
# each policy has a description
description "If a tweet is hidden, only the author can read it. Otherwise, anyone can."
# first check this. If true, then this policy passes
authorize_if relates_to_actor_via(:user)
# then check this. If false, then this policy fails
forbid_if expr(hidden? == true)
# otherwise, this policy passes
authorize_if always()
end
# blanket allow-listing of creates
policy action_type(:create) do
description "Anyone can create a tweet"
authorize_if always()
end
policy action_type(:update) do
description "Only an admin or the user who tweeted can edit their tweet"
# first check this. If true, then this policy passes
authorize_if actor_attribute_equals(:admin?, true)
# then check this. If true, then this policy passes
authorize_if relates_to_actor_via(:user)
# otherwise, there is nothing left to check and no decision, so *this policy fails*
end
end
field_policies do
# anyone can see these fields
field_policy [:text, :tweet_length] do
description "Public tweet fields are visible"
authorize_if always()
end
field_policy [:hidden?, :private_note] do
description "hidden? and private_note are only visible to the author"
authorize_if relates_to_actor_via(:user)
end
end
end
defmodule Domain do
use Ash.Domain,
validate_config_inclusion?: false
resources do
resource Tweet do
define :create_tweet, action: :create, args: [:text]
define :update_tweet, action: :update, args: [:text]
define :list_tweets, action: :read
define :get_tweet, action: :read, get_by: [:id]
end
resource User do
define :create_user, action: :create
end
end
end
{:module, Domain, <<70, 79, 82, 49, 0, 2, 117, ...>>,
[
Ash.Domain.Dsl.Resources.Resource,
Ash.Domain.Dsl.Resources.Options,
Ash.Domain.Dsl,
%{opts: [], entities: [...]},
Ash.Domain.Dsl,
Ash.Domain.Dsl.Resources.Options,
...
]}
# doing forbidden things produces an `Ash.Error.Forbidden`
user = Domain.create_user!()
other_user = Domain.create_user!()
tweet = Domain.create_tweet!("hello world!", actor: user)
Domain.update_tweet!(tweet, "Goodbye world", actor: other_user)
# Reading data applies policies as filters
user = Domain.create_user!()
other_user = Domain.create_user!()
my_hidden_tweet = Domain.create_tweet!("hello world!", %{hidden?: true}, actor: user)
other_users_hidden_tweet =
Domain.create_tweet!("hello world!", %{hidden?: true}, actor: other_user)
my_tweet = Domain.create_tweet!("hello world!", actor: user)
other_users_tweet = Domain.create_tweet!("hello world!", actor: other_user)
tweet_ids = Domain.list_tweets!(actor: user) |> Enum.map(& &1.id)
# I see my own hidden tweets, and other users non-hidden tweets
true = my_hidden_tweet.id in tweet_ids
true = other_users_tweet.id in tweet_ids
# but not other users hidden tweets
false = other_users_hidden_tweet.id in tweet_ids
:ok
:ok
# Field policies return hidden fields as `%Ash.ForbiddenField{}`
user = Domain.create_user!()
other_user = Domain.create_user!()
other_users_tweet =
Domain.create_tweet!("hello world!", %{private_note: "you can't see this!"}, actor: other_user)
%Ash.ForbiddenField{} = Domain.get_tweet!(other_users_tweet.id, actor: user).private_note
#Ash.ForbiddenField<field: :private_note, type: :attribute, ...>
Tweet
|> Ash.Policy.Chart.Mermaid.chart()
|> Kino.Shorts.mermaid()
flowchart TB
subgraph at least one policy applies
direction TB
at_least_one_policy["action.type == :read
or action.type == :create
or action.type == :update"]
end
at_least_one_policy--False-->Forbidden
at_least_one_policy--True-->0_conditions
subgraph Policy 1[If a tweet is hidden, only the author can read it. Otherwise, anyone can.]
direction TB
0_conditions{"action.type == :read"}
0_checks_0{"record.user == actor"}
0_checks_1{"hidden? == true"}
end
0_conditions--True-->0_checks_0
0_conditions--False-->1_conditions
0_checks_0--True-->1_conditions
0_checks_0--False-->0_checks_1
0_checks_1--True-->Forbidden
0_checks_1--False-->1_conditions
subgraph Policy 2[Anyone can create a tweet]
direction TB
1_conditions{"action.type == :create"}
end
subgraph Policy 3[Only an admin or the user who tweeted can edit their tweet]
direction TB
2_conditions{"action.type == :update"}
2_checks_0{"actor.admin? == true"}
2_checks_1{"record.user == actor"}
end
2_conditions--True-->2_checks_0
2_conditions--False-->Authorized
2_checks_0--True-->Authorized
2_checks_0--False-->2_checks_1
2_checks_1--True-->Authorized
2_checks_1--False-->Forbidden
subgraph results[Results]
Authorized([Authorized])
Forbidden([Forbidden])
end
1_conditions--Or-->2_conditions