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