mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
feat: add policy groups
Policy groups allow you to group policies by shared conditions. This can help simplify the mental overhead of large sets of policies. For example: ```elixir policies do policy_group actor_attribute_equals(:role, :owner) do policy action_type(:read) do authorize_if expr(owner_id == ^actor(:id)) end policy action_type([:create, :update, :destroy]) do forbid_if authorize_if expr(owner_id == ^actor(:id)) end end end ```
This commit is contained in:
parent
5a4864650b
commit
dc73c3a3d5
7 changed files with 455 additions and 3 deletions
|
@ -156,6 +156,8 @@ spark_locals_without_parens = [
|
|||
policy: 0,
|
||||
policy: 1,
|
||||
policy: 2,
|
||||
policy_group: 1,
|
||||
policy_group: 2,
|
||||
pre_check?: 1,
|
||||
pre_check_with: 1,
|
||||
prefix: 1,
|
||||
|
|
|
@ -47,6 +47,12 @@ See the [policies guide](/documentation/topics/security/policies.md) for more.
|
|||
* forbid_if
|
||||
* authorize_unless
|
||||
* forbid_unless
|
||||
* [policy_group](#policies-policy_group)
|
||||
* policy
|
||||
* authorize_if
|
||||
* forbid_if
|
||||
* authorize_unless
|
||||
* forbid_unless
|
||||
* [bypass](#policies-bypass)
|
||||
* authorize_if
|
||||
* forbid_if
|
||||
|
@ -312,6 +318,295 @@ Target: `Ash.Policy.Check`
|
|||
|
||||
Target: `Ash.Policy.Policy`
|
||||
|
||||
## policies.policy_group
|
||||
```elixir
|
||||
policy_group condition
|
||||
```
|
||||
|
||||
|
||||
Groups a set of policies together by some condition.
|
||||
|
||||
If the condition on the policy group does not apply, then none of the policies within it apply.
|
||||
|
||||
This is primarily syntactic sugar. At compile time, the conditions from the policy group are
|
||||
added to each policy it contains, and the list is flattened out. This exists primarily to make it
|
||||
easier to reason about and write policies.
|
||||
|
||||
The following are equivalent:
|
||||
|
||||
```elixir
|
||||
policy_group condition1 do
|
||||
policy condition2 do
|
||||
...
|
||||
end
|
||||
|
||||
policy condition3 do
|
||||
...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```elixir
|
||||
policy [condition1, condition2] do
|
||||
...
|
||||
end
|
||||
|
||||
policy [condition1, condition3] do
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### Nested DSLs
|
||||
* [policy](#policies-policy_group-policy)
|
||||
* authorize_if
|
||||
* forbid_if
|
||||
* authorize_unless
|
||||
* forbid_unless
|
||||
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`condition`](#policies-policy_group-condition){: #policies-policy_group-condition } | `any` | | A check or list of checks that must be true in order for this policy to apply. |
|
||||
|
||||
|
||||
|
||||
## policies.policy_group.policy
|
||||
```elixir
|
||||
policy condition \\ nil
|
||||
```
|
||||
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
See the [policies guide](/documentation/topics/security/policies.md) for more.
|
||||
|
||||
|
||||
### Nested DSLs
|
||||
* [authorize_if](#policies-policy_group-policy-authorize_if)
|
||||
* [forbid_if](#policies-policy_group-policy-forbid_if)
|
||||
* [authorize_unless](#policies-policy_group-policy-authorize_unless)
|
||||
* [forbid_unless](#policies-policy_group-policy-forbid_unless)
|
||||
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`condition`](#policies-policy_group-policy-condition){: #policies-policy_group-policy-condition } | `any` | | A check or list of checks that must be true in order for this policy to apply. |
|
||||
### Options
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`description`](#policies-policy_group-policy-description){: #policies-policy_group-policy-description } | `String.t` | | A description for the policy, used when explaining authorization results |
|
||||
| [`access_type`](#policies-policy_group-policy-access_type){: #policies-policy_group-policy-access_type } | `:strict \| :filter \| :runtime` | | Determines how the policy is applied. See the guide for more. |
|
||||
|
||||
|
||||
## policies.policy_group.policy.authorize_if
|
||||
```elixir
|
||||
authorize_if check
|
||||
```
|
||||
|
||||
|
||||
If the check is true, the request is authorized, otherwise run remaining checks.
|
||||
|
||||
|
||||
|
||||
### Examples
|
||||
```
|
||||
authorize_if logged_in()
|
||||
```
|
||||
|
||||
```
|
||||
authorize_if actor_attribute_matches_record(:group, :group)
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`check`](#policies-policy_group-policy-authorize_if-check){: #policies-policy_group-policy-authorize_if-check .spark-required} | `module \| any` | | The check to run. See `Ash.Policy.Check` for more. |
|
||||
### Options
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`name`](#policies-policy_group-policy-authorize_if-name){: #policies-policy_group-policy-authorize_if-name } | `String.t` | | A short name or description for the check, used when explaining authorization results |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.Check`
|
||||
|
||||
## policies.policy_group.policy.forbid_if
|
||||
```elixir
|
||||
forbid_if check
|
||||
```
|
||||
|
||||
|
||||
If the check is true, the request is forbidden, otherwise run remaining checks.
|
||||
|
||||
|
||||
|
||||
### Examples
|
||||
```
|
||||
forbid_if not_logged_in()
|
||||
```
|
||||
|
||||
```
|
||||
forbid_if actor_attribute_matches_record(:group, :blacklisted_groups)
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`check`](#policies-policy_group-policy-forbid_if-check){: #policies-policy_group-policy-forbid_if-check .spark-required} | `module \| any` | | The check to run. See `Ash.Policy.Check` for more. |
|
||||
### Options
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`name`](#policies-policy_group-policy-forbid_if-name){: #policies-policy_group-policy-forbid_if-name } | `String.t` | | A short name or description for the check, used when explaining authorization results |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.Check`
|
||||
|
||||
## policies.policy_group.policy.authorize_unless
|
||||
```elixir
|
||||
authorize_unless check
|
||||
```
|
||||
|
||||
|
||||
If the check is false, the request is authorized, otherwise run remaining checks.
|
||||
|
||||
|
||||
|
||||
### Examples
|
||||
```
|
||||
authorize_unless not_logged_in()
|
||||
```
|
||||
|
||||
```
|
||||
authorize_unless actor_attribute_matches_record(:group, :blacklisted_groups)
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`check`](#policies-policy_group-policy-authorize_unless-check){: #policies-policy_group-policy-authorize_unless-check .spark-required} | `module \| any` | | The check to run. See `Ash.Policy.Check` for more. |
|
||||
### Options
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`name`](#policies-policy_group-policy-authorize_unless-name){: #policies-policy_group-policy-authorize_unless-name } | `String.t` | | A short name or description for the check, used when explaining authorization results |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.Check`
|
||||
|
||||
## policies.policy_group.policy.forbid_unless
|
||||
```elixir
|
||||
forbid_unless check
|
||||
```
|
||||
|
||||
|
||||
If the check is true, the request is forbidden, otherwise run remaining checks.
|
||||
|
||||
|
||||
|
||||
### Examples
|
||||
```
|
||||
forbid_unless logged_in()
|
||||
```
|
||||
|
||||
```
|
||||
forbid_unless actor_attribute_matches_record(:group, :group)
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Arguments
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`check`](#policies-policy_group-policy-forbid_unless-check){: #policies-policy_group-policy-forbid_unless-check .spark-required} | `module \| any` | | The check to run. See `Ash.Policy.Check` for more. |
|
||||
### Options
|
||||
|
||||
| Name | Type | Default | Docs |
|
||||
|------|------|---------|------|
|
||||
| [`name`](#policies-policy_group-policy-forbid_unless-name){: #policies-policy_group-policy-forbid_unless-name } | `String.t` | | A short name or description for the check, used when explaining authorization results |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.Check`
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.Policy`
|
||||
|
||||
|
||||
|
||||
|
||||
### Introspection
|
||||
|
||||
Target: `Ash.Policy.PolicyGroup`
|
||||
|
||||
## policies.bypass
|
||||
```elixir
|
||||
bypass condition \\ nil
|
||||
|
|
|
@ -130,6 +130,49 @@ policies do
|
|||
end
|
||||
```
|
||||
|
||||
## Policy Groups
|
||||
|
||||
Policy groups are a small abstraction over policies, that allow you to group policies together
|
||||
that have shared conditions. Each policy inside of a policy group have the same conditions as
|
||||
their group.
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
policy_group actor_attribute_eqquals(:role, :owner) do
|
||||
policy action_type(:read) do
|
||||
authorize_if expr(owner_id == ^actor(:id))
|
||||
end
|
||||
|
||||
policy action_type([:create, :update, :destroy]) do
|
||||
forbid_if
|
||||
authorize_if expr(owner_id == ^actor(:id))
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Nesting Policy groups
|
||||
|
||||
Policy groups can be nested. This can help when you have lots of policies and conditions.
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
policy_group condition do
|
||||
policy_group condition2 do
|
||||
policy condition3 do
|
||||
# This policy applies if condition, condition2, and condition3 are all true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Bypasses
|
||||
|
||||
Policy groups can _not_ contain bypass policies. The purpose of policy groups is to make it easier to reason
|
||||
about the behavior of policies. When you see a policy group, you know that no policies inside that group will
|
||||
interact with policies in other policy groups, unless they also apply.
|
||||
|
||||
## Checks
|
||||
|
||||
Checks evaluate from top to bottom within a policy. A check can produce one of three results, the same that a policy can produce. While checks are not necessarily evaluated in order, they _logically apply_ in that order, so you may as well think of it in that way. It can be thought of as a step-through algorithm.
|
||||
|
@ -372,6 +415,7 @@ end
|
|||
```
|
||||
|
||||
The different options are:
|
||||
|
||||
- `:show` will always show private fields
|
||||
- `:hide` will always hide private fields
|
||||
- `:include` will let you to write field policies for private fields and private fields
|
||||
|
|
|
@ -192,6 +192,62 @@ defmodule Ash.Policy.Authorizer do
|
|||
]
|
||||
}
|
||||
|
||||
@policy_group %Spark.Dsl.Entity{
|
||||
name: :policy_group,
|
||||
target: Ash.Policy.PolicyGroup,
|
||||
describe: """
|
||||
Groups a set of policies together by some condition.
|
||||
|
||||
If the condition on the policy group does not apply, then none of the policies within it apply.
|
||||
|
||||
This is primarily syntactic sugar. At compile time, the conditions from the policy group are
|
||||
added to each policy it contains, and the list is flattened out. This exists primarily to make it
|
||||
easier to reason about and write policies.
|
||||
|
||||
The following are equivalent:
|
||||
|
||||
```elixir
|
||||
policy_group condition1 do
|
||||
policy condition2 do
|
||||
...
|
||||
end
|
||||
|
||||
policy condition3 do
|
||||
...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```elixir
|
||||
policy [condition1, condition2] do
|
||||
...
|
||||
end
|
||||
|
||||
policy [condition1, condition3] do
|
||||
...
|
||||
end
|
||||
```
|
||||
""",
|
||||
schema: [
|
||||
condition: [
|
||||
type: {:custom, __MODULE__, :validate_condition, []},
|
||||
doc: """
|
||||
A check or list of checks that must be true in order for this policy to apply.
|
||||
"""
|
||||
]
|
||||
],
|
||||
args: [:condition],
|
||||
no_depend_modules: [:condition],
|
||||
recursive_as: :policies,
|
||||
entities: [
|
||||
policies: [
|
||||
@policy
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@bypass %{
|
||||
@policy
|
||||
| name: :bypass,
|
||||
|
@ -235,6 +291,7 @@ defmodule Ash.Policy.Authorizer do
|
|||
],
|
||||
entities: [
|
||||
@policy,
|
||||
@policy_group,
|
||||
@bypass
|
||||
],
|
||||
imports: [
|
||||
|
|
|
@ -166,9 +166,24 @@ defmodule Ash.Policy.Info do
|
|||
defp do_policies(resource) do
|
||||
resource
|
||||
|> Extension.get_entities([:policies])
|
||||
|> flatten_groups()
|
||||
|> set_access_type(default_access_type(resource))
|
||||
end
|
||||
|
||||
defp flatten_groups(policies) do
|
||||
Enum.flat_map(policies, fn
|
||||
%Ash.Policy.Policy{} = policy ->
|
||||
[policy]
|
||||
|
||||
%Ash.Policy.PolicyGroup{condition: condition, policies: policies} ->
|
||||
policies
|
||||
|> flatten_groups()
|
||||
|> Enum.map(fn policy ->
|
||||
%{policy | condition: List.wrap(condition) ++ List.wrap(policy.condition)}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
def default_access_type(resource) do
|
||||
Extension.get_opt(resource, [:policies], :default_access_type, :filter, false)
|
||||
end
|
||||
|
|
33
lib/ash/policy/policy_group.ex
Normal file
33
lib/ash/policy/policy_group.ex
Normal file
|
@ -0,0 +1,33 @@
|
|||
defmodule Ash.Policy.PolicyGroup do
|
||||
@moduledoc "Represents a policy group on an Ash.Resource"
|
||||
|
||||
# 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
|
||||
]
|
||||
|
||||
@doc false
|
||||
def transform(group) do
|
||||
if Enum.empty?(group.policies) do
|
||||
{:error, "Policy groups must contain at least one policy."}
|
||||
else
|
||||
if Enum.any?(group.policies, fn
|
||||
%Ash.Policy.Policy{bypass?: bypass?} ->
|
||||
bypass?
|
||||
|
||||
_ ->
|
||||
false
|
||||
end) do
|
||||
{:error, "Policy groups cannot contain bypass policies."}
|
||||
else
|
||||
if group.condition in [nil, []] do
|
||||
{:ok, %{group | condition: [{Ash.Policy.Check.Static, result: true}]}}
|
||||
else
|
||||
{:ok, group}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -35,12 +35,18 @@ defmodule Ash.Test.Support.PolicySimple.Car do
|
|||
authorize_if never()
|
||||
end
|
||||
|
||||
policy action_type([:read, :update, :destroy]) do
|
||||
policy action_type([:update, :destroy]) do
|
||||
authorize_if expr(exists(users, id == ^actor(:id)))
|
||||
end
|
||||
|
||||
policy [action_type(:read), expr(active != true)] do
|
||||
forbid_if always()
|
||||
policy_group action_type(:read) do
|
||||
policy do
|
||||
authorize_if expr(exists(users, id == ^actor(:id)))
|
||||
end
|
||||
|
||||
policy [expr(active != true)] do
|
||||
forbid_if always()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in a new issue