Current section
Files
Jump to
Current section
Files
lib/ash_parc.ex
defmodule AshParc do
@moduledoc """
Ash adapter for `Parc`, the framework-agnostic PARC authorization contract. `Parc`
defines the request, the pipeline of preparers, the decider, and the
allow/deny/challenge/error verdict (see `Parc` and `Parc.PolicyDecider`); AshParc
binds that contract to Ash's policy DSL, so one policy, declared once, can
authorize every action across every resource.
AshParc exposes that one policy at two enforcement points:
- `can_perform/2`: an `Ash.Policy.SimpleCheck` ref for a `policies` block, the
strict yes/no gate on an action. It builds the request and runs the policy, since
Ash invokes the check and wants a verdict (mapping in `AshParc.PolicyCheck`).
- `build_request/3`: the `Parc.Request` for an Ash evaluation, for a resource-owned
`Ash.Policy.FilterCheck` that row-scopes a read. AshParc only builds it; the
resource runs the policy and turns the allow `obligations` into an `Ash.Expr`
itself, the one place column names live.
The same decider serves both points. On a read it returns `{:allow, obligations}`;
the gate reads the verdict, and the filter check consumes the obligations.
## Defining a policy
Declare the policy as a `Parc.Pipeline` (see `Parc.Pipeline` for `prepare`/`decide`
and `Parc.PolicyDecider` for the decider contract), with `AshParc.ResetAshContext` as
its first preparer to uplift the shared-context keys it trusts and drop the rest. The
decider branches on `request.action.name` to emit scope obligations on a read
(`{:allow, [{:scope, :organization, ids}]}`), which the filter check below turns into
a filter.
## Guarding resource actions
Name the policy from the resource's `policies` block. A write or generic action is
gated by `can_perform/2`; `:attributes`/`:arguments` name what to project into the
request (where `organization_id` lives, an instance attribute on a changeset, an
argument on a generic action), and `:context` lifts an argument or attribute into the
request context under a normalized key the preparers and decider read:
policy action(:update) do
authorize_if AshParc.can_perform(MyApp.AuthzPolicy, attributes: [:organization_id])
end
policy action(:archive) do
authorize_if AshParc.can_perform(MyApp.AuthzPolicy, arguments: [:organization_id])
end
policy action(:read_record) do
authorize_if AshParc.can_perform(MyApp.AuthzPolicy,
arguments: [:organization_id],
context: [target_user_id: {:argument, :user_id}]
)
end
A read is row-scoped by a resource-owned `Ash.Policy.FilterCheck`. It builds the
request with `build_request/3`, runs it through the policy itself, and turns the
verdict into a filter, the only resource-specific piece, kept where column
knowledge belongs:
policy action_type(:read) do
authorize_if MyApp.Widget.ReadScope
end
defmodule MyApp.Widget.ReadScope do
use Ash.Policy.FilterCheck
@impl true
def filter(actor, authorizer, _opts) do
request = AshParc.build_request(actor, authorizer)
scope(MyApp.AuthzPolicy.run(request))
end
defp scope({:allow, [{:scope, :organization, ids}]}), do: expr(organization_id in ^ids)
defp scope({:deny, _reasons}), do: false
defp scope({:error, reason}), do: raise("scope decider error: \#{inspect(reason)}")
end
The `{:deny, _}` clause returns `false`, which scopes the read to no rows only under
`config :ash, policies: [no_filter_static_forbidden_reads?: false]`; without it Ash
raises `Ash.Error.Forbidden`.
"""
@doc """
An `AshParc.PolicyCheck` ref for `policy`, used in an Ash resource `policies` block:
authorize_if AshParc.can_perform(MyApp.Policy, attributes: [:organization_id])
`opts` names what to pass from the action into the request:
- `:arguments`: action arguments to pass into `request.action.arguments` (default `[]`).
- `:attributes`: instance attributes to pass into `request.resource.attributes`, on update and
destroy (default `[]`).
- `:context`: a keyword list lifting an argument or instance attribute into `request.context`
under a _normalized_ key, e.g. `[target_user_id: {:argument, :user_id}]` or
`[..., {:attribute, :user_id}]` (default `[]`). The resource, which alone knows its action's
argument/attribute names, declares the source; preparers and the decider read the normalized
key, so they stay generic. See `AshParc.RequestBuilder`.
Raises `ArgumentError` at compile time on an invalid spec. The check carries no
permission; the decider derives meaning from the introspected action and the
prepared context.
"""
@spec can_perform(module(), keyword()) :: {module(), keyword()}
def can_perform(policy, opts \\ []) when is_atom(policy) and is_list(opts) do
arguments = Keyword.get(opts, :arguments, [])
attributes = Keyword.get(opts, :attributes, [])
context = Keyword.get(opts, :context, [])
validate_projection!(:arguments, arguments)
validate_projection!(:attributes, attributes)
validate_context_projection!(context)
{AshParc.PolicyCheck,
policy: policy, arguments: arguments, attributes: attributes, context: context}
end
@doc """
Builds the `Parc.Request` for an Ash policy evaluation `context` (a changeset,
query, or action input).
The request-building half of the adapter. AshParc only builds the request; the
resource's `Ash.Policy.FilterCheck` runs the policy and turns the verdict into a
filter (see the moduledoc).
`opts` names what to pass from the action into the request:
- `:arguments`: action arguments to pass into `request.action.arguments` (default `[]`).
- `:attributes`: instance attributes to pass into `request.resource.attributes`, on update and
destroy (default `[]`).
- `:context`: arguments/attributes lifted into `request.context` under a normalized key (default
`[]`); see `can_perform/2`.
Raises `ArgumentError` when `context` carries no recognized subject.
"""
@spec build_request(term(), map(), keyword()) :: Parc.Request.t()
def build_request(actor, context, opts \\ []) when is_list(opts) do
AshParc.RequestBuilder.build(actor, context, opts)
end
defp validate_projection!(_key, spec) when is_list(spec) do
Enum.each(spec, fn
name when is_atom(name) ->
:ok
other ->
raise ArgumentError,
"invalid projection entry #{inspect(other)}; expected an attribute atom"
end)
end
defp validate_projection!(key, other) do
raise ArgumentError,
"invalid #{inspect(key)} #{inspect(other)}; expected a list of attribute atoms"
end
defp validate_context_projection!(spec) when is_list(spec) do
Enum.each(spec, fn
{dest, {kind, source}}
when is_atom(dest) and kind in [:argument, :attribute] and is_atom(source) ->
:ok
other ->
raise ArgumentError,
"invalid :context entry #{inspect(other)}; " <>
"expected {normalized_key, {:argument | :attribute, source}}"
end)
end
defp validate_context_projection!(other) do
raise ArgumentError,
"invalid :context #{inspect(other)}; " <>
"expected a keyword list of {normalized_key, {:argument | :attribute, source}}"
end
end