diff --git a/.formatter.exs b/.formatter.exs index 0b3a4c4e..f5f4102a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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, diff --git a/documentation/dsls/DSL:-Ash.Policy.Authorizer.md b/documentation/dsls/DSL:-Ash.Policy.Authorizer.md index 1be64e30..4424f10e 100644 --- a/documentation/dsls/DSL:-Ash.Policy.Authorizer.md +++ b/documentation/dsls/DSL:-Ash.Policy.Authorizer.md @@ -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 diff --git a/documentation/topics/security/policies.md b/documentation/topics/security/policies.md index 1302935f..614eb280 100644 --- a/documentation/topics/security/policies.md +++ b/documentation/topics/security/policies.md @@ -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 diff --git a/lib/ash/policy/authorizer/authorizer.ex b/lib/ash/policy/authorizer/authorizer.ex index 2d6ae785..a8fe4dfd 100644 --- a/lib/ash/policy/authorizer/authorizer.ex +++ b/lib/ash/policy/authorizer/authorizer.ex @@ -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: [ diff --git a/lib/ash/policy/info.ex b/lib/ash/policy/info.ex index 7569a3d0..63a84c6a 100644 --- a/lib/ash/policy/info.ex +++ b/lib/ash/policy/info.ex @@ -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 diff --git a/lib/ash/policy/policy_group.ex b/lib/ash/policy/policy_group.ex new file mode 100644 index 00000000..bdd37e37 --- /dev/null +++ b/lib/ash/policy/policy_group.ex @@ -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 diff --git a/test/support/policy_simple/resources/car.ex b/test/support/policy_simple/resources/car.ex index 5c72b45f..aa99a1b1 100644 --- a/test/support/policy_simple/resources/car.ex +++ b/test/support/policy_simple/resources/car.ex @@ -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