Current section
Files
Jump to
Current section
Files
usage-rules.md
# AshGrant Usage Rules
> These rules help LLMs correctly use AshGrant — a permission-based authorization
> extension for Ash Framework. Follow them when generating code that uses AshGrant.
## What AshGrant Is
AshGrant is a **permission evaluation** extension, not a role management system.
It evaluates permission strings against resources and actions using deny-wins semantics.
It integrates with Ash's policy authorizer via three check types.
Roles, role assignments, and permission storage are **your responsibility**.
AshGrant only needs a resolver that returns permission strings for a given actor.
## Permission String Format
```
[!]resource:instance_id:action:scope[:field_group]
```
| Part | Required | Description |
|---------------|----------|--------------------------------------------------|
| `!` | No | Deny prefix — deny rules always override allows |
| `resource` | Yes | Resource name or `*` for all |
| `instance_id` | Yes | `*` for RBAC, specific ID for instance access |
| `action` | Yes | Action name, `*` for all, or `prefix*` wildcard |
| `scope` | Yes | Scope name (e.g., `all`, `own`) or empty string |
| `field_group` | No | 5th part for column-level access control |
### RBAC permissions (instance_id = `*`)
```elixir
"blog:*:read:always" # Read all blogs
"blog:*:read:published" # Read only published blogs
"blog:*:update:own" # Update own blogs only
"blog:*:*:always" # All actions on all blogs
"*:*:read:always" # Read all resources
"blog:*:read*:always" # All read-type actions (read, read_all, etc.)
"!blog:*:delete:always" # DENY delete on all blogs
```
### Instance permissions (specific instance_id)
```elixir
"blog:post_abc123:read:" # Read specific post (no scope condition)
"blog:post_abc123:*:" # Full access to specific post
"!blog:post_abc123:delete:" # DENY delete on specific post
"doc:doc_123:update:draft" # Update only when document is in draft (ABAC)
```
Instance permissions with an empty scope (trailing colon) mean unconditional access.
Instance permissions with a scope name impose an attribute-based condition.
### Field-level permissions (5-part format)
```elixir
"employee:*:read:always:public" # See only public fields
"employee:*:read:always:sensitive" # See public + sensitive fields
"employee:*:read:always:confidential" # See all fields including confidential
```
When the 5th part is omitted (4-part format), all fields are visible.
### DON'T: Combine a deny (`!`) with a field_group
Field-group access is **positive-only**. A deny rule must not carry a field_group
(5th part) — express column restrictions in the resource's `field_group`
definition (`inherits`/`except`/`mask`) and grant groups positively instead.
```elixir
# DON'T — a deny carrying a field_group is invalid; it over-denies the whole action
"!employee:*:read:always:sensitive"
# DO — grant the group that contains exactly the fields the actor may see
"employee:*:read:always:public" # everything except sensitive/confidential
```
The `field_group_permissions` option on the `ash_grant` block controls how an
invalid field-group deny is signaled (it never changes the outcome, which is
already fail-closed): `:off` (silent), `:warn` (logs a warning — default), or
`:strict` (raises `AshGrant.PermissionValidation.InvalidPermissionError`). Set a
global default with `config :ash_grant, field_group_permissions: :strict`.
Field-group visibility is **per-record**: with `default_field_policies: true`, a
non-trivial scope or a specific instance id on a 5-part grant restricts the
group's fields to the matching rows only (forbidden elsewhere). Combine a broad
row grant with a narrow field grant to vary field visibility by record:
```elixir
# public fields on all rows, sensitive fields only on owned rows
["employee:*:read:always:public", "employee:*:read:own:sensitive"]
```
A trivial scope (`always`) keeps the group visible on every row (action-wide).
## Resource Setup
### Always include these three things
1. `authorizers: [Ash.Policy.Authorizer]` in resource options
2. `extensions: [AshGrant]` in resource options
3. An `ash_grant` block with at least a `resolver` and one scope
```elixir
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshGrant]
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
scope :own, expr(author_id == ^actor(:id))
end
end
```
### DO: Use `default_policies: true` to eliminate boilerplate
```elixir
ash_grant do
resolver MyApp.PermissionResolver
default_policies true # Generates read + write policies automatically
scope :always, true
scope :own, expr(author_id == ^actor(:id))
end
# No policies block needed!
```
### DO: Use explicit policies when you need bypasses or custom logic
```elixir
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
scope :own, expr(author_id == ^actor(:id))
end
policies do
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
policy action_type(:read) do
authorize_if AshGrant.filter_check()
end
policy action_type([:create, :update, :destroy]) do
authorize_if AshGrant.check()
end
end
```
### DON'T: Use both `default_policies: true` and a manual `policies` block
The transformer adds policies automatically. Defining both creates conflicts.
### DON'T: Forget `authorizers: [Ash.Policy.Authorizer]`
AshGrant generates policy checks, but Ash must be told to enforce them.
## DSL Configuration
### `ash_grant` block options
| Option | Type | Required | Default | Description |
|------------------------|-------------------|----------|---------|--------------------------------------------------------------|
| `resolver` | module or fun/2 | Yes | — | Resolves permissions for actors |
| `default_policies` | bool/atom | No | `false` | `true`, `:always`, `:read`, or `:write` |
| `default_field_policies`| boolean | No | `false` | Auto-generate `field_policies` from `field_group` definitions|
| `resource_name` | string | No | derived | Override resource name for permission matching |
| `instance_key` | atom | No | `:id` | Field to match instance permission IDs against |
`resource_name` defaults to the last segment of the module name, lowercased
(e.g., `MyApp.Blog.Post` becomes `"post"`).
`instance_key` changes which field instance IDs are matched against. By default,
`"feed:feed_abc:read:"` generates `WHERE id IN ('feed_abc')`. With
`instance_key :feed_id`, it generates `WHERE feed_id IN ('feed_abc')`.
### `scope_through` entity
Propagates a parent resource's instance permissions to a child resource via a
`belongs_to` relationship.
```elixir
scope_through :relationship_name
scope_through :relationship_name, actions: [:read, :update]
```
```elixir
ash_grant do
resolver MyApp.PermissionResolver
default_policies true
scope :always, true
scope :own, expr(author_id == ^actor(:id))
# Posts inherit Feed's instance permissions via :feed relationship
scope_through :feed
end
relationships do
belongs_to :feed, MyApp.Feed
end
```
When a user has `"feed:feed_abc:read:"`, they can read all posts where
`feed_id == "feed_abc"`. Use `actions:` to limit propagation to specific actions.
### Scope entity
```elixir
scope :name, filter_expression
scope :name, [:parent_scopes], filter_expression
scope :name, filter_expression, description: "Human-readable text"
```
- Use `true` for a scope that matches all records (no filtering).
- Use `expr(...)` for attribute-based filtering.
- Use the optional second argument (list of atoms) to inherit from parent scopes.
- `write:` option exists but is **deprecated** (see "DON'T: Use the `write:`
scope option" below) — prefer `resolve_argument` for multi-hop cases.
```elixir
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
scope :own_draft, [:own], expr(status == :draft) # own AND draft
scope :same_tenant, expr(tenant_id == ^tenant()) # Multi-tenancy
end
```
### Read vs write scope evaluation
- **Reads** — scopes compile to SQL via `AshGrant.filter_check/1`.
- **Writes** — scopes are evaluated by `AshGrant.check/1`. Simple attribute
scopes run in memory; single-hop relational scopes (`exists()`, dot-paths)
fall back to a DB query automatically. For multi-hop or composite cases,
use the `resolve_argument` entity (see next section).
```elixir
ash_grant do
resolver MyApp.PermissionResolver
# Simple — in-memory for writes
scope :own, expr(author_id == ^actor(:id))
# Single-hop relational — DB query fallback on writes
scope :team_member, expr(exists(team.members, user_id == ^actor(:id)))
end
```
> **The `write:` scope option is deprecated as of 0.14.** See the "DON'T:
> Use the `write:` scope option" rule below.
### `resolve_argument` entity (argument-based scopes)
For authorization that reaches through one or more relationships, declare
scopes against an action argument and let the resource populate the argument
from its own relationships:
```elixir
ash_grant do
scope :at_own_unit, expr(^arg(:center_id) in ^actor(:own_org_unit_ids))
# The transformer auto-injects `argument :center_id` + a lazy change on
# every create/update/destroy action. The change only loads :order when
# an in-play permission uses a scope that references ^arg(:center_id).
resolve_argument :center_id, from_path: [:order, :center_id]
end
```
Options:
| Option | Required | Description |
|--------|----------|-------------|
| `from_path` | Yes | List of atoms walking belongs_to relationships to a leaf attribute. Example: `[:order, :center_id]`. Multi-hop is supported: `[:order, :customer, :organization_id]`. |
| `for_actions` | No | Restrict injection to specific action names. Defaults to all create/update/destroy actions. |
Compile-time validation:
- Path intermediates must be `:belongs_to`; the leaf must be an attribute.
- At least one scope must reference `^arg(<name>)` (dead declarations error out).
See `guides/argument-based-scope.md` for the full rationale and examples.
### Field group entity
```elixir
field_group :name, [:field1, :field2]
field_group :name, [:field1, :field2], inherits: [:parent_groups]
```
Field groups define sets of fields for column-level read authorization.
```elixir
ash_grant do
resolver MyApp.PermissionResolver
field_group :public, [:name, :department, :position]
field_group :sensitive, [:phone, :address], inherits: [:public] # Inherits public
field_group :confidential, [:salary, :ssn], inherits: [:sensitive] # Inherits sensitive
end
```
## Scope Patterns
### Actor references
Use `^actor(:field)` to reference the current actor's attributes:
```elixir
scope :own, expr(author_id == ^actor(:id))
scope :same_org, expr(org_id == ^actor(:org_id))
```
### Tenant references
Use `^tenant()` for multi-tenant scopes:
```elixir
scope :same_tenant, expr(tenant_id == ^tenant())
```
### Context injection
Use `^context(:key)` for injectable, testable values:
```elixir
# Definition
scope :today, expr(fragment("DATE(inserted_at) = ?", ^context(:reference_date)))
scope :threshold, expr(amount < ^context(:max_amount))
# Usage — inject at query time
Post
|> Ash.Query.for_read(:read)
|> Ash.Query.set_context(%{reference_date: Date.utc_today()})
|> Ash.read!(actor: actor)
```
### DO: Prefer `^context(:key)` over database functions for testability
```elixir
# DO
scope :today, expr(fragment("DATE(inserted_at) = ?", ^context(:reference_date)))
# DON'T
scope :today, expr(fragment("DATE(inserted_at) = CURRENT_DATE"))
```
### Scope inheritance
Child scopes combine parent filters with AND logic:
```elixir
scope :own, expr(author_id == ^actor(:id))
scope :own_draft, [:own], expr(status == :draft)
# Effective filter: author_id == ^actor(:id) AND status == :draft
```
## Check Types
AshGrant provides three check types. Use the right one for each action type.
### `AshGrant.filter_check/1` — for read actions
Returns a filter expression that limits query results. Supports `exists()` scopes
because filters are converted to SQL.
```elixir
policy action_type(:read) do
authorize_if AshGrant.filter_check()
end
```
### `AshGrant.check/1` — for write actions
Returns true/false by evaluating the scope in-memory against the record.
```elixir
policy action_type([:create, :update, :destroy]) do
authorize_if AshGrant.check()
end
```
### `AshGrant.field_check/1` — for field-level access
Used inside Ash's `field_policies` block to control column visibility.
```elixir
field_policies do
field_policy [:salary, :ssn] do
authorize_if AshGrant.field_check(:confidential)
end
field_policy :* do
authorize_if always()
end
end
```
### DO: Override action names when Ash action names differ from permission actions
```elixir
policy action(:publish) do
authorize_if AshGrant.check(action: "update")
end
policy action(:list_published) do
authorize_if AshGrant.filter_check(action: "read")
end
```
### DON'T: Use `filter_check` for write actions or `check` for read actions
- `filter_check` returns filter expressions — meaningless for writes.
- `check` returns true/false — doesn't filter read results.
### DO: Use `exists()` scopes for simple single-hop writes — DB query fallback handles them
Scopes with `exists()` or dot-paths that traverse one belongs_to hop work
automatically for reads and writes. For writes, a DB query verifies the scope.
```elixir
# DO — works for both reads and writes automatically
scope :team_member, expr(exists(team.memberships, user_id == ^actor(:id)))
```
### DO: Prefer argument-based scopes + `resolve_argument` for multi-hop or composite cases
When the authorization value lives one or more relationships away
(e.g., `refund → order → center_id`), or when you inherit a relational parent
into a composite child, prefer **argument-based scopes** over deep
relationship traversal in the scope expression itself:
```elixir
ash_grant do
# Scope compares an action argument, not a traversed 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)
# Declare how the argument is populated from the record's relationships.
# The transformer auto-injects an `argument :center_id` and a lazy change
# on every create/update/destroy action. The change only loads :order
# when an in-play permission uses a scope that references ^arg(:center_id).
resolve_argument :center_id, from_path: [:order, :center_id]
end
```
Why this is better than `expr(order.center_id in ^actor(...))` on writes:
- Scope expression stays in-memory-evaluable — no DB query fallback.
- Composite inheritance works without edge cases.
- Scopes that don't reference the argument (e.g., `:by_own_author`) pay
zero cost — the lazy change skips the DB load for those actors.
- The resource resolves its own FK, so callers can't tamper with the value.
Requirements:
- Always pass `actor:` to `for_update/4`, `for_create/4`, `for_destroy/4` —
the lazy change needs the actor to introspect permissions.
- Set `require_atomic? false` on update/destroy actions if your data layer
defaults to atomic updates.
See `guides/argument-based-scope.md` for the full pattern.
### DON'T: Use the `write:` scope option (deprecated)
The `write:` override has been **deprecated as of 0.14**. It was an escape
hatch for relational scopes that couldn't be evaluated in memory on writes;
argument-based scopes (above) subsume its use cases cleanly. Using `write:`
still compiles but emits a compile-time deprecation warning.
For new code:
- Multi-hop / composite: use `resolve_argument` + argument-based scopes.
- Read-only semantics: define a separate read-only scope name or gate at
the policy layer, not via `write: false`.
## Deny-Wins Semantics
When both allow and deny rules match, **deny always wins**:
```elixir
permissions = [
"blog:*:*:always", # Allow all blog actions
"!blog:*:delete:always" # Deny delete
]
# Result: read ✓, update ✓, delete ✗ (deny wins)
```
Evaluation rules:
1. If **any** deny rule matches → **denied**
2. If no deny matches and at least one allow matches → **allowed**
3. If **no** rules match → **denied** (deny by default)
## PermissionResolver Behaviour
Implement `AshGrant.PermissionResolver` to provide permissions for actors.
### Simple resolver (returns strings)
```elixir
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(nil, _context), do: []
@impl true
def resolve(actor, _context) do
actor
|> MyApp.Accounts.get_roles()
|> Enum.flat_map(& &1.permissions)
end
end
```
### Resolver with metadata (for debugging with `explain/4`)
Return `AshGrant.PermissionInput` structs to include source tracking:
```elixir
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(nil, _context), do: []
@impl true
def resolve(actor, _context) do
actor
|> MyApp.Accounts.get_roles()
|> Enum.flat_map(fn role ->
Enum.map(role.permissions, fn perm ->
%AshGrant.PermissionInput{
string: perm,
description: "From role permissions",
source: "role:#{role.name}"
}
end)
end)
end
end
```
### Custom structs with the Permissionable protocol
Implement `AshGrant.Permissionable` for your own structs:
```elixir
defimpl AshGrant.Permissionable, for: MyApp.RolePermission do
def to_permission_input(%MyApp.RolePermission{} = rp) do
%AshGrant.PermissionInput{
string: rp.permission_string,
description: rp.label,
source: "role:#{rp.role_name}"
}
end
end
```
### DON'T: Return nil from the resolver
Always return an empty list `[]` for unauthenticated or unknown actors.
## Default Policies
`default_policies` controls automatic policy generation:
| Value | Read policy | Write policy |
|----------|-------------|--------------|
| `false` | No | No |
| `true` | Yes | Yes |
| `:always` | Yes | Yes |
| `:read` | Yes | No |
| `:write` | No | Yes |
Generated policies are equivalent to:
```elixir
policies do
policy action_type(:read) do
authorize_if AshGrant.filter_check()
end
policy action_type([:create, :update, :destroy]) do
authorize_if AshGrant.check()
end
end
```
Use `:read` or `:write` when you need auto-generation for one type and
explicit control over the other.
## Instance Permissions
Instance permissions enable resource-sharing patterns (like Google Docs sharing).
```elixir
# Grant user access to a specific document
"document:doc_abc123:read:" # Read access (no conditions)
"document:doc_abc123:*:" # Full access
# Grant conditional instance access (ABAC)
"document:doc_abc123:update:draft" # Update only when in draft status
```
For read actions, `FilterCheck` automatically builds `WHERE id IN (...)` filters
from instance permissions and combines them with RBAC scope filters using OR.
Instance permissions match against the resource's own key field only (`:id` by
default, or the field set by `instance_key`). They do **not** propagate to child
resources automatically — use `scope_through` for that.
### `instance_key` — match against a different field
```elixir
ash_grant do
resolver MyApp.PermissionResolver
instance_key :feed_id # "feed:feed_abc:read:" → WHERE feed_id IN ('feed_abc')
scope :always, true
end
```
### `scope_through` — propagate parent permissions to children
```elixir
# Parent: Feed (user has "feed:feed_abc:read:")
# Child: Post (belongs_to :feed)
ash_grant do
resolver MyApp.PermissionResolver
default_policies true
scope :always, true
scope_through :feed # Posts where feed_id == "feed_abc" are now readable
end
```
Works with FilterCheck (reads), Check (writes), and CanPerform calculations.
Parent instance filters are combined with RBAC scopes using OR logic.
### DON'T: Assume instance permissions propagate to children automatically
```elixir
# User has "feed:feed_abc:read:"
# WRONG — this only grants access to the Feed record itself, not its Posts
# (unless Post has scope_through :feed)
# CORRECT — add scope_through to the child resource
ash_grant do
scope_through :feed
end
```
## Field-Level Permissions
### Manual field policies (Mode A)
Write `field_policies` yourself using `AshGrant.field_check/1`:
```elixir
ash_grant do
resolver MyApp.PermissionResolver
field_group :public, [:name, :department]
field_group :sensitive, [:phone, :address], inherits: [:public]
field_group :confidential, [:salary, :ssn], inherits: [:sensitive]
end
field_policies do
field_policy [:salary, :ssn] do
authorize_if AshGrant.field_check(:confidential)
end
field_policy [:phone, :address] do
authorize_if AshGrant.field_check(:sensitive)
end
field_policy :* do
authorize_if always()
end
end
```
### Auto-generated field policies (Mode B)
Set `default_field_policies: true` to auto-generate from field group definitions:
```elixir
ash_grant do
resolver MyApp.PermissionResolver
default_field_policies true
field_group :public, [:name, :department]
field_group :sensitive, [:phone, :address], inherits: [:public]
field_group :confidential, [:salary, :ssn], inherits: [:sensitive]
end
# field_policies block is generated automatically
```
### Field group inheritance
Field groups support inheritance. A group that inherits from another includes
all of the parent's fields plus its own:
```
:public → [:name, :department]
:sensitive → [:name, :department, :phone, :address] (inherits :public)
:confidential → [:name, :department, :phone, :address, :salary, :ssn] (inherits :sensitive)
```
If an actor's permission uses the 4-part format (no field_group), all fields
are visible. The 5th part only restricts when explicitly present.
## Debugging
### `AshGrant.explain/4`
Returns an `AshGrant.Explanation` struct with details about an authorization decision:
```elixir
explanation = AshGrant.explain(MyApp.Post, :read, actor)
# Print human-readable output
explanation |> AshGrant.Explanation.to_string() |> IO.puts()
```
The explanation includes:
- All matching permissions with metadata (description, source)
- All evaluated permissions with match/no-match reasons
- Scope information and field groups
- The final decision and reason
### `AshGrant.Introspect`
Runtime introspection for building admin UIs and permission management:
```elixir
# Check if actor can perform an action
AshGrant.Introspect.can?(MyApp.Post, :read, actor)
# => :allow or :deny
# List all allowed actions
AshGrant.Introspect.allowed_actions(MyApp.Post, actor)
# Get all permissions with their status
AshGrant.Introspect.actor_permissions(MyApp.Post, actor)
# List all possible permissions for a resource
AshGrant.Introspect.available_permissions(MyApp.Post)
```
### `AshGrant.Info`
DSL introspection helpers for accessing configuration at runtime:
```elixir
AshGrant.Info.resolver(MyApp.Post) # The configured resolver module
AshGrant.Info.scopes(MyApp.Post) # List of scope definitions
AshGrant.Info.field_groups(MyApp.Post) # List of field group definitions
AshGrant.Info.resource_name(MyApp.Post) # The resource name string
```
## Policy Testing
### `mix ash_grant.verify`
Run policy configuration tests defined in YAML or Elixir files:
```bash
# Run all YAML tests in default directories
mix ash_grant.verify
# Run a specific file
mix ash_grant.verify path/to/test.yaml --verbose
# Run all tests in a directory
mix ash_grant.verify priv/policy_tests/
# Run an Elixir fixture file
mix ash_grant.verify test/support/policy_test_fixtures.ex
```
### YAML test format
```yaml
resource: MyApp.Blog.Post
tests:
- name: "Editor can read all posts"
actor:
role: editor
permissions:
- "post:*:read:always"
action: read
expected: allow
```
## Common Mistakes
### Using `check()` for read actions
```elixir
# WRONG — check() returns true/false, doesn't filter results
policy action_type(:read) do
authorize_if AshGrant.check()
end
# CORRECT
policy action_type(:read) do
authorize_if AshGrant.filter_check()
end
```
### Missing the `:always` scope
Every resource with AshGrant should define a `:always` scope. Without it,
permissions like `"post:*:read:always"` will raise a runtime error because
the scope `"always"` cannot be resolved.
```elixir
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true # Always include this
scope :own, expr(author_id == ^actor(:id))
end
```
### Using `exists()` scopes without a data layer
The DB query fallback requires a data layer (e.g., AshPostgres). For resources
without a data layer, `exists()` conditions are replaced with `true` during
in-memory evaluation. Use `write:` to provide a direct-field expression:
```elixir
# For resources WITHOUT a data layer, use write: to provide an alternative
scope :same_org, expr(exists(org.users, id == ^actor(:id))),
write: expr(org_id == ^actor(:org_id))
```
### Forgetting that deny-wins means no order dependency
Deny rules win regardless of where they appear in the permission list.
You cannot "override" a deny with a later allow.
```elixir
# These are equivalent — deny ALWAYS wins
["!post:*:delete:always", "post:*:*:always"]
["post:*:*:always", "!post:*:delete:always"]
```
### Using wrong permission format for instances
```elixir
# WRONG — 3-part format is legacy and may be ambiguous
"blog:post_abc123:read"
# CORRECT — use 4-part format with trailing colon for no-scope instance access
"blog:post_abc123:read:"
```