mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
improvement: move ash_policy_authorizer into core as Ash.Policy.Authorizer
This commit is contained in:
parent
6b9776a7fb
commit
b3e0632792
46 changed files with 3056 additions and 17 deletions
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
|
|
9
.github/workflows/elixir.yml
vendored
9
.github/workflows/elixir.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
152
documentation/concepts/policies.md
Normal file
152
documentation/concepts/policies.md
Normal 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
|
||||
```
|
|
@ -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,
|
||||
|
|
280
lib/ash/error/forbidden/policy.ex
Normal file
280
lib/ash/error/forbidden/policy.ex
Normal 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
|
736
lib/ash/policy/authorizer.ex
Normal file
736
lib/ash/policy/authorizer.ex
Normal 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
76
lib/ash/policy/check.ex
Normal 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
|
14
lib/ash/policy/check/action.ex
Normal file
14
lib/ash/policy/check/action.ex
Normal 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
|
14
lib/ash/policy/check/action_type.ex
Normal file
14
lib/ash/policy/check/action_type.ex
Normal 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
|
16
lib/ash/policy/check/actor_attribute_equals.ex
Normal file
16
lib/ash/policy/check/actor_attribute_equals.ex
Normal 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
|
15
lib/ash/policy/check/attribute.ex
Normal file
15
lib/ash/policy/check/attribute.ex
Normal 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
|
90
lib/ash/policy/check/built_in_checks.ex
Normal file
90
lib/ash/policy/check/built_in_checks.ex
Normal 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
|
57
lib/ash/policy/check/changing_attributes.ex
Normal file
57
lib/ash/policy/check/changing_attributes.ex
Normal 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
|
24
lib/ash/policy/check/changing_relationships.ex
Normal file
24
lib/ash/policy/check/changing_relationships.ex
Normal 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
|
14
lib/ash/policy/check/expression.ex
Normal file
14
lib/ash/policy/check/expression.ex
Normal 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
|
45
lib/ash/policy/check/relates_to_actor_via.ex
Normal file
45
lib/ash/policy/check/relates_to_actor_via.ex
Normal 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
|
30
lib/ash/policy/check/relating_to_actor.ex
Normal file
30
lib/ash/policy/check/relating_to_actor.ex
Normal 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
|
20
lib/ash/policy/check/selecting.ex
Normal file
20
lib/ash/policy/check/selecting.ex
Normal 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
|
14
lib/ash/policy/check/static.ex
Normal file
14
lib/ash/policy/check/static.ex
Normal 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
117
lib/ash/policy/checker.ex
Normal 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
|
96
lib/ash/policy/filter_check.ex
Normal file
96
lib/ash/policy/filter_check.ex
Normal 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
164
lib/ash/policy/info.ex
Normal 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
280
lib/ash/policy/policy.ex
Normal 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
|
120
lib/ash/policy/sat_solver.ex
Normal file
120
lib/ash/policy/sat_solver.ex
Normal 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
|
25
lib/ash/policy/simple_check.ex
Normal file
25
lib/ash/policy/simple_check.ex
Normal 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
|
|
@ -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__)
|
||||
|
|
2
mix.exs
2
mix.exs
|
@ -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
57
test/policy/rbac_test.exs
Normal 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
|
89
test/policy/simple_test.exs
Normal file
89
test/policy/simple_test.exs
Normal 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
8
test/support/rbac/api.ex
Normal 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
|
104
test/support/rbac/checks/role_checks.ex
Normal file
104
test/support/rbac/checks/role_checks.ex
Normal 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
|
31
test/support/rbac/resources/file.ex
Normal file
31
test/support/rbac/resources/file.ex
Normal 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
|
36
test/support/rbac/resources/membership.ex
Normal file
36
test/support/rbac/resources/membership.ex
Normal 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
|
23
test/support/rbac/resources/organization.ex
Normal file
23
test/support/rbac/resources/organization.ex
Normal 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
|
13
test/support/rbac/resources/registry.ex
Normal file
13
test/support/rbac/resources/registry.ex
Normal 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
|
26
test/support/rbac/resources/user.ex
Normal file
26
test/support/rbac/resources/user.ex
Normal 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
|
8
test/support/simple/api.ex
Normal file
8
test/support/simple/api.ex
Normal 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
|
15
test/support/simple/registry.ex
Normal file
15
test/support/simple/registry.ex
Normal 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
|
38
test/support/simple/resources/car.ex
Normal file
38
test/support/simple/resources/car.ex
Normal 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
|
22
test/support/simple/resources/car_user.ex
Normal file
22
test/support/simple/resources/car_user.ex
Normal 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
|
29
test/support/simple/resources/organization.ex
Normal file
29
test/support/simple/resources/organization.ex
Normal 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
|
52
test/support/simple/resources/post.ex
Normal file
52
test/support/simple/resources/post.ex
Normal 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
|
30
test/support/simple/resources/trip.ex
Normal file
30
test/support/simple/resources/trip.ex
Normal 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
|
43
test/support/simple/resources/user.ex
Normal file
43
test/support/simple/resources/user.ex
Normal 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
|
Loading…
Reference in a new issue