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:
Zach Daniel 2024-08-09 16:48:54 -04:00
parent 5a4864650b
commit dc73c3a3d5
7 changed files with 455 additions and 3 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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: [

View file

@ -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

View 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

View file

@ -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