improvement: move ash_policy_authorizer into core as Ash.Policy.Authorizer

This commit is contained in:
Zach Daniel 2022-05-17 15:56:40 -04:00
parent 6b9776a7fb
commit b3e0632792
46 changed files with 3056 additions and 17 deletions

View file

@ -157,9 +157,10 @@
#
# Controversial and experimental checks (opt-in, just replace `false` with `[]`)
#
{Credo.Check.Readability.StrictModuleLayout,
order: [:shortdoc, :moduledoc, :behaviour, :use, :defstruct, :type, :import, :alias, :require],
ignore: [:module_attribute, :type]},
# {Credo.Check.Readability.StrictModuleLayout,
# order: [:shortdoc, :moduledoc, :behaviour, :use, :defstruct, :type, :import, :alias, :require],
# ignore: [:module_attribute, :type]},
{Credo.Check.Readability.StrictModuleLayout, false},
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
{Credo.Check.Consistency.UnusedVariableNames, false},
{Credo.Check.Design.DuplicatedCode, false},

View file

@ -2,6 +2,7 @@
# DONT MODIFY IT BY HAND
locals_without_parens = [
accept: 1,
access_type: 1,
action: 1,
allow: 1,
allow_async?: 1,
@ -17,10 +18,16 @@ locals_without_parens = [
attribute: 1,
attribute: 2,
attribute: 3,
authorize_if: 1,
authorize_if: 2,
authorize_unless: 1,
authorize_unless: 2,
base_filter: 1,
before_action?: 1,
belongs_to: 2,
belongs_to: 3,
bypass: 1,
bypass: 2,
calculate: 3,
calculate: 4,
change: 1,
@ -40,6 +47,7 @@ locals_without_parens = [
debug: 1,
debug: 2,
default: 1,
default_access_type: 1,
default_context: 1,
defaults: 1,
define: 1,
@ -66,6 +74,10 @@ locals_without_parens = [
filterable?: 1,
first: 3,
first: 4,
forbid_if: 1,
forbid_if: 2,
forbid_unless: 1,
forbid_unless: 2,
generated?: 1,
get?: 1,
get_by: 1,
@ -104,6 +116,8 @@ locals_without_parens = [
output: 1,
pagination: 1,
parse_attribute: 1,
policy: 1,
policy: 2,
pre_check_with: 1,
prefix: 1,
prepare: 1,

View file

@ -99,14 +99,7 @@ jobs:
strategy:
fail-fast: false
matrix:
project:
[
"ash_postgres",
"ash_csv",
"ash_graphql",
"ash_json_api",
"ash_policy_authorizer",
]
project: ["ash_postgres", "ash_csv", "ash_graphql", "ash_json_api"]
otp: ["23"]
elixir: ["1.13.1"]
services:

View file

@ -44,14 +44,14 @@ end
### Authorizers
- [AshPolicyAuthorizer](https://hexdocs.pm/ash_policy_authorizer)
- [Ash.Policy.Authorizer (builtin)](https://hexdocs.pm/ash/Ash.Policy.Authorizer.html)
### Datalayers
- [AshPostgres](https://hexdocs.pm/ash_postgres)
- [AshCsv](https://hexdocs.pm/ash_csv)
- [Ets (built-in)](https://hexdocs.pm/ash/Ash.DataLayer.Ets.html) - Only used for testing
- [Mnesia (built-in)](https://hexdocs.pm/ash/Ash.DataLayer.Mnesia.html)
- [Ets (built-in)](https://hexdocs.pm/ash/Ash.DataLayer.Ets.html) - Only used for testing/prototyping
- [Mnesia (built-in)](https://hexdocs.pm/ash/Ash.DataLayer.Mnesia.html) - Only used for testing/prototyping
## Introduction

View file

@ -0,0 +1,152 @@
# Policies
Policies determine what actions on a resource are permitted for a given actor.
You can specify an actor using the code api via the `actor` option, like so:
```elixir
MyApp.MyApi.read(MyResource, actor: current_user)
```
## Important!
Before we jump into the guide, it is critical to understand that the policy code doesn't actually
_do_ anything in the classic sense. It simply builds up a set of policies that are stored for use later.
The checker that reads those policies and authorizes requests may run all, some of, or none of your checks,
depending on the details of the request being authorized.
## Guide
To see what checks are built-in, see `Ash.Policy.Check.BuiltInChecks`
### The Simplest Policy
Lets start with the simplest policy set:
```elixir
policies do
policy always() do
authorize_if always()
end
end
```
Here, we have a single policy. The first argument to `policy` is the "condition". If the condition is true,
then the policy applies to the request. If a given policy applies, then one of the checks inside the policy must authorize that policy. _Every policy that applies_ to a given request must each be authorized for a request to be authorized.
Within this policy we have a single check, declared with `authorize_if`. Checks logically apply from top to bottom, based on their check type. In this case, we'd read the policy as "this policy always applies, and authorizes always".
There are four check types, all of which do what they sound like they do:
- `authorize_if` - if the check is true, the policy is authorized.
- `authorize_unless` - if the check is false, the policy is authorized.
- `forbid_if` - if the check is true, the policy is forbidden.
- `forbid_unless` - if the check is false, the policy is forbidden.
In each case, if the policy is not authorized or forbidden, the flow moves to the next check.
### A realistic policy
In this example, we use some of the provided built in checks.
```elixir
policies do
# Anything you can use in a condition, you can use in a check, and vice-versa
# This policy applies if the actor is a super_user
# Additionally, this policy is declared as a `bypass`. That means that this check is allowed to fail without
# failing the whole request, and that if this check *passes*, the entire request passes.
bypass actor_attribute_equals(:super_user, true) do
authorize_if always()
end
# This will likely be a common occurrence. Specifically, policies that apply to all read actions
policy action_type(:read) do
# unless the actor is an active user, forbid their request
forbid_unless actor_attribute_equals(:active, true)
# if the record is marked as public, authorize the request
authorize_if attribute(:public, true)
# if the actor is related to the data via that data's `owner` relationship, authorize the request
authorize_if relates_to_actor_via(:owner)
end
end
```
### Access Type
The default access type is `:filter`. In most cases this will be all you need. In the example above, if a user made a request for all instances
of the resource, it wouldn't actually return a forbidden error. It simply attaches the appropriate filter to fetch data that the user can see.
If the actor attribute `active` was `false`, then the request _would_ be forbidden (because there is no data for which they can pass this policy). However, if `active` is `true`, the authorizer would attach the following filter to the request:
```elixir
public or owner == actor(:_primary_key)
```
To understand what `actor(:_primary_key)` means, see the Filter Templates section in `Ash.Filter`
To change this behavior, use `access_type :strict`. With `access_type :strict` you will force the request to fail unless a filter was provided to yield the appropriate data. In this case, any filter that is a subset of the authorization filter would work. For example: `[public: true]`, or `[owner: [id: current_user.id]]`.
Additionally, some checks have more expensive components that can't be checked before the request is run. To enable those, use the `access_type :runtime`. This is still relatively experimental, but this will attempt to run as much of your checks in a strict fashion, and attach as many things as filters as possible, before running the expensive portion of the checks (defined on the check as `c:Ash.Policy.Check.check/4`)
### Custom checks
See `Ash.Policy.Check` for more information on writing custom checks, which you will likely need at some point when the built in checks are insufficient
## Policy Breakdowns
## Explanation
Policy breakdowns can be fetched on demand for a given forbidden error (either an `Ash.Error.Forbidden` that contains one ore more `Ash.Error.Forbidden.Policy`
errors, or an `Ash.Error.Forbidden.Policy` error itself), via `Ash.Policy.Forbidden.Error.report/2`.
Here is an example policy breakdown from tests:
```text
Policy Breakdown
A check status of `?` implies that the solver did not need to determine that check.
Some checks may look like they failed when in reality there was simply no need to check them.
Look for policies with `✘` and `✓` in check statuses.
A check with a `⬇` means that it didn't determine if the policy was authorized or forbidden, and so moved on to the next check.
`🌟` and `⛔` mean that the check was responsible for producing an authorized or forbidden (respectively) status.
If no check results in a status (they all have `⬇`) then the policy is assumed to have failed. In some cases, however, the policy
may have just been ignored, as described above.
Admins and managers can create posts | ⛔:
authorize if: actor.admin == true | ✘ | ⬇
authorize if: actor.manager == true | ✘ | ⬇
```
To remove the help text, you can pass the `help_text?: false` option, which would leave you with:
```text
Policy Breakdown
Admins and managers can create posts | ⛔:
authorize if: actor.admin == true | ✘ | ⬇
authorize if: actor.manager == true | ✘ | ⬇
```
## Including in error message
### **IMPORTANT WARNING**
The following configuration should only ever be used in development mode!
### Instructions
For security reasons, authorization errors don't include any extra information, aside from `forbidden`. To have authorization errors include a policy breakdown (without help text)
use the following config.
```elixir
config :ash, :policies, show_policy_breakdowns?: true
```
## Logging
It is generally safe to log authorization error details, even in production. This can be very helpful when investigating certain classes of issue.
To have ash automatically log each authorization failure, use
```elixir
config :ash, :policies, log_policy_breakdowns: :error # Use whatever log level you'd like to use here
```

View file

@ -842,9 +842,9 @@ defmodule Ash.Engine.Request do
Logger.error("""
Could not apply filter policy because it cannot be checked using Ash.Filter.Runtime: #{inspect(filter)}.
If you are using ash_policy_authorizer policy must include a filter like this, try setting the access_type to `:runtime`"
If you are using policies and the policy must include a filter like this, try setting the access_type to `:runtime`"
Otherwise, please report this issue: https://github.com/ash-project/ash_policy_authorizer/issues/new?assignees=&labels=bug%2C+needs+review&template=bug_report.md&title=
Otherwise, please report this issue.
""")
{:error,

View file

@ -0,0 +1,280 @@
defmodule Ash.Error.Forbidden.Policy do
@moduledoc "Raised when policy authorization for an action fails"
require Logger
use Ash.Error.Exception
alias Ash.Policy.Policy
def_ash_error(
[
scenarios: [],
facts: %{},
filter: nil,
policy_breakdown?: false,
must_pass_strict_check?: false,
policies: [],
resource: nil,
action: nil
],
class: :forbidden
)
def exception(opts) do
exception =
super(Keyword.put(opts, :policy_breakdown?, Ash.Policy.Info.show_policy_breakdowns?()))
case Ash.Policy.Info.log_policy_breakdowns() do
nil ->
:ok
level ->
Logger.log(level, report(exception, help_text?: false))
end
exception
end
@help_text """
A check status of `?` implies that the solver did not need to determine that check.
Some checks may look like they failed when in reality there was simply no need to check them.
Look for policies with `` and `` in check statuses.
A check with a `` means that it didn't determine if the policy was authorized or forbidden, and so moved on to the next check.
`🌟` and `` mean that the check was responsible for producing an authorized or forbidden (respectively) status.
If no check results in a status (they all have ``) then the policy is assumed to have failed. In some cases, however, the policy
may have just been ignored, as described above.
"""
@doc """
Print a report of an authorization failure
Options:
- `:help_text?`: Defaults to true. Displays help text at the top of the policy breakdown.
"""
def report(error, opts \\ []) do
error
|> get_errors()
|> case do
[] ->
"No policy errors"
errors ->
error_lines =
errors
|> Enum.map(fn
%{
facts: facts,
filter: filter,
policies: policies,
must_pass_strict_check?: must_pass_strict_check?
} ->
must_pass_strict_check? =
if must_pass_strict_check? do
"""
Scenario must pass strict check only, meaning `runtime` policies cannot be checked.
This requirement is generally used for filtering on related resources, when we can't fetch those
related resources to run `runtime` policies. For this reason, you generally want your primary read
actions on your resources to have standard policies which can be checked statically (like `actor_attribute_equals`)
in addition to filter policies, like `expr(foo == :bar)`.
"""
else
""
end
policy_breakdown_title =
if Keyword.get(opts, :help_text?, true) do
["Policy Breakdown", @help_text]
else
"Policy Breakdown"
end
policy_explanation =
policies
|> Enum.filter(&relevant?(&1, facts))
|> Enum.map(&explain_policy(&1, facts))
|> Enum.intersperse("\n")
|> title(policy_breakdown_title, false)
filter =
if filter do
title(
"Did not match filter expression #{inspect(filter)}",
"Generated Filter"
)
else
""
end
[must_pass_strict_check?, filter, policy_explanation]
|> Enum.filter(& &1)
|> Enum.intersperse("\n\n")
end)
|> Enum.intersperse("\n\n")
[title_line(error), "\n", error_lines]
|> IO.iodata_to_binary()
|> String.trim()
end
end
defp title_line(error) do
cond do
error.resource && error.action ->
"#{inspect(error.resource)}.#{action_name(error.action)}"
error.resource ->
"#{inspect(error.resource)}"
error.action ->
"#{action_name(error.action)}"
end
end
defp action_name(%{name: name}), do: name
defp action_name(name), do: name
defp relevant?(policy, facts) do
Enum.all?(policy.condition, fn condition ->
Policy.fetch_fact(facts, condition) == {:ok, true}
end)
end
defp title(other, title, semicolon \\ true)
defp title([], _, _), do: []
defp title(other, title, true), do: [title, ":\n", other]
defp title(other, title, false), do: [title, "\n", other]
defp explain_policy(policy, facts) do
bypass =
if policy.bypass? do
"Bypass: "
else
""
end
{description, state} = describe_checks(policy.policies, facts)
tag =
case state do
:unknown ->
""
:authorized ->
"🌟"
:forbidden ->
""
end
title(Enum.map(description, &[" ", &1]), [
" ",
bypass,
policy.description || "Policy",
" | ",
tag
])
end
defp describe_checks(checks, facts) do
{description, state} =
Enum.reduce(checks, {[], :unknown}, fn check, {descriptions, state} ->
new_state =
case state do
:unknown ->
new_state(
check.type,
Policy.fetch_fact(facts, check.check)
)
other ->
other
end
tag =
case {state, new_state} do
{:unknown, :authorized} ->
"🌟"
{:unknown, :forbidden} ->
""
{:unknown, :unknown} ->
""
_ ->
""
end
{[describe_check(check, Policy.fetch_fact(facts, check.check), tag) | descriptions],
new_state}
end)
{Enum.intersperse(Enum.reverse(description), "\n"), state}
end
defp describe_check(check, fact_result, tag) do
fact_result =
case fact_result do
{:ok, true} ->
""
{:ok, false} ->
""
:error ->
"?"
end
[
check_type(check),
": ",
check.check_module.describe(check.check_opts),
" | ",
fact_result,
" | ",
tag
]
end
defp check_type(%{type: :authorize_if}), do: "authorize if"
defp check_type(%{type: :forbid_if}), do: "forbid if"
defp check_type(%{type: :authorize_unless}), do: "authorize unless"
defp check_type(%{type: :forbid_unless}), do: "forbid unless"
defp new_state(:authorize_if, {:ok, true}), do: :authorized
defp new_state(:forbid_if, {:ok, true}), do: :forbidden
defp new_state(:authorize_unless, {:ok, false}), do: :authorized
defp new_state(:forbid_unless, {:ok, false}), do: :forbidden
defp new_state(_, _), do: :unknown
defp get_errors(%Ash.Error.Forbidden{errors: errors}) do
Enum.flat_map(errors || [], fn error ->
get_errors(error)
end)
end
defp get_errors(%__MODULE__{} = error) do
[error]
end
defp get_errors(_), do: []
defimpl Ash.ErrorKind do
def id(_), do: Ecto.UUID.generate()
def message(error) do
if error.policy_breakdown? do
"forbidden:\n\n#{Ash.Error.Forbidden.Policy.report(error, help_text?: false)}"
else
"forbidden"
end
end
def code(_), do: "Forbidden"
end
end

View file

@ -0,0 +1,736 @@
defmodule Ash.Policy.Authorizer do
defstruct [
:actor,
:resource,
:query,
:changeset,
:data,
:action,
:api,
:verbose?,
:scenarios,
:real_scenarios,
:check_scenarios,
policies: [],
facts: %{true => true, false => false},
data_facts: %{}
]
@type t :: %__MODULE__{}
alias Ash.Policy.{Checker, Policy}
@check_schema [
check: [
type: {:custom, __MODULE__, :validate_check, []},
required: true,
doc: """
A check is a tuple of `{module, keyword}`.
The module must implement the `Ash.Policy.Check` behaviour.
Generally, you won't be passing `{module, opts}`, but will use one
of the provided functions that return that, like `always()` or
`actor_attribute_matches_record(:foo, :bar)`. To make custom ones
define a module that implements the `Ash.Policy.Check` behaviour,
put a convenience function in that module that returns {module, opts}, and
import that into your resource.
```elixir
defmodule MyResource do
use Ash.Resource, authorizers: [Ash.Policy.Authorizer]
import MyCustomCheck
policies do
...
policy do
authorize_if my_custom_check(:foo)
end
end
end
```
"""
],
name: [
type: :string,
required: false,
doc: "A short name or description for the check, used when explaining authorization results"
]
]
@authorize_if %Ash.Dsl.Entity{
name: :authorize_if,
describe: "If the check is true, the request is authorized, otherwise run remaining checks.",
args: [:check],
schema: @check_schema,
examples: [
"authorize_if logged_in()",
"authorize_if actor_attribute_matches_record(:group, :group)"
],
target: Ash.Policy.Check,
transform: {Ash.Policy.Check, :transform, []},
auto_set_fields: [
type: :authorize_if
]
}
@forbid_if %Ash.Dsl.Entity{
name: :forbid_if,
describe: "If the check is true, the request is forbidden, otherwise run remaining checks.",
args: [:check],
schema: @check_schema,
target: Ash.Policy.Check,
transform: {Ash.Policy.Check, :transform, []},
examples: [
"forbid_if not_logged_in()",
"forbid_if actor_attribute_matches_record(:group, :blacklisted_groups)"
],
auto_set_fields: [
type: :forbid_if
]
}
@authorize_unless %Ash.Dsl.Entity{
name: :authorize_unless,
describe: "If the check is false, the request is authorized, otherwise run remaining checks.",
args: [:check],
schema: @check_schema,
target: Ash.Policy.Check,
transform: {Ash.Policy.Check, :transform, []},
examples: [
"authorize_unless not_logged_in()",
"authorize_unless actor_attribute_matches_record(:group, :blacklisted_groups)"
],
auto_set_fields: [
type: :authorize_unless
]
}
@forbid_unless %Ash.Dsl.Entity{
name: :forbid_unless,
describe: "If the check is true, the request is forbidden, otherwise run remaining checks.",
args: [:check],
schema: @check_schema,
target: Ash.Policy.Check,
transform: {Ash.Policy.Check, :transform, []},
examples: [
"forbid_unless logged_in()",
"forbid_unless actor_attribute_matches_record(:group, :group)"
],
auto_set_fields: [
type: :forbid_unless
]
}
@policy %Ash.Dsl.Entity{
name: :policy,
describe: """
A policy has a name, a condition, and a list of checks.
Checks apply logically in the order they are specified, from top to bottom.
If no check explicitly authorizes the request, then the request is forbidden.
This means that, if you want to "blacklist" instead of "whitelist", you likely
want to add an `authorize_if always()` at the bottom of your policy, like so:
```elixir
policy action_type(:read) do
forbid_if not_logged_in()
forbid_if user_is_denylisted()
forbid_if user_is_in_denylisted_group()
authorize_if always()
end
```
If the policy should always run, use the `always()` check, like so:
```elixir
policy always() do
...
end
```
""",
schema: [
description: [
type: :string,
doc: "A description for the policy, used when explaining authorization results"
],
access_type: [
type: {:one_of, [:strict, :filter, :runtime]},
doc: """
There are three choices for access_type:
* `:strict` - authentication uses *only* the request context, failing when unknown.
* `:filter` - this is probably what you want. Automatically removes unauthorized data by altering the request filter.
* `:runtime` - tries to add a filter before the query, but if it cannot, it fetches the records and checks authorization.
Be careful with runtime checks, as they can potentially cause a given scenario to fetch *all* records of a resource, because
it can't figure out a common filter between all of the possible scenarios. Use sparingly, if at all.
"""
],
condition: [
type: {:custom, __MODULE__, :validate_condition, []},
doc: """
A check or list of checks that must be true in order for this policy to apply.
If the policy does not apply, it is not run, and some other policy
will need to authorize the request. If no policies apply, the request
is forbidden. If multiple policies apply, they must each authorize the
request.
"""
]
],
args: [:condition],
target: Ash.Policy.Policy,
entities: [
policies: [
@authorize_if,
@forbid_if,
@authorize_unless,
@forbid_unless
]
]
}
@bypass %{
@policy
| name: :bypass,
auto_set_fields: [bypass?: true],
describe:
"A policy that, if passed, will skip all following policies. If failed, authorization moves on to the next policy"
}
@policies %Ash.Dsl.Section{
name: :policies,
describe: """
A section for declaring authorization policies.
Each policy that applies must pass independently in order for the
request to be authorized.
""",
examples: [
"""
policies do
# Anything you can use in a condition, you can use in a check, and vice-versa
# This policy applies if the actor is a super_user
# Addtionally, this policy is declared as a `bypass`. That means that this check is allowed to fail without
# failing the whole request, and that if this check *passes*, the entire request passes.
bypass actor_attribute_equals(:super_user, true) do
authorize_if always()
end
# This will likely be a common occurrence. Specifically, policies that apply to all read actions
policy action_type(:read) do
# unless the actor is an active user, forbid their request
forbid_unless actor_attribute_equals(:active, true)
# if the record is marked as public, authorize the request
authorize_if attribute(:public, true)
# if the actor is related to the data via that data's `owner` relationship, authorize the request
authorize_if relates_to_actor_via(:owner)
end
end
"""
],
entities: [
@policy,
@bypass
],
imports: [
Ash.Policy.Check.BuiltInChecks,
Ash.Filter.TemplateHelpers
],
schema: [
default_access_type: [
type: {:one_of, [:strict, :filter, :runtime]},
default: :filter,
doc: """
The default access type of policies for this resource.
See the access type on individual policies for more information.
"""
]
]
}
@sections [@policies]
@moduledoc """
An authorization extension for ash resources.
To add this extension to a resource, add it to the list of `authorizers` like so:
```elixir
use Ash.Resource,
...,
authorizers: [
Ash.Policy.Authorizer
]
```
# DSL Documenation
## Table of Contents
#{Ash.Dsl.Extension.doc_index(@sections)}
#{Ash.Dsl.Extension.doc(@sections)}
A resource can be given a set of policies, which are enforced on each call to a resource action.
For reads, policies can be configured to filter out data that the actor shouldn't see, as opposed to
resulting in a forbidden error.
See the [policy writing guide](policies.md) for practical examples.
Policies are solved/managed via a boolean satisfiability solver. To read more about boolean satisfiability,
see this page: https://en.wikipedia.org/wiki/Boolean_satisfiability_problem. At the end of
the day, however, it is not necessary to understand exactly how Ash takes your
authorization requirements and determines if a request is allowed. The
important thing to understand is that Ash may or may not run any/all of your
authorization rules as they may be deemed unnecessary. As such, authorization
checks should have no side effects. Ideally, the checks built-in to ash should
cover the bulk of your needs.
"""
require Logger
@behaviour Ash.Authorizer
use Ash.Dsl.Extension, sections: @sections
@impl true
def exception({:changeset_doesnt_match_filter, filter}, state) do
Ash.Error.Forbidden.Policy.exception(
scenarios: Map.get(state, :scenarios),
facts: Map.get(state, :facts),
policies: Map.get(state, :policies),
resource: Map.get(state, :resource),
action: Map.get(state, :action),
filter: filter
)
end
def exception(:must_pass_strict_check, state) do
Ash.Error.Forbidden.Policy.exception(
scenarios: Map.get(state, :scenarios),
facts: Map.get(state, :facts),
policies: Map.get(state, :policies),
resource: Map.get(state, :resource),
action: Map.get(state, :action),
must_pass_strict_check?: true
)
end
def exception(_, state) do
Ash.Error.Forbidden.Policy.exception(
scenarios: Map.get(state, :scenarios),
facts: Map.get(state, :facts),
policies: Map.get(state, :policies),
resource: Map.get(state, :resource),
action: Map.get(state, :action),
must_pass_strict_check?: true
)
end
@doc false
def validate_check({module, opts}) when is_atom(module) and is_list(opts) do
{:ok, {module, opts}}
end
def validate_check(module) when is_atom(module) do
validate_check({module, []})
end
def validate_check(other) do
{:ok, {Ash.Policy.Check.Expression, expr: other}}
end
def validate_condition(conditions) when is_list(conditions) do
Enum.reduce_while(conditions, {:ok, []}, fn condition, {:ok, conditions} ->
{:ok, {condition, opts}} = validate_check(condition)
if Ash.Helpers.implements_behaviour?(condition, Ash.Policy.Check) do
{:cont, {:ok, [{condition, opts} | conditions]}}
else
{:halt, {:error, "Expected all conditions to be valid checks"}}
end
end)
end
@doc false
def validate_condition(condition) do
validate_condition([condition])
end
@impl true
def initial_state(actor, resource, action, verbose?) do
%__MODULE__{
resource: resource,
actor: actor,
action: action,
verbose?: verbose?
}
end
@impl true
def strict_check_context(_authorizer) do
[:query, :changeset, :api, :resource]
end
@impl true
def check_context(_authorizer) do
[:query, :changeset, :data, :api, :resource]
end
@impl true
def check(authorizer, context) do
check_result(%{authorizer | data: context.data})
end
@impl true
def strict_check(authorizer, context) do
%{
authorizer
| query: context.query,
changeset: context.changeset,
api: context.api
}
|> get_policies()
|> do_strict_check_facts()
|> case do
{:ok, authorizer} ->
strict_check_result(authorizer)
{:error, error} ->
{:error, error}
end
end
defp strict_filter(authorizer) do
{filterable, require_check} =
authorizer.scenarios
|> Enum.split_with(fn scenario ->
scenario
|> Enum.reject(fn {{check_module, opts}, _} ->
opts[:access_type] == :filter ||
match?(
{:ok, _},
Ash.Policy.Policy.fetch_fact(authorizer.facts, {check_module, opts})
) || check_module.type() == :filter
end)
|> Enum.empty?()
end)
filter = strict_filters(filterable, authorizer)
case {filter, require_check} do
{[], []} ->
:authorized
{_filters, []} ->
case filter do
[filter] ->
log(authorizer, "filtering with: #{inspect(filter)}, authorization complete")
{:filter, authorizer, filter}
filters ->
log(authorizer, "filtering with: #{inspect(or: filter)}, authorization complete")
{:filter, authorizer, [or: filters]}
end
{_filters, _require_check} ->
case global_filters(authorizer) do
nil ->
maybe_forbid_strict(authorizer)
{[single_filter], scenarios_without_global} ->
log(
authorizer,
"filtering with: #{inspect(single_filter)}, continuing authorization process"
)
{:filter_and_continue, single_filter,
%{authorizer | check_scenarios: scenarios_without_global}}
{filters, scenarios_without_global} ->
log(
authorizer,
"filtering with: #{inspect(and: filters)}, continuing authorization process"
)
{:filter_and_continue, [and: filters],
%{authorizer | check_scenarios: scenarios_without_global}}
end
end
end
defp strict_filters(filterable, authorizer) do
filterable
|> Enum.reduce([], fn scenario, or_filters ->
scenario
|> Enum.filter(fn {{check_module, check_opts}, _} ->
check_module.type() == :filter && check_opts[:access_type] in [:filter, :runtime]
end)
|> Enum.reject(fn {{check_module, check_opts}, result} ->
match?({:ok, ^result}, Policy.fetch_fact(authorizer.facts, {check_module, check_opts}))
end)
|> Enum.map(fn
{{check_module, check_opts}, true} ->
check_module.auto_filter(authorizer.actor, authorizer, check_opts)
{{check_module, check_opts}, false} ->
check_module.auto_filter_not(authorizer.actor, authorizer, check_opts)
end)
|> case do
[] ->
or_filters
[single] ->
[single | or_filters]
filters ->
[[and: filters] | or_filters]
end
end)
end
defp maybe_forbid_strict(authorizer) do
log(authorizer, "could not determine authorization filter, checking at runtime")
{:continue, %{authorizer | check_scenarios: authorizer.scenarios}}
end
defp global_filters(authorizer, scenarios \\ nil, filter \\ []) do
scenarios = scenarios || authorizer.scenarios
global_check_value =
Enum.find_value(scenarios, fn scenario ->
Enum.find(scenario, fn {{check_module, _opts} = check, value} ->
check_module.type == :filter &&
Enum.all?(scenarios, &(Map.fetch(&1, check) == {:ok, value}))
end)
end)
case global_check_value do
nil ->
case filter do
[] ->
nil
filter ->
{filter, scenarios}
end
{{check_module, check_opts}, required_status} ->
additional_filter =
if required_status do
check_module.auto_filter(authorizer.actor, authorizer, check_opts)
else
check_module.auto_filter_not(authorizer.actor, authorizer, check_opts)
end
scenarios = remove_clause(authorizer.scenarios, {check_module, check_opts})
new_facts = Map.put(authorizer.facts, {check_module, check_opts}, required_status)
global_filters(%{authorizer | facts: new_facts}, scenarios, [additional_filter | filter])
end
end
defp remove_clause(scenarios, clause) do
Enum.map(scenarios, &Map.delete(&1, clause))
end
defp check_result(authorizer) do
Enum.reduce_while(authorizer.data, {:ok, authorizer}, fn record, {:ok, authorizer} ->
authorizer.scenarios
|> Enum.reject(&scenario_impossible?(&1, authorizer, record))
|> case do
[] ->
{:halt, {:error, :forbidden, authorizer}}
scenarios ->
scenarios
|> Ash.Policy.SatSolver.remove_irrelevant_clauses()
|> do_check_result(authorizer, record)
end
end)
end
defp do_check_result(cleaned_scenarios, authorizer, record) do
if Enum.any?(cleaned_scenarios, &scenario_applies?(&1, authorizer, record)) do
{:cont, {:ok, authorizer}}
else
check_facts_until_known(cleaned_scenarios, authorizer, record)
end
end
defp scenario_applies?(scenario, authorizer, record) do
Enum.all?(scenario, fn {clause, requirement} ->
case Map.fetch(authorizer.facts, clause) do
{:ok, ^requirement} ->
true
_ ->
scenario_applies_to_record?(authorizer, clause, record)
end
end)
end
defp scenario_applies_to_record?(authorizer, clause, record) do
case Map.fetch(authorizer.data_facts, clause) do
{:ok, ids_that_match} ->
pkey = Map.take(record, Ash.Resource.Info.primary_key(authorizer.resource))
MapSet.member?(ids_that_match, pkey)
_ ->
false
end
end
defp scenario_impossible?(scenario, authorizer, record) do
Enum.any?(scenario, fn {clause, requirement} ->
case Map.fetch(authorizer.facts, clause) do
{:ok, value} when value != requirement ->
true
_ ->
scenario_impossible_by_data?(authorizer, clause, record)
end
end)
end
defp scenario_impossible_by_data?(authorizer, clause, record) do
case Map.fetch(authorizer.data_facts, clause) do
{:ok, ids_that_match} ->
pkey = Map.take(record, Ash.Resource.Info.primary_key(authorizer.resource))
not MapSet.member?(ids_that_match, pkey)
_ ->
false
end
end
defp check_facts_until_known(scenarios, authorizer, record) do
new_authorizer =
scenarios
|> find_fact_to_check(authorizer)
|> check_fact(authorizer)
scenarios
|> Enum.reject(&scenario_impossible?(&1, new_authorizer, record))
|> case do
[] ->
log(authorizer, "Checked all facts, no real scenarios")
{:halt, {:forbidden, authorizer}}
scenarios ->
cleaned_scenarios = Ash.Policy.SatSolver.remove_irrelevant_clauses(scenarios)
if Enum.any?(cleaned_scenarios, &scenario_applies?(&1, new_authorizer, record)) do
{:cont, {:ok, new_authorizer}}
else
check_facts_until_known(scenarios, new_authorizer, record)
end
end
end
defp check_fact({check_module, check_opts}, authorizer) do
if check_module.type() == :simple do
raise "Assumption failed"
else
authorized_records =
check_module.check(authorizer.actor, authorizer.data, authorizer, check_opts)
pkey = Ash.Resource.Info.primary_key(authorizer.resource)
pkeys = MapSet.new(authorized_records, &Map.take(&1, pkey))
%{
authorizer
| data_facts: Map.put(authorizer.data_facts, {check_module, check_opts}, pkeys)
}
end
end
defp find_fact_to_check(scenarios, authorizer) do
scenarios
|> Enum.concat()
|> Enum.find(fn {key, _value} ->
not Map.has_key?(authorizer.facts, key) and not Map.has_key?(authorizer.data_facts, key)
end)
|> case do
nil -> raise "Assumption failed"
{key, _value} -> key
end
end
defp strict_check_result(authorizer) do
case Checker.strict_check_scenarios(authorizer) do
{:ok, scenarios} ->
report_scenarios(authorizer, scenarios, "Potential Scenarios")
case Checker.find_real_scenarios(scenarios, authorizer.facts) do
[] ->
maybe_strict_filter(authorizer, scenarios)
real_scenarios ->
report_scenarios(authorizer, real_scenarios, "Real Scenarios")
:authorized
end
{:error, :unsatisfiable} ->
{:error,
Ash.Error.Forbidden.Policy.exception(
facts: authorizer.facts,
policies: authorizer.policies,
scenarios: []
)}
end
end
defp maybe_strict_filter(authorizer, scenarios) do
log(authorizer, "No real scenarios, attempting to filter")
strict_filter(%{authorizer | scenarios: scenarios})
end
defp do_strict_check_facts(authorizer) do
case Checker.strict_check_facts(authorizer) do
{:ok, authorizer, new_facts} ->
{:ok, %{authorizer | facts: new_facts}}
{:error, error} ->
{:error, error}
end
end
defp get_policies(authorizer) do
%{
authorizer
| policies: Ash.Policy.Info.policies(authorizer.resource)
}
end
defp report_scenarios(%{verbose?: true}, scenarios, title) do
scenario_description =
scenarios
|> Enum.map(fn scenario ->
scenario
|> Enum.reject(fn {{module, _}, _} ->
module == Ash.Policy.Check.Static
end)
|> Enum.map(fn {{module, opts}, requirement} ->
[" ", module.describe(opts) <> " => #{requirement}"]
end)
|> Enum.intersperse("\n")
end)
|> Enum.intersperse("\n--\n")
Logger.info([title, "\n", scenario_description])
end
defp report_scenarios(_, _, _), do: :ok
defp log(%{verbose?: true}, message) do
Logger.info(message)
end
defp log(_, _), do: :ok
end

76
lib/ash/policy/check.ex Normal file
View file

@ -0,0 +1,76 @@
defmodule Ash.Policy.Check do
@moduledoc """
A behaviour for declaring checks, which can be used to easily construct
authorization rules.
If a check can be expressed simply as a function of the actor, or the context of the request,
see `Ash.Policy.SimpleCheck` for an easy way to write that check.
If a check can be expressed simply with a filter statement, see `Ash.Policy.FilterCheck`
for an easy way to write that check.
"""
@type options :: Keyword.t()
@type authorizer :: Ash.Policy.Authorizer.t()
@type check_type :: :simple | :filter | :manual
defstruct [:check, :check_module, :check_opts, :type]
@doc false
def transform(%{check: {check_module, opts}} = policy) do
{:ok, %{policy | check_module: check_module, check_opts: opts}}
end
@type t :: %__MODULE__{}
@doc """
Strict checks should be cheap, and should never result in external calls (like database or api)
It should return `{:ok, true}` if it can tell that the request is authorized, and `{:ok, false}` if
it can tell that it is not. If unsure, it should return `{:ok, :unknown}`
"""
@callback strict_check(struct(), authorizer(), options) :: {:ok, boolean | :unknown}
@doc """
An optional callback, that allows the check to work with policies set to `access_type :filter`
Return a keyword list filter that will be applied to the query being made, and will scope the results to match the rule
"""
@callback auto_filter(struct(), authorizer(), options()) :: Keyword.t()
@doc """
An optional callback, hat allows the check to work with policies set to `access_type :runtime`
Takes a list of records, and returns `{:ok, true}` if they are all authorized, or `{:ok, list}` containing the list
of records that are authorized. You can also just return the whole list, `{:ok, true}` is just a shortcut.
Can also return `{:error, error}` if something goes wrong
"""
@callback check(struct(), list(Ash.Resource.record()), map, options) ::
{:ok, list(Ash.Resource.record()) | boolean} | {:error, Ash.Error.t()}
@doc "Describe the check in human readable format, given the options"
@callback describe(options()) :: String.t()
@doc """
The type of the check
`:manual` checks must be written by hand as standard check modules
`:filter` checks can use `Ash.Policy.FilterCheck` for simplicity
`:simple` checks can use `Ash.Policy.SimpleCheck` for simplicity
"""
@callback type() :: check_type()
@optional_callbacks check: 4, auto_filter: 3
def defines_check?(module) do
:erlang.function_exported(module, :check, 4)
end
def defines_auto_filter?(module) do
:erlang.function_exported(module, :auto_filter, 3)
end
defmacro __using__(_opts) do
quote do
@behaviour Ash.Policy.Check
def type, do: :manual
end
end
end

View file

@ -0,0 +1,14 @@
defmodule Ash.Policy.Check.Action do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(options) do
"action == #{inspect(options[:action])}"
end
@impl true
def match?(_actor, %{action: %{name: name}}, options) do
name == options[:action]
end
end

View file

@ -0,0 +1,14 @@
defmodule Ash.Policy.Check.ActionType do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(options) do
"action.type == #{inspect(options[:type])}"
end
@impl true
def match?(_actor, %{action: %{type: type}}, options) do
type == options[:type]
end
end

View file

@ -0,0 +1,16 @@
defmodule Ash.Policy.Check.ActorAttributeEquals do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(opts) do
"actor.#{opts[:attribute]} == #{inspect(opts[:value])}"
end
@impl true
def match?(nil, _, _), do: false
def match?(actor, _context, opts) do
Map.fetch(actor, opts[:attribute]) == {:ok, opts[:value]}
end
end

View file

@ -0,0 +1,15 @@
defmodule Ash.Policy.Check.Attribute do
@moduledoc false
use Ash.Policy.FilterCheck
@impl true
def describe(opts) do
"record.#{opts[:attribute]} matches #{inspect(opts[:filter])}"
end
@impl true
def filter(opts) do
[{opts[:attribute], opts[:filter]}]
end
end

View file

@ -0,0 +1,90 @@
defmodule Ash.Policy.Check.BuiltInChecks do
@moduledoc "The global authorization checks built into ash"
@doc "This check always passes"
def always do
{Ash.Policy.Check.Static, result: true}
end
@doc "this check never passes"
def never do
{Ash.Policy.Check.Static, result: false}
end
@doc "This check is true when the action type matches the provided type"
def action_type(action_type) do
{Ash.Policy.Check.ActionType, type: action_type}
end
@doc "This check is true when the action name matches the provided action name"
def action(action) do
{Ash.Policy.Check.Action, action: action}
end
@doc "This check is true when the field is being selected and false when it is not"
def selecting(attribute) do
{Ash.Policy.Check.Selecting, attribute: attribute}
end
@doc " This check passes if the data relates to the actor via the specified relationship or path of relationships"
def relates_to_actor_via(relationship_path) do
{Ash.Policy.Check.RelatesToActorVia, relationship_path: List.wrap(relationship_path)}
end
@doc "This check is true when a field on the record matches a specific filter"
def attribute(attribute, filter) do
{Ash.Policy.Check.Attribute, attribute: attribute, filter: filter}
end
@doc "This check is true when the value of the specified attribute equals the specified value"
def actor_attribute_equals(attribute, value) do
{Ash.Policy.Check.ActorAttributeEquals, attribute: attribute, value: value}
end
@doc """
This check is true when attribute changes correspond to the provided options.
Provide a keyword list of options or just an atom representing the attribute.
For example:
```elixir
# if you are changing both first name and last name
changing_attributes([:first_name, :last_name])
# if you are changing first name to fred
changing_attributes(first_name: [to: "fred"])
# if you are changing last name from bob
changing_attributes(last_name: [from: "bob"])
# if you are changing :first_name at all, last_name from "bob" and middle name from "tom" to "george"
changing_attributes([:first_name, last_name: [from: "bob"], middle_name: [from: "tom", to: "george]])
```
"""
def changing_attributes(opts) do
opts =
Enum.map(opts, fn opt ->
if is_atom(opt) do
{opt, []}
end
end)
{Ash.Policy.Check.ChangingAttributes, opts}
end
@doc "This check is true when the specified relationship is being changed to the current actor"
def relating_to_actor(relationship) do
{Ash.Policy.Check.RelatingToActor, relationship: relationship}
end
@doc "This check is true when the specified relationship is changing"
def changing_relationship(relationship) do
changing_relationships(List.wrap(relationship))
end
@doc "This check is true when the specified relationships are changing"
def changing_relationships(relationships) do
{Ash.Policy.Check.ChangingRelationships, relationships: relationships}
end
end

View file

@ -0,0 +1,57 @@
defmodule Ash.Policy.Check.ChangingAttributes do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(opts) do
message =
Enum.map_join(opts, " and ", fn
{key, further} ->
field_message =
Enum.map_join(further, ", ", fn
{:to, to_value} ->
"to #{inspect(to_value)}"
{:from, from_value} ->
"from #{inspect(from_value)}"
end)
"#{key} #{field_message}"
key ->
"#{key}"
end)
"changing #{message}"
end
@impl true
def match?(_actor, %{changeset: %Ash.Changeset{} = changeset}, opts) do
Enum.all?(opts, fn
{attribute, opts} ->
if Keyword.has_key?(opts, :from) && changeset.action_type == :create do
false
else
case Ash.Changeset.fetch_change(changeset, attribute) do
{:ok, new_value} ->
opts == [] ||
Enum.all?(opts, fn
{:to, value} ->
new_value == value
{:from, value} ->
Map.get(changeset.data, attribute) == value
end)
_ ->
false
end
end
attribute ->
match?({:ok, _}, Ash.Changeset.fetch_change(changeset, attribute))
end)
end
def match?(_, _, _), do: false
end

View file

@ -0,0 +1,24 @@
defmodule Ash.Policy.Check.ChangingRelationships do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(opts) do
case opts[:relationships] do
[relationship] ->
"changing relationship: #{relationship}"
relationships ->
"changing any of #{Enum.join(relationships, ",")} relationships"
end
end
@impl true
def match?(_actor, %{changeset: %Ash.Changeset{} = changeset}, opts) do
Enum.any?(opts[:relationships], fn relationship ->
Ash.Changeset.changing_relationship?(changeset, relationship)
end)
end
def match?(_, _, _), do: false
end

View file

@ -0,0 +1,14 @@
defmodule Ash.Policy.Check.Expression do
@moduledoc "The check module used for `expr`s in policies"
use Ash.Policy.FilterCheck
@impl true
def describe(opts) do
inspect(opts[:expr])
end
@impl true
def filter(opts) do
opts[:expr]
end
end

View file

@ -0,0 +1,45 @@
defmodule Ash.Policy.Check.RelatesToActorVia do
@moduledoc false
use Ash.Policy.FilterCheck
@impl true
def describe(opts) do
path = Enum.join(opts[:relationship_path], ".")
"record.#{path} == actor"
end
@impl true
def filter(opts) do
pkey =
opts[:resource]
|> Ash.Resource.Info.related(opts[:relationship_path])
|> Kernel.||(raise "Must be able to determine related resource for `relates_to_actor_via`")
|> Ash.Resource.Info.primary_key()
put_in_path(opts[:relationship_path], Enum.map(pkey, &{&1, {:_actor, &1}}))
end
@impl true
def reject(opts) do
pkey =
opts[:resource]
|> Ash.Resource.Info.related(opts[:relationship_path])
|> Kernel.||(raise "Must be able to determine related resource for `relates_to_actor_via`")
|> Ash.Resource.Info.primary_key()
[
or: [
[not: filter(opts)],
[put_in_path(opts[:relationship_path], Enum.map(pkey, &{:is_nil, &1}))]
]
]
end
defp put_in_path([], value) do
value
end
defp put_in_path([key | rest], value) do
[{key, put_in_path(rest, value)}]
end
end

View file

@ -0,0 +1,30 @@
defmodule Ash.Policy.Check.RelatingToActor do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(opts) do
"relating this.#{opts[:relationship]} to the actor"
end
@impl true
def match?(nil, _, _), do: false
def match?(actor, %{changeset: %Ash.Changeset{} = changeset}, opts) do
resource = changeset.resource
relationship = Ash.Resource.Info.relationship(resource, opts[:relationship])
unless relationship.type == :belongs_to do
raise "Can only use `belongs_to` relationships in relating_to_actor checks"
end
if Ash.Changeset.changing_attribute?(changeset, relationship.source_field) do
Ash.Changeset.get_attribute(changeset, relationship.source_field) ==
Map.get(actor, relationship.destination_field)
else
false
end
end
def match?(_, _, _), do: false
end

View file

@ -0,0 +1,20 @@
defmodule Ash.Policy.Check.Selecting do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(opts) do
"selecting #{opts[:attribute]}"
end
@impl true
def match?(_actor, %{changeset: %Ash.Changeset{} = changeset}, opts) do
Ash.Changeset.selecting?(changeset, opts[:attribute])
end
def match?(_actor, %{query: %Ash.Query{} = query}, opts) do
Ash.Query.selecting?(query, opts[:attribute])
end
def match?(_, _, _), do: false
end

View file

@ -0,0 +1,14 @@
defmodule Ash.Policy.Check.Static do
@moduledoc false
use Ash.Policy.SimpleCheck
@impl true
def describe(options) do
"always #{inspect(options[:result])}"
end
@impl true
def match?(_actor, _request, options) do
options[:result]
end
end

117
lib/ash/policy/checker.ex Normal file
View file

@ -0,0 +1,117 @@
defmodule Ash.Policy.Checker do
@moduledoc false
alias Ash.Policy.{Check, Policy}
def strict_check_facts(%{policies: policies} = authorizer) do
Enum.reduce_while(policies, {:ok, authorizer, authorizer.facts}, fn policy,
{:ok, authorizer, facts} ->
case do_strict_check_facts(policy, authorizer, facts) do
{:ok, authorizer, facts} ->
{:cont, {:ok, authorizer, facts}}
{:error, error} ->
{:halt, {:error, error}}
end
end)
end
defp do_strict_check_facts(%Policy{} = policy, authorizer, facts) do
policy.condition
|> List.wrap()
|> Enum.reduce_while({:ok, authorizer, facts}, fn {check_module, opts},
{:ok, authorizer, facts} ->
case do_strict_check_facts(
%Check{check_module: check_module, check_opts: opts},
authorizer,
facts
) do
{:ok, authorizer, facts} -> {:cont, {:ok, authorizer, facts}}
{:error, error} -> {:halt, {:error, error}}
end
end)
|> case do
{:ok, authorizer, facts} ->
strict_check_policies(policy.policies, authorizer, facts)
{:error, error} ->
{:error, error}
end
end
defp do_strict_check_facts(%Ash.Policy.Check{} = check, authorizer, facts) do
check_module = check.check_module
opts = check.check_opts
case check_module.strict_check(authorizer.actor, authorizer, opts) do
{:ok, boolean} when is_boolean(boolean) ->
{:ok, authorizer, Map.put(facts, {check_module, opts}, boolean)}
{:ok, :unknown} ->
{:ok, authorizer, facts}
{:error, error} ->
{:error, error}
other ->
raise "Invalid return value from strict_check call #{check_module}.strict_check(actor, authorizer, #{inspect(opts)}) - #{inspect(other)}"
end
end
defp strict_check_policies(policies, authorizer, facts) do
Enum.reduce_while(policies, {:ok, authorizer, facts}, fn policy, {:ok, authorizer, facts} ->
case do_strict_check_facts(policy, authorizer, facts) do
{:ok, authorizer, facts} -> {:cont, {:ok, authorizer, facts}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
def find_real_scenarios(scenarios, facts) do
Enum.filter(scenarios, fn scenario ->
scenario_is_reality(scenario, facts) == :reality
end)
end
defp scenario_is_reality(scenario, facts) do
scenario
|> Map.drop([true, false])
|> Enum.reduce_while(:reality, fn {fact, requirement}, status ->
case Map.fetch(facts, fact) do
{:ok, ^requirement} ->
{:cont, status}
{:ok, _} ->
{:halt, :not_reality}
:error ->
{:cont, :maybe}
end
end)
end
def strict_check_scenarios(authorizer) do
case Ash.Policy.Policy.solve(authorizer) do
{:ok, scenarios} ->
{:ok, remove_scenarios_with_impossible_facts(scenarios, authorizer)}
{:error, :unsatisfiable} ->
{:error, :unsatisfiable}
end
end
defp remove_scenarios_with_impossible_facts(scenarios, authorizer) do
# Remove any scenarios with a fact that must be a certain value, but are not, at strict check time
# They aren't true, so that scenario isn't possible
Enum.reject(scenarios, fn scenario ->
Enum.any?(scenario, fn {{mod, opts}, required_value} ->
opts[:access_type] == :strict &&
not match?(
{:ok, ^required_value},
Policy.fetch_fact(authorizer.facts, {mod, opts})
)
end)
end)
end
end

View file

@ -0,0 +1,96 @@
defmodule Ash.Policy.FilterCheck do
@moduledoc """
A type of check that is represented by a filter statement
That filter statement can be templated, currently only supporting `{:_actor, field}`
which will replace that portion of the filter with the appropriate field value from the actor and
`{:_actor, :_primary_key}` which will replace the value with a keyword list of the primary key
fields of an actor to their values, like `[id: 1]`. If the actor is not present `{:_actor, field}`
becomes `nil`, and `{:_actor, :_primary_key}` becomes `false`.
You can customize what the "negative" filter looks like by defining `c:reject/1`. This is important for
filters over related data. For example, given an `owner` relationship and a data layer like `ash_postgres`
where `column != NULL` does *not* evaluate to true (see postgres docs on NULL for more):
# The opposite of
`owner.id == 1`
# in most cases is not
`not(owner.id == 1)`
# because in postgres that would be `NOT (owner.id = NULL)` in cases where there was no owner
# A better opposite would be
`owner.id != 1 or is_nil(owner.id)`
# alternatively
`not(owner.id == 1) or is_nil(owner.id)`
By being able to customize the `reject` filter, you can use related filters in your policies. Without it,
they will likely have undesired effects.
"""
@type options :: Keyword.t()
@callback filter(options()) :: Keyword.t()
@callback reject(options()) :: Keyword.t()
@optional_callbacks [filter: 1, reject: 1]
defmacro __using__(_) do
quote do
@behaviour Ash.Policy.FilterCheck
@behaviour Ash.Policy.Check
require Ash.Query
def type, do: :filter
def strict_check_context(opts) do
[]
end
def strict_check(_, _, _), do: {:ok, :unknown}
def auto_filter(actor, authorizer, opts) do
opts = Keyword.put_new(opts, :resource, authorizer.resource)
Ash.Filter.build_filter_from_template(filter(opts), actor)
end
def auto_filter_not(actor, authorizer, opts) do
opts = Keyword.put_new(opts, :resource, authorizer.resource)
Ash.Filter.build_filter_from_template(reject(opts), actor)
end
def reject(opts) do
[not: filter(opts)]
end
def check(actor, data, authorizer, opts) do
pkey = Ash.Resource.Info.primary_key(authorizer.resource)
filter =
case data do
[record] -> Map.take(record, pkey)
records -> [or: Enum.map(data, &Map.take(&1, pkey))]
end
authorizer.resource
|> authorizer.api.query()
|> Ash.Query.filter(^filter)
|> Ash.Query.filter(^auto_filter(authorizer.actor, authorizer, opts))
|> authorizer.api.read()
|> case do
{:ok, authorized_data} ->
authorized_pkeys = Enum.map(authorized_data, &Map.take(&1, pkey))
Enum.filter(data, fn record ->
Map.take(record, pkey) in authorized_pkeys
end)
{:error, error} ->
{:error, error}
end
end
defoverridable reject: 1
end
end
def is_filter_check?(module) do
:erlang.function_exported(module, :filter, 1)
end
end

164
lib/ash/policy/info.ex Normal file
View file

@ -0,0 +1,164 @@
defmodule Ash.Policy.Info do
@moduledoc """
An authorization extension for ash resources.
For more information, see `Ash.Policy.Authorizer`
"""
@type request :: Ash.Engine.Request.t()
alias Ash.Dsl.Extension
@doc "Whether or not ash policy authorizer is configured to show policy breakdowns in error messages"
def show_policy_breakdowns? do
Application.get_env(:ash, :policies)[:show_policy_breakdowns?] || false
end
@doc "Whether or not ash policy authorizer is configured to show policy breakdowns in error messages"
def log_policy_breakdowns do
Application.get_env(:ash, :policies)[:log_policy_breakdowns]
end
@doc """
A utility to determine if a given query/changeset would pass authorization.
*This is still experimental.*
"""
def strict_check(_actor, %{action: nil}, _) do
raise "Cannot use `strict_check/3` unless an action has been set on the query/changeset"
end
def strict_check(actor, %Ash.Query{} = query, api) do
authorizer = %Ash.Policy.Authorizer{
actor: actor,
resource: query.resource,
action: query.action
}
case Ash.Policy.Authorizer.strict_check(authorizer, %{
api: api,
query: query,
changeset: nil
}) do
{:error, _error} ->
false
:authorized ->
true
{:filter, _, _} ->
true
_ ->
:maybe
end
end
def strict_check(actor, %Ash.Changeset{} = changeset, api) do
authorizer = %Ash.Policy.Authorizer{
actor: actor,
resource: changeset.resource,
action: changeset.action
}
case Ash.Policy.Authorizer.strict_check(authorizer, %{
api: api,
changeset: changeset,
query: nil
}) do
{:error, _error} ->
false
:authorized ->
true
{:filter, _, _} ->
:maybe
_ ->
:maybe
end
end
def describe_resource(resource) do
resource
|> policies()
|> describe_policies()
end
defp describe_policies(policies) do
Enum.map_join(policies, "\n", fn policy ->
case policy.condition do
empty when empty in [nil, []] ->
describe_checks(policy.policies)
conditions ->
"When:\n" <>
indent(describe_conditions(conditions)) <>
"\nThen:\n" <> indent(describe_checks(policy.policies))
end
end)
end
defp describe_checks(checks) do
checks
|> Enum.map_join("\n", fn
%{type: type, check_module: check_module, check_opts: check_opts} ->
"#{type}: #{check_module.describe(check_opts)}"
end)
|> Kernel.<>("\n")
end
defp describe_conditions(conditions) do
Enum.map_join(conditions, " and ", fn
{check_module, check_opts} ->
check_module.describe(check_opts)
end)
end
defp indent(string) do
string
|> String.split("\n")
|> Enum.map_join("\n", fn line ->
" " <> line
end)
end
def policies(resource) do
resource
|> Extension.get_entities([:policies])
|> set_access_type(default_access_type(resource))
end
def default_access_type(resource) do
Extension.get_opt(resource, [:policies], :default_access_type, :strict, false)
end
# This should be done at compile time
defp set_access_type(policies, default) when is_list(policies) do
Enum.map(policies, &set_access_type(&1, default))
end
defp set_access_type(
%Ash.Policy.Policy{
policies: policies,
condition: conditions,
checks: checks,
access_type: access_type
} = policy,
default
) do
%{
policy
| policies: set_access_type(policies, default),
condition: set_access_type(conditions, default),
checks: set_access_type(checks, default),
access_type: access_type || default
}
end
defp set_access_type(%Ash.Policy.Check{check_opts: check_opts} = check, default) do
%{check | check_opts: Keyword.update(check_opts, :access_type, default, &(&1 || default))}
end
defp set_access_type(other, _), do: other
end

280
lib/ash/policy/policy.ex Normal file
View file

@ -0,0 +1,280 @@
defmodule Ash.Policy.Policy do
@moduledoc "The data structure for a policy, and functions for working with them."
# For now we just write to `checks` and move them to `policies`
# on build, when we support nested policies we can change that.
defstruct [
:condition,
:policies,
:bypass?,
:checks,
:description,
:access_type
]
@type t :: %__MODULE__{}
def solve(authorizer) do
authorizer.policies
|> build_requirements_expression(authorizer.facts)
|> Ash.Policy.SatSolver.solve()
end
defp build_requirements_expression(policies, facts) do
at_least_one_policy_expression = at_least_one_policy_expression(policies, facts)
policy_expression =
{:and, at_least_one_policy_expression, compile_policy_expression(policies, facts)}
facts_expression = Ash.Policy.SatSolver.facts_to_statement(Map.drop(facts, [true, false]))
if facts_expression do
{:and, facts_expression, policy_expression}
else
policy_expression
end
end
def at_least_one_policy_expression(policies, facts) do
policies
|> Enum.map(&condition_expression(&1.condition, facts))
|> Enum.filter(& &1)
|> Enum.reduce(false, fn condition, acc ->
{:or, condition, acc}
end)
end
def fetch_fact(facts, %{check_module: mod, check_opts: opts}) do
fetch_fact(facts, {mod, opts})
end
def fetch_fact(facts, {mod, opts}) do
# TODO: this is slow, and we should figure out a better way to access facts indiscriminate of access type,
# which my necessity must be stored with the fact (as facts create scenarios)
# Eventually we may just want to track two separate maps of facts, one with access type and one without
Enum.find_value(facts, fn
{{fact_mod, fact_opts}, result} ->
if mod == fact_mod &&
Keyword.delete(fact_opts, :access_type) ==
Keyword.delete(opts, :access_type) do
{:ok, result}
end
_ ->
nil
end)
|> case do
nil ->
:error
value ->
value
end
end
defp condition_expression(condition, facts) do
condition
|> List.wrap()
|> Enum.reduce(nil, fn
condition, nil ->
case fetch_fact(facts, condition) do
{:ok, true} ->
true
{:ok, false} ->
false
_ ->
condition
end
_condition, false ->
false
condition, expression ->
case fetch_fact(facts, condition) do
{:ok, true} ->
expression
{:ok, false} ->
false
_ ->
{:and, condition, expression}
end
end)
end
defp compile_policy_expression(policies, facts)
defp compile_policy_expression([], _facts) do
false
end
defp compile_policy_expression(
[%__MODULE__{condition: condition, policies: policies}],
facts
) do
compiled_policies = compile_policy_expression(policies, facts)
condition_expression = condition_expression(condition, facts)
case condition_expression do
true ->
compiled_policies
false ->
true
nil ->
compiled_policies
condition_expression ->
{:and, condition_expression, compiled_policies}
end
end
defp compile_policy_expression(
[
%__MODULE__{condition: condition, policies: policies, bypass?: bypass?} | rest
],
facts
) do
condition_expression = condition_expression(condition, facts)
case condition_expression do
true ->
if bypass? do
{:or, compile_policy_expression(policies, facts),
compile_policy_expression(rest, facts)}
else
{:and, compile_policy_expression(policies, facts),
compile_policy_expression(rest, facts)}
end
false ->
compile_policy_expression(rest, facts)
nil ->
if bypass? do
{:or, compile_policy_expression(policies, facts),
compile_policy_expression(rest, facts)}
else
{:and, compile_policy_expression(policies, facts),
compile_policy_expression(rest, facts)}
end
condition_expression ->
if bypass? do
{:or, {:and, condition_expression, compile_policy_expression(policies, facts)},
compile_policy_expression(rest, facts)}
else
{:or, {:and, condition_expression, compile_policy_expression(policies, facts)},
{:and, {:not, condition_expression}, compile_policy_expression(rest, facts)}}
end
end
end
defp compile_policy_expression(
[%{type: :authorize_if} = clause],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
true
{:ok, false} ->
false
:error ->
{clause.check_module, clause.check_opts}
end
end
defp compile_policy_expression(
[%{type: :authorize_if} = clause | rest],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
true
{:ok, false} ->
compile_policy_expression(rest, facts)
:error ->
{:or, {clause.check_module, clause.check_opts}, compile_policy_expression(rest, facts)}
end
end
defp compile_policy_expression(
[%{type: :authorize_unless} = clause],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
false
{:ok, false} ->
true
:error ->
{clause.check_module, clause.check_opts}
end
end
defp compile_policy_expression(
[%{type: :authorize_unless} = clause | rest],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
compile_policy_expression(rest, facts)
{:ok, false} ->
true
:error ->
{:or, {clause.check_module, clause.check_opts}, compile_policy_expression(rest, facts)}
end
end
defp compile_policy_expression([%{type: :forbid_if}], _facts) do
false
end
defp compile_policy_expression(
[%{type: :forbid_if} = clause | rest],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
false
{:ok, false} ->
compile_policy_expression(rest, facts)
:error ->
{:and, {:not, {clause.check_module, clause.check_opts}},
compile_policy_expression(rest, facts)}
end
end
defp compile_policy_expression([%{type: :forbid_unless}], _facts) do
false
end
defp compile_policy_expression(
[%{type: :forbid_unless} = clause | rest],
facts
) do
case fetch_fact(facts, clause) do
{:ok, true} ->
compile_policy_expression(rest, facts)
{:ok, false} ->
false
:error ->
{:and, {clause.check_module, clause.check_opts}, compile_policy_expression(rest, facts)}
end
end
end

View file

@ -0,0 +1,120 @@
defmodule Ash.Policy.SatSolver do
@moduledoc false
def solve(expression) do
expression
|> add_negations_and_solve([])
|> get_all_scenarios(expression)
|> case do
[] ->
{:error, :unsatisfiable}
scenarios ->
static_checks = [
{Ash.Policy.Check.Static, [result: true]},
{Ash.Policy.Check.Static, [result: false]}
]
{:ok,
scenarios
|> Enum.uniq()
|> remove_irrelevant_clauses()
|> Enum.uniq()
|> Enum.map(&Map.drop(&1, static_checks))}
end
end
defp get_all_scenarios(scenario_result, expression, scenarios \\ [])
defp get_all_scenarios({:error, :unsatisfiable}, _, scenarios), do: scenarios
defp get_all_scenarios({:ok, scenario}, expression, scenarios) do
expression
|> add_negations_and_solve([Map.drop(scenario, [true, false]) | scenarios])
|> get_all_scenarios(expression, [Map.drop(scenario, [true, false]) | scenarios])
end
def remove_irrelevant_clauses([scenario]), do: [scenario]
def remove_irrelevant_clauses(scenarios) do
new_scenarios =
scenarios
|> Enum.uniq()
|> Enum.map(fn scenario ->
unnecessary_fact = find_unnecessary_fact(scenario, scenarios)
Map.delete(scenario, unnecessary_fact)
end)
|> Enum.uniq()
if new_scenarios == scenarios do
scenarios
else
remove_irrelevant_clauses(new_scenarios)
end
end
defp find_unnecessary_fact(scenario, scenarios) do
Enum.find_value(scenario, fn
{fact, value_in_this_scenario} ->
matching =
Enum.find(scenarios, fn potential_irrelevant_maker ->
potential_irrelevant_maker != scenario &&
Map.delete(scenario, fact) == Map.delete(potential_irrelevant_maker, fact)
end)
case matching do
%{^fact => value} when is_boolean(value) and value != value_in_this_scenario ->
fact
_ ->
false
end
end)
end
@spec add_negations_and_solve(term, term) :: term | no_return()
defp add_negations_and_solve(requirements_expression, negations) do
negations =
Enum.reduce(negations, nil, fn negation, expr ->
negation_statement =
negation
|> Map.drop([true, false])
|> facts_to_statement()
if expr do
{:and, expr, {:not, negation_statement}}
else
{:not, negation_statement}
end
end)
full_expression =
if negations do
{:and, requirements_expression, negations}
else
requirements_expression
end
solve_expression(full_expression)
end
def facts_to_statement(facts) do
Enum.reduce(facts, nil, fn {fact, true?}, expr ->
expr_component =
if true? do
fact
else
{:not, fact}
end
if expr do
{:and, expr, expr_component}
else
expr_component
end
end)
end
defp solve_expression(expression) do
Ash.SatSolver.solve_expression(expression)
end
end

View file

@ -0,0 +1,25 @@
defmodule Ash.Policy.SimpleCheck do
@moduledoc """
A type of check that operates only on request context, never on the data
Simply define `c:match?/3`, which gets the actor, request context, and opts, and returns true or false
"""
@type authorizer :: Ash.Policy.Authorizer.t()
@type options :: Keyword.t()
@doc "Whether or not the request matches the check"
@callback match?(struct(), authorizer(), options) :: boolean
defmacro __using__(_) do
quote do
@behaviour Ash.Policy.SimpleCheck
@behaviour Ash.Policy.Check
def type, do: :simple
def strict_check(actor, context, opts) do
{:ok, match?(actor, context, opts)}
end
end
end
end

View file

@ -69,6 +69,14 @@ defmodule Ash.Resource do
|> Enum.map(& &1.name)
|> Enum.uniq()
if AshPolicyAuthorizer.Authorizer in @extensions do
raise """
AshPolicyAuthorizer has been deprecated and is now built into Ash core.
To use it, replace `authorizers: [AshPolicyAuthorizer.Authorizer]` with `authorizers: [Ash.Policy.Authorizer]`
"""
end
if api = Ash.Resource.Info.define_interface_for(__MODULE__) do
require Ash.CodeInterface
Ash.CodeInterface.define_interface(api, __MODULE__)

View file

@ -216,7 +216,7 @@ defmodule Ash.MixProject do
sobelow: "sobelow --skip",
credo: "credo --strict",
"ash.formatter":
"ash.formatter --extensions Ash.Resource.Dsl,Ash.Api.Dsl,Ash.Flow.Dsl,Ash.Registry.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia,Ash.Notifier.PubSub"
"ash.formatter --extensions Ash.Resource.Dsl,Ash.Api.Dsl,Ash.Flow.Dsl,Ash.Registry.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia,Ash.Notifier.PubSub,Ash.Policy.Authorizer"
]
end
end

57
test/policy/rbac_test.exs Normal file
View file

@ -0,0 +1,57 @@
defmodule Ash.Policy.Test.RbacTest do
@doc false
use ExUnit.Case
alias Ash.Policy.Test.Rbac.{Api, File, Membership, Organization, User}
setup do
[
user: Api.create!(Ash.Changeset.new(User)),
org: Api.create!(Ash.Changeset.new(Organization))
]
end
test "if the actor has no permissions, they can't see anything", %{
user: user,
org: org
} do
create_file(org, "foo")
create_file(org, "bar")
create_file(org, "baz")
assert Api.read!(File, actor: user) == []
end
test "if the actor has permission to read a file, they can only read that file", %{
user: user,
org: org
} do
file_with_access = create_file(org, "foo")
give_role(user, org, :viewer, :file, file_with_access.id)
create_file(org, "bar")
create_file(org, "baz")
assert [%{name: "foo"}] = Api.read!(File, actor: user)
end
test "unauthorized if no policy is defined", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Api.read!(User, actor: user) == []
end
end
defp give_role(user, org, role, resource, resource_id) do
Membership
|> Ash.Changeset.new(%{role: role, resource: resource, resource_id: resource_id})
|> Ash.Changeset.replace_relationship(:user, user)
|> Ash.Changeset.replace_relationship(:organization, org)
|> Api.create!()
end
defp create_file(org, name) do
File
|> Ash.Changeset.new(%{name: name})
|> Ash.Changeset.replace_relationship(:organization, org)
|> Api.create!()
end
end

View file

@ -0,0 +1,89 @@
defmodule Ash.Policy.Test.SimpleTest do
@doc false
use ExUnit.Case
require Ash.Query
alias Ash.Policy.Test.Simple.{Api, Car, Organization, Post, Trip, User}
setup do
[
user: Api.create!(Ash.Changeset.new(User))
]
end
test "filter checks work on create/update/destroy actions", %{user: user} do
user2 = Api.create!(Ash.Changeset.new(User))
assert_raise Ash.Error.Forbidden, fn ->
Api.update!(Ash.Changeset.new(user), actor: user2)
end
end
test "non-filter checks work on create/update/destroy actions" do
user = Api.create!(Ash.Changeset.new(User))
assert_raise Ash.Error.Forbidden, fn ->
Api.create!(Ash.Changeset.new(Post, %{text: "foo"}), actor: user)
end
end
test "filter checks work with related data", %{user: user} do
organization =
Organization
|> Ash.Changeset.for_create(:create, %{owner: user.id})
|> Api.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{author: user.id, text: "aaa"})
|> Api.create!()
post2 =
Post
|> Ash.Changeset.for_create(:create, %{organization: organization.id, text: "bbb"})
|> Api.create!()
Post
|> Ash.Changeset.for_create(:create, %{text: "invalid"})
|> Api.create!()
ids =
Post
|> Api.read!(actor: user)
|> Enum.map(& &1.id)
|> Enum.sort()
assert ids == Enum.sort([post1.id, post2.id])
end
test "filter checks work with many to many related data and a filter", %{user: user} do
car1 =
Car
|> Ash.Changeset.for_create(:create, %{users: [user.id]})
|> Api.create!()
car2 =
Car
|> Ash.Changeset.for_create(:create, %{})
|> Api.create!()
results =
Car
|> Ash.Query.filter(id == ^car2.id)
|> Api.read!(actor: user)
assert results == []
results =
Car
|> Ash.Query.filter(id == ^car1.id)
|> Api.read!(actor: user)
|> Enum.map(& &1.id)
assert results == [car1.id]
end
test "filter checks work via deeply related data", %{user: user} do
assert Api.read!(Trip, actor: user) == []
end
end

8
test/support/rbac/api.ex Normal file
View file

@ -0,0 +1,8 @@
defmodule Ash.Policy.Test.Rbac.Api do
@moduledoc false
use Ash.Api
resources do
registry(Ash.Policy.Test.Rbac.Registry)
end
end

View file

@ -0,0 +1,104 @@
defmodule Ash.Policy.Test.Rbac.Checks.RoleChecks do
@moduledoc false
@behaviour Ash.Policy.Check
require Ash.Query
# Description of role hierarchy/permissions granted
@role_inheritance [:admin, :member, :viewer]
@permission_grants %{
admin: [:create, :read, :update, :destroy],
member: [:create, :read, :update],
viewer: [:read]
}
def describe(_opts) do
"A description"
end
## Helper to use in resources
def can?(resource), do: {__MODULE__, [resource: resource]}
## Helper to use in resources
def has_permission?(resource, permission) do
roles =
permission
|> roles_that_can()
|> all_higher_roles()
{__MODULE__, [resource: resource, roles: roles]}
end
## Helper to use in resources
def has_role?(resource, role_or_roles) do
{__MODULE__, [resource: resource, roles: all_higher_roles(role_or_roles)]}
end
# Says "I need to know these field values to do my work"
def strict_check_context(_opts) do
[:query, :action]
end
# This would be a spot for us to pre-empt doing any filtering/request work
# We could use this to say "we already kow the answer" For example,
# if the actor had a `super_admin` flag enabled on then, we could return
# {:ok, true} to say "whatever role you're asking about, they have it"
# (don't implement that this way, use a bypass check, its just an example)
def strict_check(_, _, _) do
{:ok, :unknown}
end
# Describes the type of check
def type, do: :filter
# Returns a filter statement over the data
def auto_filter(actor, authorizer, []) do
# :create | :update | :destroy | :read
action_type = authorizer.action.type
Ash.Query.expr(
memberships.role in ^roles_that_can(action_type) and memberships.user_id == ^actor.id
)
end
def auto_filter(actor, authorizer, opts) do
# We're asking for specific roles
if opts[:roles] do
Ash.Query.expr(
organization.memberships.role in ^opts[:roles] and
organization.memberships.user_id == ^actor.id and
organization.memberships.resource == ^opts[:resource] and
id == organization.memberships.resource_id
)
else
# We're asking if they have the permission corresponding to the action
# :create | :update | :destroy | :read
action_type = authorizer.action.type
Ash.Query.expr(
organization.memberships.role in ^roles_that_can(action_type) and
organization.memberships.user_id == ^actor.id and
organization.memberships.resource == ^opts[:resource] and
id == organization.memberships.resource_id
)
end
end
defp roles_that_can(permission) do
@permission_grants
|> Enum.filter(fn {_key, val} ->
permission in val
end)
|> Enum.map(&elem(&1, 0))
end
defp all_higher_roles(role_or_roles) do
role_or_roles
|> List.wrap()
|> Enum.flat_map(fn role ->
Enum.take_while(@role_inheritance, &(&1 != role))
end)
|> Enum.uniq()
end
end

View file

@ -0,0 +1,31 @@
defmodule Ash.Policy.Test.Rbac.File do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
import Ash.Policy.Test.Rbac.Checks.RoleChecks, only: [can?: 1]
policies do
policy always() do
authorize_if(can?(:file))
end
end
ets do
private?(true)
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string)
end
relationships do
belongs_to(:organization, Ash.Policy.Test.Rbac.Organization)
end
end

View file

@ -0,0 +1,36 @@
defmodule Ash.Policy.Test.Rbac.Membership do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute :role, :atom do
allow_nil?(false)
constraints(one_of: [:admin, :member, :viewer])
end
attribute :resource, :atom do
allow_nil?(false)
constraints(one_of: [:file])
end
attribute :resource_id, :uuid do
allow_nil?(false)
end
end
actions do
defaults [:create, :read, :update, :destroy]
end
relationships do
belongs_to(:user, Ash.Policy.Test.Rbac.User)
belongs_to(:organization, Ash.Policy.Test.Rbac.Organization)
end
end

View file

@ -0,0 +1,23 @@
defmodule Ash.Policy.Test.Rbac.Organization do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key(:id)
end
relationships do
has_many :memberships, Ash.Policy.Test.Rbac.Membership do
destination_field(:organization_id)
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Ash.Policy.Test.Rbac.Registry do
@moduledoc false
use Ash.Registry
alias Ash.Policy.Test.Rbac
entries do
entry(Rbac.User)
entry(Rbac.Organization)
entry(Rbac.Membership)
entry(Rbac.File)
end
end

View file

@ -0,0 +1,26 @@
defmodule Ash.Policy.Test.Rbac.User do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [
Ash.Policy.Authorizer
]
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
end
actions do
defaults [:create, :read, :update, :destroy]
end
relationships do
has_many(:memberships, Ash.Policy.Test.Rbac.Membership, destination_field: :user_id)
belongs_to(:organization, Ash.Policy.Test.Rbac.Organization)
end
end

View file

@ -0,0 +1,8 @@
defmodule Ash.Policy.Test.Simple.Api do
@moduledoc false
use Ash.Api
resources do
registry(Ash.Policy.Test.Simple.Registry)
end
end

View file

@ -0,0 +1,15 @@
defmodule Ash.Policy.Test.Simple.Registry do
@moduledoc false
use Ash.Registry
alias Ash.Policy.Test.Simple
entries do
entry(Simple.User)
entry(Simple.Organization)
entry(Simple.Post)
entry(Simple.Car)
entry(Simple.CarUser)
entry(Simple.Trip)
end
end

View file

@ -0,0 +1,38 @@
defmodule Ash.Policy.Test.Simple.Car do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
ets do
private?(true)
end
actions do
defaults [:read, :update, :destroy]
create :create do
primary? true
argument(:users, {:array, :uuid})
change(manage_relationship(:users, type: :replace))
end
end
attributes do
uuid_primary_key(:id)
end
policies do
policy always() do
authorize_if(expr(users.id == ^actor(:id)))
end
end
relationships do
many_to_many :users, Ash.Policy.Test.Simple.User do
through(Ash.Policy.Test.Simple.CarUser)
source_field_on_join_table(:car_id)
destination_field_on_join_table(:user_id)
end
end
end

View file

@ -0,0 +1,22 @@
defmodule Ash.Policy.Test.Simple.CarUser do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key(:id)
end
relationships do
belongs_to(:user, Ash.Policy.Test.Simple.User)
belongs_to(:car, Ash.Policy.Test.Simple.Car)
end
end

View file

@ -0,0 +1,29 @@
defmodule Ash.Policy.Test.Simple.Organization do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
defaults [:read, :update, :destroy]
create(:create) do
primary? true
argument(:owner, :uuid)
change(manage_relationship(:owner, type: :replace))
end
end
attributes do
uuid_primary_key(:id)
end
relationships do
has_many(:users, Ash.Policy.Test.Simple.User)
has_many(:posts, Ash.Policy.Test.Simple.Post)
belongs_to(:owner, Ash.Policy.Test.Simple.User)
end
end

View file

@ -0,0 +1,52 @@
defmodule Ash.Policy.Test.Simple.Post do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [
Ash.Policy.Authorizer
]
policies do
policy action_type(:read) do
description "You can read a post if you created it or if you own the organization"
authorize_if(expr(author.id == ^actor(:id)))
authorize_if(expr(organization.owner_id == ^actor(:id)))
end
policy action_type(:create) do
description "Admins and managers can create posts"
authorize_if(actor_attribute_equals(:admin, true))
authorize_if(actor_attribute_equals(:manager, true))
end
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute :text, :string do
allow_nil?(false)
end
end
actions do
defaults [:read, :update, :destroy]
create :create do
primary? true
argument(:author, :uuid)
change(manage_relationship(:author, type: :replace))
argument(:organization, :uuid)
change(manage_relationship(:organization, type: :replace))
end
end
relationships do
belongs_to(:organization, Ash.Policy.Test.Simple.Organization)
belongs_to(:author, Ash.Policy.Test.Simple.User)
end
end

View file

@ -0,0 +1,30 @@
defmodule Ash.Policy.Test.Simple.Trip do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [
Ash.Policy.Authorizer
]
ets do
private?(true)
end
policies do
policy action_type(:read) do
authorize_if(expr(car.users.id == ^actor(:id)))
end
end
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key(:id)
end
relationships do
belongs_to(:car, Ash.Policy.Test.Simple.Car)
end
end

View file

@ -0,0 +1,43 @@
defmodule Ash.Policy.Test.Simple.User do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
authorizers: [
Ash.Policy.Authorizer
]
policies do
policy action_type(:update) do
authorize_if(expr(id == ^actor(:id)))
end
policy action_type(:read) do
authorize_if(always())
end
end
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:admin, :boolean)
attribute(:manager, :boolean)
end
actions do
defaults [:create, :read, :update, :destroy]
end
relationships do
belongs_to(:organization, Ash.Policy.Test.Simple.Organization)
has_many(:posts, Ash.Policy.Test.Simple.Post, destination_field: :author_id)
many_to_many :cars, Ash.Policy.Test.Simple.Car do
through(Ash.Policy.Test.Simple.CarUser)
source_field_on_join_table(:user_id)
destination_field_on_join_table(:car_id)
end
end
end