ash/documentation/how-to/authorize-access-to-resources.livemd
2024-05-10 15:28:31 -04:00

297 lines
8.3 KiB
Markdown

<!-- livebook:{"persist_outputs":true} -->
# Authorize Access to Resources
```elixir
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)
```
## Introduction
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](https://hexdocs.pm/ash/policies.html#policies).
## Writing Policies
1. Consider whether or not you want to adopt a specific style of authorization, like [ACL](https://en.wikipedia.org/wiki/Access-control_list), or [RBAC](https://en.wikipedia.org/wiki/Role-based_access_control). For standard RBAC, look into [AshRbac](https://hexdocs.pm/ash_rbac/getting_started.html), and you may not need to write any of your own policies at that point
2. Determine if there are any `bypass` policies to add (admin users, super users, etc.). Consider placing this on the domain, instead of the resource
3. 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
4. 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.
5. Find patterns, like cross-cutting checks that exist in all policies, that can be expressed as smaller, simpler policies
6. Determine if any field policies are required to prohibit access to attributes/calculations/aggregates
7. 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)
## Example
<!-- livebook:{"disable_formatting":true} -->
```elixir
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)
# the 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
```
<!-- livebook:{"output":true} -->
```
{: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,
...
]}
```
## Interacting with resources that have policies
```elixir
# 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)
```
```elixir
# 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
```
<!-- livebook:{"output":true} -->
```
:ok
```
```elixir
# 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
```
<!-- livebook:{"output":true} -->
```
#Ash.ForbiddenField<field: :private_note, type: :attribute, ...>
```
```elixir
Tweet
|> Ash.Policy.Chart.Mermaid.chart()
|> Kino.Shorts.mermaid()
```
<!-- livebook:{"output":true} -->
```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
```