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?
| Property | Relational order.center_id in ... | Argument-based ^arg(:center_id) in ... |
|---|---|---|
| Expression evaluator | DB-query fallback on writes | In-memory, always |
| Composite inheritance | Fragile (see #83, #86) | Not involved |
| Pre/post state on update | Ambiguous if FK changes | Caller/resource decides explicitly |
| Multi-hop relationships | One SQL query per hop pattern | Resource loads what it needs, when it needs |
| Cost for scopes that don't need the relationship | Always pays | Zero — load is skipped |
| Tamper resistance | N/A | Guaranteed: 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.
Using the DSL sugar (recommended)
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
endNotice 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:
- Validates the path: intermediates must be
belongs_torelationships, the leaf must be an attribute. Invalid paths fail the compile. - Validates that at least one scope references
^arg(:center_id)— a declaration no scope uses is a compile error. - Adds an
argument :center_id, <inferred_type>, allow_nil?: trueto every targeted write action (create, update, destroy). - Installs
AshGrant.Changes.ResolveArgumenton 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:
- The change runs. If the actor is
nilor none of the actor's permissions are for a scope that references this argument → no-op, argument stays unset. - 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.dataand reads the leaf attribute.
- create: the change reads the first-hop foreign key from the
changeset's attributes (e.g.,
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
enddefmodule 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
endPrefer 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 sugartest/support/resources/auth_pattern_refund.ex— hand-rolled change moduletest/ash_grant/resolve_argument_dsl_test.exs— DSL behavior teststest/ash_grant/argument_based_scope_test.exs— hand-rolled behavior teststest/ash_grant/argument_analyzer_test.exs— unit tests for the AST walkertest/ash_grant/resolve_argument_validation_test.exs— compile-time errorstest/ash_grant/resolve_argument_property_test.exs— property-based tests