Argument-Based Scope Pattern

Copy Markdown View Source

For authorization that depends on a value reachable only through a relationship — e.g., "a user can refund an order only if the order belongs to one of their units" — the natural first reach is a relational scope that traverses the relationship directly:

scope :at_own_unit, expr(order.center_id in ^actor(:own_org_unit_ids))

This works for read actions (Ash lowers it to SQL). For write actions it forces AshGrant's DB-query fallback path, which has rough edges: composite inheritance corner cases, limits on function-wrapped relational refs, and pre/post-state ambiguity on updates that change foreign keys.

This guide describes an alternative that keeps scope expressions in-memory-evaluable and moves the relationship traversal into the resource's own change pipeline — with lazy loading so unrelated scopes pay no cost.

Prerequisite: Familiarity with Scopes and Authorization Patterns.

The pattern in one sentence

Declare scopes against action arguments, and let the resource populate those arguments from its own relationships — only when the actor's permissions actually need them.

Why argument-based instead of relational?

PropertyRelational order.center_id in ...Argument-based ^arg(:center_id) in ...
Expression evaluatorDB-query fallback on writesIn-memory, always
Composite inheritanceFragile (see #83, #86)Not involved
Pre/post state on updateAmbiguous if FK changesCaller/resource decides explicitly
Multi-hop relationshipsOne SQL query per hop patternResource loads what it needs, when it needs
Cost for scopes that don't need the relationshipAlways paysZero — load is skipped
Tamper resistanceN/AGuaranteed: resource resolves its own FKs

The last two rows are where this pattern distinguishes itself. A scope like :by_own_author (direct attribute) doesn't need to know about order at all. The pattern lets you add relational scopes alongside it without forcing every write to preload order.

AshGrant provides a resolve_argument entity that wires up the argument and the lazy change automatically:

defmodule MyApp.Orders.Refund do
  use Ash.Resource,
    domain: MyApp.Orders,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshGrant]

  ash_grant do
    resolver MyApp.PermissionResolver
    resource_name "refund"

    scope :always, true
    scope :by_own_author, expr(author_id == ^actor(:id))

    # Argument-based: compares an action argument, not a relationship
    scope :at_own_unit,
      expr(^arg(:center_id) in ^actor(:own_org_unit_ids))

    scope :at_own_unit_and_small,
      [:at_own_unit],
      expr(total_amount <= 100)

    # Auto-generates :center_id argument + lazy change on every write action
    resolve_argument :center_id, from_path: [:order, :center_id]
  end

  attributes do
    uuid_primary_key :id
    attribute :author_id, :uuid, public?: true, allow_nil?: false
    attribute :total_amount, :integer, public?: true, allow_nil?: false
  end

  relationships do
    belongs_to :order, MyApp.Orders.Order, allow_nil?: false
  end

  policies do
    policy action_type(:read),                       do: authorize_if AshGrant.filter_check()
    policy action_type([:create, :update, :destroy]), do: authorize_if AshGrant.check()
  end

  actions do
    defaults [:read, :destroy]
    create :create, do: accept [:author_id, :total_amount, :order_id]

    update :update do
      accept [:total_amount]
      require_atomic? false
    end
  end
end

Notice what the scope doesn't say:

  • No exists(order.memberships, ...)
  • No dot-path order.center_id
  • Just a plain comparison between an argument and an actor attribute

What the transformer generates

AshGrant.Transformers.AddArgumentResolvers walks every scope at compile time and records which arguments each scope references. For each resolve_argument declaration, it then:

  1. Validates the path: intermediates must be belongs_to relationships, the leaf must be an attribute. Invalid paths fail the compile.
  2. Validates that at least one scope references ^arg(:center_id) — a declaration no scope uses is a compile error.
  3. Adds an argument :center_id, <inferred_type>, allow_nil?: true to every targeted write action (create, update, destroy).
  4. Installs AshGrant.Changes.ResolveArgument on every targeted write action, with the compile-time list of "scopes that need this argument" baked in.

Multi-hop paths

resolve_argument :organization_id,
  from_path: [:order, :customer, :organization_id]

Works the same way — intermediates are belongs_to, leaf is an attribute.

Restricting to specific actions

resolve_argument :center_id,
  from_path: [:order, :center_id],
  for_actions: [:update, :destroy]

Defaults to all write actions; use for_actions: to narrow.

Runtime behavior

For each write action's execution:

  1. The change runs. If the actor is nil or none of the actor's permissions are for a scope that references this argument → no-op, argument stays unset.
  2. Otherwise:
    • create: the change reads the first-hop foreign key from the changeset's attributes (e.g., :order_id), loads the head record, then walks any remaining path keys through loaded relationships.
    • update / destroy: the change loads the relationship path on changeset.data and reads the leaf attribute.
  3. Changeset.set_argument(:center_id, value) is set; authorization proceeds.

Actor holds only "refund:*:update:by_own_author"

:by_own_author does not reference ^arg(:center_id). The change skips the DB load and returns the changeset unchanged. Authorization evaluates author_id == ^actor(:id) in-memory. Zero overhead.

Actor holds only "refund:*:update:at_own_unit"

:at_own_unit references ^arg(:center_id), which is in the scopes_needing set baked in at compile time. The change loads :order, sets the argument, and authorization evaluates ^arg(:center_id) in ^actor(:own_org_unit_ids).

Why this is safe

A common worry about argument-based checks is: "what if the caller tampers with the argument?" — e.g., passing a center_id they have access to while actually updating a record from a different center.

This pattern avoids that entirely: the resource itself computes the argument from its own authoritative FK relationships. The caller doesn't supply :center_id; the change does. The only way an attacker could influence the argument is to influence the actual order_id — which would change what record is updated in the first place.

When to use this pattern

Prefer argument-based scope + resolve_argument when:

  • The authorization check reads through one or more relationships (refund.order.center_id, comment.post.author_id, etc.).
  • You have multiple scopes on the same action, some needing the relationship and some not, and you don't want to pay the load cost for the cheap scopes.
  • The composite inheritance, function wrapping, or pre/post-state concerns of the relational scope path bite you.

Prefer relational scopes (expr(order.center_id in ...)) when:

  • The scope is used only on read actions (Ash lowers to SQL cleanly).
  • The scope is on a single-attribute, same-resource comparison — there's nothing to resolve.

Gotchas

for_update/for_create/for_destroy must receive the actor

The change runs during for_<action>/4. It needs the actor to introspect permissions. If your caller builds the changeset without the actor and only passes it to Ash.update/2, the change sees nil and skips the load — and the authorization fails with nil arguments.

Always pass actor: to for_*/4 when using this pattern.

Multi-tenancy

resolve_argument forwards the changeset's tenant to the internal Ash.get!/Ash.load! calls. Paths that traverse resources with multitenancy strategy: :attribute resolve correctly as long as you call the action with tenant: set — the same tenant you would pass to any other multitenant action.

require_atomic? false on update/destroy

The generated change does not implement the atomic protocol. If your data layer supports atomic updates (most do), set require_atomic? false on affected actions.

Relationship with the write: scope option

The write: option on scope was an earlier escape hatch for the same problem this pattern solves: a simpler, in-memory-evaluable expression for write actions when the main filter traverses relationships.

With argument-based scopes + resolve_argument, the scope expression is already in-memory-evaluable and the relationship traversal lives in the change module. write: is deprecated as of 0.14 — new code should use this pattern. Existing write: usage still compiles (with a deprecation warning) to give projects time to migrate.

Hand-rolled version (under the hood)

The DSL sugar is equivalent to the following hand-rolled wiring. Useful to know when you need a customized variant (e.g., different resolution logic for specific actions):

# Resource — no resolve_argument entity
ash_grant do
  scope :at_own_unit, expr(^arg(:center_id) in ^actor(:own_org_unit_ids))
end

actions do
  update :update do
    accept [:total_amount]
    require_atomic? false
    argument :center_id, :uuid, allow_nil?: true
    change {MyApp.Orders.ResolveCenterIdFromOrder, []}
  end
end
defmodule MyApp.Orders.ResolveCenterIdFromOrder do
  use Ash.Resource.Change
  alias Ash.Changeset

  @impl true
  def change(changeset, _opts, ctx) do
    actor = ctx.actor || changeset.context[:private][:actor]

    if needs_center_id?(changeset.resource, actor) do
      loaded = Ash.load!(changeset.data, :order, authorize?: false)
      Changeset.set_argument(changeset, :center_id, loaded.order.center_id)
    else
      changeset
    end
  end

  defp needs_center_id?(_resource, nil), do: false

  defp needs_center_id?(resource, %{permissions: perms}) when is_list(perms) do
    Enum.any?(perms, &scope_references_center_id?(resource, &1))
  end

  defp needs_center_id?(_, _), do: false

  defp scope_references_center_id?(resource, perm_string) do
    with {:ok, parsed} <- AshGrant.Permission.parse(perm_string),
         scope_atom when is_atom(scope_atom) <- safe_to_atom(parsed.scope),
         filter when filter not in [nil, true, false] <-
           AshGrant.Info.resolve_write_scope_filter(resource, scope_atom, %{}) do
      AshGrant.ArgumentAnalyzer.references_arg?(filter, :center_id)
    else
      _ -> false
    end
  end

  defp safe_to_atom(s) when is_atom(s), do: s

  defp safe_to_atom(s) when is_binary(s) do
    String.to_existing_atom(s)
  rescue
    ArgumentError -> nil
  end
end

Prefer the DSL sugar unless you need this kind of surgical control.

Reference implementation

See the test suite for working implementations of both styles:

  • test/support/resources/auth_pattern_refund_dsl.ex — DSL sugar
  • test/support/resources/auth_pattern_refund.ex — hand-rolled change module
  • test/ash_grant/resolve_argument_dsl_test.exs — DSL behavior tests
  • test/ash_grant/argument_based_scope_test.exs — hand-rolled behavior tests
  • test/ash_grant/argument_analyzer_test.exs — unit tests for the AST walker
  • test/ash_grant/resolve_argument_validation_test.exs — compile-time errors
  • test/ash_grant/resolve_argument_property_test.exs — property-based tests