AshGrant provides check modules that integrate with Ash's policy system, plus DSL options for automatic policy generation.
Check Types
filter_check/1 - For Read Actions
Returns a filter expression that limits query results to accessible records.
All scope types including exists() are fully supported (converted to SQL).
policy action_type(:read) do
authorize_if AshGrant.filter_check()
endcheck/1 - For Write and Generic Actions
Returns true or false based on whether the actor has permission.
Simple scopes are evaluated in-memory. Scopes with relationship references
(exists() or dot-paths) automatically use a DB query to verify the scope.
policy action_type([:create, :update, :destroy]) do
authorize_if AshGrant.check()
end
# Generic actions require an explicit policy (not covered by default_policies)
policy action_type(:action) do
authorize_if AshGrant.check()
endGeneric Actions
Generic actions (Ash actions with type: :action) use Ash.ActionInput instead
of Ash.Query or Ash.Changeset. check/1 handles this correctly, including
tenant extraction from action_input for multi-tenant authorization.
Generic actions must be authorized by specific action name in the permission string. Type wildcards do not apply because each generic action is individually unique:
# Grants access to the specific "ping" action only
"service_request:*:ping:always"
# Wildcard (*) grants access to all actions including generic ones
"service_request:*:*:always"Since generic actions have no target record, only non-record scopes (like
scope :always, true) will pass scope evaluation.
CanPerform Calculation - Per-Record UI Visibility
AshGrant generates per-record boolean calculations for UI visibility patterns
(show/hide buttons per row). These compile to SQL via expression/2 (no N+1).
DSL Sugar (Recommended)
ash_grant do
resolver MyApp.PermissionResolver
scope :always, true
scope :own, expr(author_id == ^actor(:id))
# Batch — generates :can_update? and :can_destroy?
can_perform_actions [:update, :destroy]
# Individual with custom name
can_perform :read, name: :visible?
endExplicit Module (Advanced)
For cases needing full control (e.g., custom resource_name):
calculations do
calculate :can_update?, :boolean,
{AshGrant.Calculation.CanPerform, action: "update", resource: __MODULE__},
public?: true
endDSL-generated and explicit calculations coexist safely. If both declare the same name, the explicit one takes precedence.
Querying and Templates
# In your LiveView / controller
members =
Member
|> Ash.Query.load([:can_update?, :can_destroy?])
|> Ash.read!(actor: current_user)
# In your template
<.button :if={member.can_update?}>Edit</.button>
<.button :if={member.can_destroy?}>Delete</.button>DSL Options
| DSL | Description |
|---|---|
can_perform_actions [:update, :destroy] | Batch-generate :can_<action>? calculations (public) |
can_perform :action | Generate a single calculation (default name: :can_<action>?) |
can_perform :action, name: :custom? | Generate with a custom calculation name |
can_perform :action, public?: false | Generate a private calculation |
Explicit Module Options
| Option | Type | Description |
|---|---|---|
:action | string | Required. Action name for permission matching |
:resource | module | Required. The resource module (use __MODULE__) |
:resource_name | string | Override resource name for permission matching |
The calculation handles RBAC scopes, instance permissions, deny-wins, and
multi-scope OR combination — all identical to FilterCheck.
Under the Hood
CanPerform implements Ash's calculation expression/2 callback, which
means each loaded calculation becomes a SQL expression on the same
query — one round-trip, no N+1, no per-record resolver calls.
At load time, expression/2 runs once with the actor in context:
- Call the configured
PermissionResolverto get the actor's permission strings. - Get matching RBAC scopes via
AshGrant.Evaluator.get_all_scopes/4, resolve each to its filter expression, and combine withOR. - Get matching instance IDs and build
expr(id in ^instance_ids)— or^ref(instance_key) in ^instance_idsif the resource declares a non-defaultinstance_key. - For each
scope_through, addexpr(fk in ^parent_instance_ids)(or anexists()subquery when the parent'sinstance_keydiffers from the relationship's destination attribute). - Combine everything with
OR. Shortcuts:- Any scope resolves to
true(:always— the recommended name; the runtime also accepts:alland:globalas synonyms for backward compatibility) → the calculation is literallyexpr(true)and collapses to aSELECT true. - No scopes, no instance IDs, no parent filters →
expr(false). - Actor is
nil→expr(false)(no permissions evaluated).
- Any scope resolves to
Template references in scope expressions (^actor(:id), ^tenant(),
^context(:key)) stay as templates in the returned expression — Ash
fills them during query execution using the actor and tenant on the
query, the same way it does for FilterCheck.
Because the whole calculation is one expression, the database evaluates
it in the same query that loads the records. Loading
[:can_update?, :can_destroy?, :can_publish?] on 500 rows is still one
SQL statement.
Custom Resource Name
When a resource uses resource_name "custom" (permissions match
"custom:*:..." rather than the module-derived default), the DSL
sugar picks that up automatically. Pass :resource_name explicitly only
if you've bypassed the DSL and are writing an explicit calculation
declaration:
calculations do
calculate :can_update?, :boolean,
{AshGrant.Calculation.CanPerform,
action: "update",
resource: __MODULE__,
resource_name: "legacy_post"}
endActor Context
The actor comes from context.actor — the same actor you pass to
Ash.read!/2. Without an actor the calculation short-circuits to
false; unauthenticated queries do not need special handling at the
call site.
DSL Configuration
ash_grant do
resolver MyApp.PermissionResolver # Required (or inherited from domain)
default_policies true # Optional: auto-generate policies
resource_name "custom_name" # Optional: defaults to module name (e.g., MyApp.Blog.Post → "post")
# Inline scopes
scope :always, true
scope :own, expr(owner_id == ^actor(:id))
# Argument-based scope + argument resolver
# (for multi-hop authorization — see the Argument-Based Scope guide)
scope :at_own_unit, expr(^arg(:center_id) in ^actor(:own_org_unit_ids))
resolve_argument :center_id, from_path: [:order, :center_id]
# UI visibility calculations
can_perform_actions [:update, :destroy]
end| Option | Type | Description |
|---|---|---|
resolver | module or function | Required (can be inherited from domain via AshGrant.Domain). Resolves permissions for actors |
default_policies | boolean or atom | Auto-generate policies: true, :always, :read, or :write |
default_field_policies | boolean | Auto-generate field_policies from field_group definitions |
can_perform_actions | list of atoms | Batch-generate CanPerform calculations (e.g., [:update, :destroy]) |
resource_name | string | Resource name for permission matching. Default: derived from module name (last segment, snake_cased). MyApp.Blog.Post → "post", MyApp.CustomerOrder → "customer_order" |
instance_key | atom | Field to match instance permission IDs against. Defaults to :id (primary key). See Instance Key |
Entities inside ash_grant do ... end
| Entity | Description |
|---|---|
scope :name, filter | Named scope — see Scopes |
field_group :name, fields | Field-level access group — see Field-Level Permissions |
can_perform :action | Per-record boolean calculation |
scope_through :rel | Propagate parent instance permissions to this resource |
resolve_argument :name, from_path: [...] | Auto-populate an action argument from a relationship for argument-based scopes — see Argument-Based Scope |
Default Policies Options
The default_policies option controls automatic policy generation:
| Value | Description |
|---|---|
false | No policies generated (default). You must define policies explicitly. |
true or :always | Generate read, write, and generic action policies |
:read | Only generate filter_check() policy for read actions |
:write | Only generate check() policy for write and generic actions |
Generated policies when default_policies: true:
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
policy action_type(:action) do
authorize_if AshGrant.check()
end
endPer-Action Authorization with default_policies
When default_policies: true is set, the generated check() and filter_check() calls
automatically match the current action name against the actor's permission strings.
This means you get per-action authorization without writing explicit policies for each action.
For example, with these permissions:
# Resolver returns:
["post:*:read:always", "post:*:update:own"]The default policies will:
- Allow
:readactions (matchespost:*:read:always) - Allow
:updateactions only on own records (matchespost:*:update:own) - Deny
:createand:destroyactions (no matching permission)
Each action is individually checked against the permission strings — there is no
blanket "write" grant unless the actor has a wildcard permission like post:*:*:always.
If you need to map multiple Ash actions to the same permission, use the action: override:
policy action([:read, :get_by_id, :list]) do
authorize_if AshGrant.filter_check(action: "read")
endAdvanced Usage
Action Override
Map different Ash actions to the same permission:
# Both :get_by_id and :list use "read" permission
policy action([:read, :get_by_id, :list]) do
authorize_if AshGrant.filter_check(action: "read")
endCombining default_policies with Custom Policies
default_policies adds policies, it doesn't replace existing ones.
You can combine them:
ash_grant do
resolver MyApp.PermissionResolver
default_policies true # Adds filter_check for read, check for write
end
policies do
# This bypass runs BEFORE the default policies
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
# You can add more custom policies too
policy action(:special_action) do
authorize_if MyCustomCheck
end
endEvaluation order:
- Bypass policies (if any)
- Custom policies defined in
policies do - Default policies from
default_policies: true
Legacy ScopeResolver
The scope_resolver option is deprecated. If configured alongside inline scopes, inline scope DSL is checked first and scope_resolver acts as a fallback for scopes not defined inline. An error is raised if a scope is found in neither. Migrate all scopes to inline scope definitions.
ash_grant do
resolver MyApp.PermissionResolver
scope_resolver MyApp.LegacyScopeResolver # Deprecated fallback
# Inline scopes take priority
scope :always, true
scope :own, expr(author_id == ^actor(:id))
# :legacy_scope will fall back to scope_resolver
end