AshGrant
Permission-based authorization extension for Ash Framework.
AshGrant connects three Ash-native concepts — resources, actions, and
expr() scopes — through a permission string ([!]resource:instance_id:action:scope[:field_group]).
Permissions resolve to native Ash filters and policy checks, with deny-wins semantics.
Authorization:
- Domain-level DSL — shared resolver and scopes inherited by all resources in a domain
- Scope DSL with
expr()— row-level filters, scope inheritance,^tenant()support - Field groups — column-level read access with inheritance and masking
- Instance permissions — per-record sharing with optional scope conditions
- Deny-wins evaluation — deny rules always override allows
UI Integration:
CanPerformcalculation — per-record boolean for UI visibility (compiles to SQL), with DSL sugar (can_perform_actions,can_perform)
Verification & Tooling:
explain/4— trace why authorization succeeded or failedIntrospect— query actor permissions, available actions at runtime- Policy testing — DSL and YAML-based config tests, no database required
AshGrant handles permission evaluation, not role management. Resolve roles to permission strings in your resolver.
Installation
Add ash_grant to your list of dependencies in mix.exs:
def deps do
[
{:ash_grant, "~> 0.12"}
]
endQuick Start
1. Add the Extension to Your Resource
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshGrant]
ash_grant do
# Resolver converts actor to permission strings
resolver fn actor, _context ->
case actor do
%{role: :admin} -> ["post:*:*:all"] # Full access
%{role: :editor} -> [
"post:*:read:all", # Read all posts
"post:*:create:all", # Create posts
"post:*:update:own" # Update own posts only
]
%{role: :viewer} -> ["post:*:read:published"] # Read published only
_ -> []
end
end
default_policies true # Auto-generates read/write policies
# Scopes define row-level filters (referenced by permission strings)
scope :all, true
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
end
# ... attributes, actions, etc.
endHow it works:
-
Actor (
%{role: :editor, id: "user_123"}) is passed to the resolver -
Resolver returns permission strings like
"post:*:update:own" -
Permission
post:*:update:ownreferences scope:own -
Scope
:ownadds filterauthor_id == actor.idto queries
2. Use It
# Editor can read all posts
editor = %{id: "user_123", role: :editor}
Post |> Ash.read!(actor: editor)
# Editor can only update their own posts
Ash.update!(post, %{title: "New Title"}, actor: editor)
# => Succeeds if post.author_id == "user_123"
# => Fails if post.author_id != "user_123"
# Viewer can only read published posts
viewer = %{id: "user_456", role: :viewer}
Post |> Ash.read!(actor: viewer)
# => Returns only posts where status == :published3. Module-Based Resolver (Production)
For production, extract the resolver to a module:
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(nil, _context), do: []
@impl true
def resolve(actor, _context) do
# Load permissions from database
actor
|> MyApp.Accounts.get_user_permissions()
|> Enum.map(& &1.permission_string)
end
endThen reference it in your resource:
ash_grant do
resolver MyApp.PermissionResolver
# ...
end4. Explicit Policies (Full Control)
For more control, disable default_policies and define policies explicitly:
ash_grant do
resolver MyApp.PermissionResolver
# default_policies false (default)
scope :all, true
scope :own, expr(author_id == ^actor(:id))
end
policies do
# Admin bypass
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
# Read actions: use filter_check (returns filtered results)
policy action_type(:read) do
authorize_if AshGrant.filter_check()
end
# Write actions: use check (returns true/false)
policy action_type([:create, :update, :destroy]) do
authorize_if AshGrant.check()
end
endResolver Context
The context parameter passed to your resolver contains:
| Key | Type | Description |
|---|---|---|
:actor | term | The actor performing the action |
:resource | module | The Ash resource module |
:action | Ash.Action.t | The action struct |
:tenant | term | nil | Current tenant (from query/changeset) |
:changeset | Ash.Changeset.t | nil | For write actions |
:query | Ash.Query.t | nil | For read actions |
Example usage:
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(actor, context) do
base_permissions = get_role_permissions(actor)
# Add instance permissions based on context
case context do
%{resource: MyApp.Document, action: %{name: :read}} ->
shared_docs = get_shared_document_ids(actor)
instance_perms = Enum.map(shared_docs, &"document:#{&1}:read:")
base_permissions ++ instance_perms
_ ->
base_permissions
end
end
endDomain-Level DSL
When multiple resources share the same resolver and scopes, define them once at the domain level instead of repeating the same ash_grant do block in every resource.
When to use:
-
3+ resources in a domain share the same resolver and common scopes (
:all,:own, etc.) - You want a single place to change the resolver or add a scope for all resources
When NOT to use:
- Resources in a domain have very different resolvers or scope logic
- You only have 1–2 resources in the domain
Setup
defmodule MyApp.Blog do
use Ash.Domain,
extensions: [AshGrant.Domain]
ash_grant do
resolver MyApp.PermissionResolver
scope :all, true
scope :own, expr(author_id == ^actor(:id))
end
resources do
resource MyApp.Blog.Post
resource MyApp.Blog.Comment
end
endResources inherit the domain's resolver and scopes automatically:
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshGrant]
ash_grant do
default_policies true
# No resolver needed — inherited from domain
# :all and :own scopes inherited from domain
scope :published, expr(status == :published) # Add resource-specific scopes
end
# ...
endInheritance Rules
| Config | Resource defines it | Domain defines it | Result |
|---|---|---|---|
| resolver | Yes | Yes | Resource wins |
| resolver | No | Yes | Domain's resolver used |
| scope (same name) | Yes | Yes | Resource wins (override) |
| scope | No | Yes | Domain scope inherited |
Resource scopes can inherit from domain-defined parent scopes:
# Domain defines :own scope
# Resource adds :own_draft that inherits from domain's :own
ash_grant do
scope :own_draft, [:own], expr(status == :draft)
endA compile error is raised if no resolver is found from either the resource or the domain.
Resolver Patterns
Permissions with Metadata
Return AshGrant.PermissionInput structs for enhanced debugging and explain/4:
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(actor, _context) do
actor
|> 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
endCustom Structs with Permissionable Protocol
Implement the AshGrant.Permissionable protocol for your custom structs:
defmodule MyApp.RolePermission do
defstruct [:permission_string, :label, :role_name]
end
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
# Then just return your structs from the resolver
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
@impl true
def resolve(actor, _context) do
MyApp.Accounts.get_role_permissions(actor)
end
endPermission Format
Permission String Format (Apache Shiro-Inspired)
[!]resource:instance_id:action:scope[:field_group]| Component | Description | Examples |
|---|---|---|
! | Optional deny prefix | !blog:*:delete:all |
| resource |
Resource type or * | blog, post, * |
| instance_id |
Resource instance or * | *, post_abc123xyz789ab |
| action | Action name or wildcard | read, *, read* |
| scope | Access scope | all, own, published, or empty |
| field_group | Optional column-level group | public, sensitive, confidential |
The 5th part (field_group) is optional. When omitted (4-part format), all fields are visible.
When present, only fields in the named group (and its inherited parents) are accessible.
Wildcard Matching Rules
| Component | * (all) | prefix* | Exact match |
|---|---|---|---|
| resource | Yes | No | Yes |
| instance_id | Yes | No | Yes |
| action | Yes | Yes | Yes |
| scope | No | No | Yes |
Examples:
"*:*:read:all" # All resources, read action
"blog*:*:read:all" # Invalid - resource doesn't support prefix
"blog:*:read*:all" # Valid - action supports prefix (read, read_all, etc.)
"blog:post_*:read:" # Invalid - instance_id doesn't support prefix
RBAC Permissions (instance_id = *)
"blog:*:read:all" # Read all blogs
"blog:*:read:published" # Read only published blogs
"blog:*:update:own" # Update own blogs only
"blog:*:*:all" # All actions on all blogs
"*:*:read:all" # Read all resources
"blog:*:read*:all" # All read-type actions
"!blog:*:delete:all" # DENY delete on all blogsInstance Permissions (specific instance_id)
For sharing specific resources (like Google Docs):
"blog:post_abc123xyz789ab:read:" # Read specific post
"blog:post_abc123xyz789ab:*:" # Full access to specific post
"!blog:post_abc123xyz789ab:delete:" # DENY delete on specific postInstance permissions have an empty scope (trailing colon) because the permission is already scoped to a specific instance.
Instance Permissions with Scopes (ABAC)
Instance permissions can include scope conditions for attribute-based access control:
# Permission format: resource:instance_id:action:scope
"doc:doc_123:update:draft" # Can update doc_123 only when in draft status
"doc:doc_123:read:" # Can read doc_123 unconditionally (empty scope)Define the scope in your resource:
ash_grant do
resolver MyApp.PermissionResolver
scope :draft, expr(status == :draft)
scope :business_hours, expr(fragment("EXTRACT(HOUR FROM NOW()) BETWEEN 9 AND 17"))
endHow it works:
-
For read actions:
filter_checkadds the scope filter to the query -
For write actions:
checkevaluates the scope against the target record
# User has: "doc:doc_123:update:draft"
# This succeeds (doc is in draft)
Ash.update!(draft_doc, %{title: "New"}, actor: user)
# This fails (doc is published, not draft)
Ash.update!(published_doc, %{title: "New"}, actor: user)Instance permissions work with both:
- Read actions (
filter_check/1) - AddsWHERE id IN (instance_ids)filter - Write actions (
check/1) - Validates access to specific instance
Instance Permission Read Example
# Resolver returns instance permissions for shared documents
defmodule MyApp.PermissionResolver do
@behaviour AshGrant.PermissionResolver
def resolve(%{shared_doc_ids: doc_ids}, _context) when is_list(doc_ids) do
# Generate instance permission for each shared document
Enum.map(doc_ids, fn doc_id ->
"document:#{doc_id}:read:"
end)
end
end
# User can only read documents explicitly shared with them
actor = %{id: "user-1", shared_doc_ids: ["doc_abc", "doc_xyz"]}
Document |> Ash.read!(actor: actor)
# => Returns only doc_abc and doc_xyzWhen combined with RBAC permissions, users can access:
-
All records matching their RBAC scopes (e.g.,
:own,:published) - Plus specific instances from instance permissions
The filters are combined with OR logic:
(owner_id == actor.id) OR (id IN ["doc_abc", "doc_xyz"])
Legacy Format Support
For backward compatibility, shorter formats are supported but use with caution:
| Input | Parsed As | Notes |
|---|---|---|
"blog:read:all" | blog:*:read:all | Safe - 3rd part is clearly a scope |
"blog:read" | blog:*:read: | Safe - 2-part format |
"blog:post123:read" | blog:*:post123:read |
Ambiguous! post123 becomes action |
Recommendation: Always use the full 4-part format to avoid ambiguity:
# RBAC permissions
"blog:*:read:all" # Explicit 4-part format (recommended)
"blog:read:all" # Legacy 3-part format (works but discouraged)
# Instance permissions
"blog:post123:read:" # Explicit instance permission (recommended)Deny-Wins Pattern
When both allow and deny rules match, deny always takes precedence:
permissions = [
"blog:*:*:all", # Allow all blog actions
"!blog:*:delete:all" # Deny delete
]
# Result:
# - blog:read -> allowed
# - blog:update -> allowed
# - blog:delete -> DENIED (deny wins)This pattern is useful for:
- Revoking specific permissions from broad grants
- Creating "except" rules (e.g., "all except delete")
- Implementing inheritance with overrides
Scope DSL
Define scopes inline using the scope entity. The expr macro is automatically
available within the ash_grant block.
ash_grant do
resolver MyApp.PermissionResolver
# Boolean scope - no filtering
scope :all, true
# Expression scope - filter by condition
scope :own, expr(author_id == ^actor(:id))
scope :published, expr(status == :published)
# Inherited scope - combines parent with additional filter
scope :own_draft, [:own], expr(status == :draft)
# Result: author_id == actor.id AND status == :draft
# Relational scope - works for both reads and writes automatically
scope :team_member, expr(exists(team.memberships, user_id == ^actor(:id)))
endScope Inheritance
Scopes can inherit from parent scopes:
scope :base, expr(tenant_id == ^actor(:tenant_id))
scope :active, [:base], expr(status == :active)
# Result: tenant_id == actor.tenant_id AND status == :activeScope Combination Rules
Multiple Permissions = OR
When an actor has multiple permissions with different scopes for the same action, they are combined with OR:
# Actor has both permissions:
["post:*:read:own", "post:*:read:published"]
# Result filter: (author_id == actor.id) OR (status == :published)
# Actor can see their own posts AND all published postsScope Inheritance = AND
When a scope inherits from parent scopes, they are combined with AND:
ash_grant do
scope :own, expr(author_id == ^actor(:id))
scope :draft, expr(status == :draft)
scope :own_draft, [:own], expr(status == :draft)
# Inheritance: [:own] + expr(status == :draft)
end
# :own_draft filter: (author_id == actor.id) AND (status == :draft)
# NOT the same as having two separate permissions!Key difference: Multiple permissions expand access (OR), scope inheritance restricts access (AND).
Example: Date-Based Scopes
You can use SQL fragments for temporal filtering:
# Records created today only
scope :today, expr(fragment("DATE(inserted_at) = CURRENT_DATE"))
# Combined with ownership
scope :own_today, [:own], expr(fragment("DATE(inserted_at) = CURRENT_DATE"))Multi-Tenancy Support
AshGrant fully supports Ash's multi-tenancy with the ^tenant() template:
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshGrant]
ash_grant do
resolver fn actor, _context ->
case actor do
%{role: :tenant_admin} -> ["post:*:*:same_tenant"]
%{role: :tenant_user} -> ["post:*:read:same_tenant", "post:*:update:own_in_tenant"]
_ -> []
end
end
default_policies true
# Tenant-based scopes using ^tenant()
scope :all, true
scope :same_tenant, expr(tenant_id == ^tenant())
scope :own, expr(author_id == ^actor(:id))
scope :own_in_tenant, [:same_tenant], expr(author_id == ^actor(:id))
end
# ...
endUsage with tenant context:
# Read - only returns posts from the specified tenant
posts = Post |> Ash.read!(actor: user, tenant: tenant_id)
# Create - validated against tenant scope
Ash.create(Post, %{title: "Hello", tenant_id: tenant_id},
actor: user,
tenant: tenant_id
)
# Update - must match both tenant AND ownership for own_in_tenant scope
Ash.update(post, %{title: "Updated"}, actor: user, tenant: tenant_id)Multi-Tenancy: Two Approaches
| Approach | Use When |
|---|---|
^tenant() | Using Ash's multi-tenancy features, tenant can change per-request |
^actor(:tenant_id) | Tenant is fixed per user, simpler setup |
Option 1: ^tenant() - Context-based (Recommended)
Uses Ash's built-in tenant context, passed via query/changeset options:
ash_grant do
scope :same_tenant, expr(tenant_id == ^tenant())
end
# Usage - tenant comes from Ash context
Post |> Ash.read!(actor: user, tenant: "acme_corp")Option 2: ^actor(:tenant_id) - Actor-based
Uses a tenant_id field stored on the actor:
ash_grant do
scope :same_tenant, expr(tenant_id == ^actor(:tenant_id))
end
# Usage - tenant comes from actor struct
actor = %User{id: 1, tenant_id: "acme_corp"}
Post |> Ash.read!(actor: actor)Warning: Don't mix approaches in the same resource. Pick one and be consistent.
Key points:
-
Use
^tenant()to reference the current tenant from query/changeset context -
Use
^actor(:tenant_id)if tenant is stored on the actor instead -
Scope inheritance works with tenant scopes (e.g.,
[:same_tenant]) -
Both
filter_check(reads) andcheck(writes) properly evaluate tenant scopes
Relational Scopes (exists() and Dot-Paths)
You can use exists() and dot-path references in scope expressions for relationship-based filtering.
These work for both read and write actions:
ash_grant do
scope :team_member, expr(exists(team.memberships, user_id == ^actor(:id)))
scope :own_in_team, expr(author_id == ^actor(:id) and exists(team.memberships, user_id == ^actor(:id)))
scope :same_center, expr(order.center_id == ^actor(:center_id))
end
For read actions, FilterCheck converts these to SQL (EXISTS subquery or JOIN).
For write actions, Check automatically uses a DB query fallback when the
scope contains relationship references — the read scope expression is used as a DB
query to verify the record matches the scope.
Optional:
write:overrideYou can use the
write:option to explicitly control write behavior:
ash_grant do
# Explicitly deny writes with this scope
scope :org_member, expr(exists(org.users, id == ^actor(:id))),
write: false
# Explicit in-memory expression (avoids DB round-trip)
scope :same_org, expr(exists(org.users, id == ^actor(:id))),
write: expr(org_id == ^actor(:org_id))
endSee Dual Read/Write Scope for full details on the
write:option.
Business Scope Examples
AshGrant supports a wide variety of business scenarios. Here are common patterns:
Status-Based Workflow
ash_grant do
scope :all, true
scope :draft, expr(status == :draft)
scope :pending_review, expr(status == :pending_review)
scope :approved, expr(status == :approved)
scope :editable, expr(status in [:draft, :pending_review])
endSecurity Classification
Hierarchical access levels:
ash_grant do
scope :public, expr(classification == :public)
scope :internal, expr(classification in [:public, :internal])
scope :confidential, expr(classification in [:public, :internal, :confidential])
scope :top_secret, true # Can see all
endTransaction Limits
Numeric comparisons for amount-based authorization:
ash_grant do
scope :small_amount, expr(amount < 1000)
scope :medium_amount, expr(amount < 10000)
scope :large_amount, expr(amount < 100000)
scope :unlimited, true
endMulti-Tenant with Inheritance
Combined scopes using inheritance:
ash_grant do
scope :tenant, expr(tenant_id == ^actor(:tenant_id))
scope :tenant_active, [:tenant], expr(status == :active)
scope :tenant_own, [:tenant], expr(created_by_id == ^actor(:id))
endTime/Period Based
Temporal filtering:
ash_grant do
scope :current_period, expr(period_id == ^actor(:current_period_id))
scope :open_periods, expr(period_status == :open)
scope :this_fiscal_year, expr(fiscal_year == ^actor(:fiscal_year))
endGeographic/Territory
List membership for territory assignments:
ash_grant do
scope :same_region, expr(region_id == ^actor(:region_id))
scope :assigned_territories, expr(territory_id in ^actor(:territory_ids))
scope :my_accounts, expr(account_manager_id == ^actor(:id))
endField-Level Permissions
AshGrant supports column-level read authorization through field groups. Field groups control which fields are visible based on the actor's permissions, using Ash's native field_policies system.
Field Group DSL
Define field groups with optional inheritance:
ash_grant do
resolver MyApp.PermissionResolver
scope :all, true
# Root group — no inheritance (whitelist)
field_group :public, [:name, :department, :position]
# Inherits all fields from :public, adds phone and address
field_group :sensitive, [:phone, :address], inherits: [:public]
# Inherits all fields from :sensitive (which includes :public)
field_group :confidential, [:salary, :email], inherits: [:sensitive]
end
Blacklist Mode (except)
When a resource has many attributes, use :all with except to exclude specific fields instead of listing all visible ones:
ash_grant do
resolver MyApp.PermissionResolver
scope :all, true
# All attributes except salary and ssn
field_group :public, :all, except: [:salary, :ssn]
# Child group adds back the excluded fields
field_group :full, [:salary, :ssn], inherits: [:public]
end:all expands to all resource attributes at compile time. except removes fields from that list. :all without except is also valid (expands to all attributes).
Permission Strings with Field Groups
The 5th part of the permission string specifies the field group:
"employee:*:read:all:public" # See name, department, position only
"employee:*:read:all:sensitive" # See public + phone, address
"employee:*:read:all:confidential" # See all fields
"employee:*:read:all" # No field_group → all fields visible
Fields not in the actor's field group are replaced with %Ash.ForbiddenField{}.
Mode A: Manual Field Policies
Write Ash field_policies using AshGrant.field_check/1:
field_policies do
field_policy [:salary, :email] 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
endMode B: Auto-Generated Field Policies
Set default_field_policies: true to auto-generate field policies from field group definitions:
ash_grant do
resolver MyApp.PermissionResolver
default_policies true
default_field_policies true # Auto-generates field_policies from field_groups
scope :all, true
field_group :public, [:name, :department, :position]
field_group :sensitive, [:phone, :address], inherits: [:public]
field_group :confidential, [:salary, :email], inherits: [:sensitive]
endThis also works with blacklist mode:
field_group :public, :all, except: [:salary, :ssn]
field_group :full, [:salary, :ssn], inherits: [:public]
Auto-generates equivalent field policies with a catch-all field_policy :* that allows non-grouped fields.
Field Group Inheritance
Inheritance follows a DAG (directed acyclic graph) — a child group includes all parent fields:
:public → [:name, :department, :position]
:sensitive → [:name, :department, :position, :phone, :address]
:confidential → [:name, :department, :position, :phone, :address, :salary, :email]
An actor with confidential permission can see everything that sensitive and public can see, plus their own fields.
Field Masking
Instead of hiding fields entirely, you can show masked values:
field_group :sensitive, [:phone, :address],
inherits: [:public],
mask: [:phone, :address],
mask_with: fn value, _field ->
if is_binary(value), do: String.replace(value, ~r/./, "*"), else: "***"
endMasking rules:
- Masking is not inherited — a higher-level group sees original values
- Allow-wins: if an actor has both a masking group and a non-masking group for the same field, the field is unmasked
- Actors with 4-part permissions (no field_group) see all fields unmasked
Example behavior:
| Actor Permission | phone | salary |
|---|---|---|
...:public | %Ash.ForbiddenField{} | %Ash.ForbiddenField{} |
...:sensitive (with masking) | "*************" | %Ash.ForbiddenField{} |
...:confidential | "010-1234-5678" | 80000 |
... (4-part, no field_group) | "010-1234-5678" | 80000 |
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 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(:destroy) do
authorize_if AshGrant.check()
endCanPerform 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 :all, 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.
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 :all, true
scope :own, expr(owner_id == ^actor(: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, :all, :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" |
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 :all | Generate both read and write policies |
:read |
Only generate filter_check() policy for read actions |
:write |
Only generate check() policy for write 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
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_policiesadds 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 :all, true
scope :own, expr(author_id == ^actor(:id))
# :legacy_scope will fall back to scope_resolver
endArchitecture
Ash Policy Check Ash Calculation
| |
+-------------+-------------+--------+ +---v-----------+
| | | | CanPerform |
+-----v-----+ +------v------+ | | (UI booleans) |
| Check | | FilterCheck | | +---+-----------+
| (writes) | | (reads) | | |
+-----+-----+ +------+------+ | |
| | | |
+-----------+---------------+-+------+------+
|
+-----------v-----------+
| PermissionResolver |
| (actor -> permissions)|
+-----------+-----------+
|
+-----------v-----------+
| Evaluator |
| (deny-wins matching) |
+-----------+-----------+
|
+-----------v-----------+
| Scope DSL / Field |
| Groups / Resolver |
+-----------------------+
Debugging with explain/4
Use AshGrant.explain/4 to understand why authorization succeeded or failed:
# Get detailed explanation
result = AshGrant.explain(MyApp.Post, :read, actor)
# Check the decision
result.decision # => :allow or :deny
# See matching permissions with metadata
result.matching_permissions
# => [%{permission: "post:*:read:all", description: "Read all posts", source: "editor_role", ...}]
# See why permissions didn't match
result.evaluated_permissions
# => [%{permission: "post:*:update:own", matched: false, reason: "Action mismatch"}, ...]
# Print human-readable output
result |> AshGrant.Explanation.to_string() |> IO.puts()Sample output:
═══════════════════════════════════════════════════════════════════
Authorization Explanation for MyApp.Blog.Post
═══════════════════════════════════════════════════════════════════
Action: read
Decision: ✓ ALLOW
Actor: %{id: "user-1", role: :editor}
Matching Permissions:
• post:*:read:all [scope: all - All records without restriction] (from: editor_role)
└─ Read all posts
Scope Filter: true (no filtering)
───────────────────────────────────────────────────────────────────
Dual Read/Write Scope (write: Option)
Scopes with exists() or dot-paths work automatically for both reads and writes via
DB query fallback. The write: option
is an optional override for explicit control:
write: value | Strategy | Behavior |
|---|---|---|
| (omitted, no relationships) | In-memory | Evaluates filter in-memory (default) |
| (omitted, has relationships) | DB query | Queries DB with read scope (automatic) |
write: expr(...) | In-memory | Use this expression for writes (overrides DB query) |
write: false | Deny | Explicitly deny all writes with this scope |
write: true | Allow | Allow all writes with this scope (no filtering) |
ash_grant do
resolver MyApp.PermissionResolver
scope :all, true
# Relational scope — DB query fallback handles writes automatically
scope :team_member, expr(exists(team.memberships, user_id == ^actor(:id)))
# Explicitly deny writes
scope :org_member, expr(exists(org.users, id == ^actor(:id))),
write: false
# Explicit in-memory override (avoids DB round-trip)
scope :same_org, expr(exists(org.users, id == ^actor(:id))),
write: expr(org_id == ^actor(:org_id))
# Simple scopes — in-memory evaluation, no write: needed
scope :own, expr(author_id == ^actor(:id))
endInheritance: Child scopes inherit their parent's write: expression. If a parent
has write: false, it propagates to all children:
scope :team_member, expr(exists(team.memberships, user_id == ^actor(:id))),
write: false
scope :team_editor, [:team_member], expr(role == :editor)
# team_editor inherits write: false — writes are deniedResolution functions:
AshGrant.Info.resolve_scope_filter/3— always returns the read (filter) expressionAshGrant.Info.resolve_write_scope_filter/3— returnswrite:expression if set, otherwise falls back tofilter
Scope Descriptions
Add descriptions to scopes for better debugging output:
ash_grant do
resolver MyApp.PermissionResolver
scope :all, true, description: "All records without restriction"
scope :own, expr(author_id == ^actor(:id)), description: "Records owned by the current user"
scope :published, expr(status == :published), description: "Published records visible to everyone"
endAccess scope descriptions programmatically:
AshGrant.Info.scope_description(MyApp.Post, :own)
# => "Records owned by the current user"Permission Introspection
The AshGrant.Introspect module provides runtime helpers for querying permissions:
Admin UI: What can this user do?
AshGrant.Introspect.actor_permissions(Post, current_user)
# => [
# %{action: "read", allowed: true, scope: "all", denied: false, instance_ids: nil, field_groups: []},
# %{action: "update", allowed: true, scope: "own", denied: false, instance_ids: nil, field_groups: []},
# %{action: "destroy", allowed: false, scope: nil, denied: false, instance_ids: nil, field_groups: []}
# ]Permission Management: What permissions exist?
AshGrant.Introspect.available_permissions(Post)
# => [
# %{permission_string: "post:*:read:all", action: "read", scope: "all", scope_description: "All records", field_group: nil},
# %{permission_string: "post:*:read:own", action: "read", scope: "own", scope_description: "Own records", field_group: nil},
# ...
# ]Note:
available_permissions/1requires inline scope definitions in the DSL. Resources usingscope_resolverwill return an empty list.
Debugging: Can user do this action?
AshGrant.Introspect.can?(Post, :read, user)
# => {:allow, %{scope: "all", instance_ids: nil, field_groups: []}}
AshGrant.Introspect.can?(Post, :destroy, user)
# => {:deny, %{reason: :no_permission}}API Response: What actions are available?
# Simple list
AshGrant.Introspect.allowed_actions(Post, user)
# => [:read, :create, :update]
# With details
AshGrant.Introspect.allowed_actions(Post, user, detailed: true)
# => [
# %{action: :read, scope: "all", instance_ids: nil, field_groups: []},
# %{action: :create, scope: "all", instance_ids: nil, field_groups: []},
# %{action: :update, scope: "own", instance_ids: nil, field_groups: []}
# ]Raw Permission Access
AshGrant.Introspect.permissions_for(Post, user)
# => ["post:*:read:all", "post:*:update:own", "post:*:create:all"]With Context
All functions accept a :context option for passing additional resolver context:
AshGrant.Introspect.actor_permissions(Post, user, context: %{tenant: tenant_id})SAT Solver Optimization
AshGrant implements Ash's optional policy check callbacks to help the SAT solver make smarter authorization decisions:
| Callback | Purpose |
|---|---|
simplify/2 | Decomposes checks into simpler SAT expressions |
implies?/3 | Determines if one check guarantees another is true |
conflicts?/3 | Determines if two checks are mutually exclusive |
These callbacks enable the authorizer to reach decisions with fewer variables in conditions, potentially short-circuiting evaluation before loading data.
Current implementation:
simplify/2returns the ref unchanged (permissions are runtime-resolved)implies?/3returnstruewhen check refs have identical module and optionsconflicts?/3returnsfalse(deny-wins is handled at evaluation time)
This provides a foundation for future optimizations while maintaining correct behavior with Ash's policy system.
Policy Configuration Testing
AshGrant provides a DSL-based testing framework for verifying policy configurations without requiring a database. This tests policy configuration, not data - no database records needed.
Resource Setup
Policy tests verify how your resolver converts roles to permissions. Use the Post resource from the Quick Start section above, or any resource with an ash_grant block configured.
DSL-Based Tests
Write policy tests to verify the resolver and scope configuration:
defmodule MyApp.PolicyTests.PostPolicyTest do
use AshGrant.PolicyTest
resource MyApp.Post
actor :admin, %{role: :admin}
actor :editor, %{role: :editor, id: "editor_001"}
actor :viewer, %{role: :viewer}
describe "read access" do
test "editor can read all posts" do
assert_can :editor, :read
end
test "viewer can read published posts" do
assert_can :viewer, :read, %{status: :published}
end
test "viewer cannot read drafts" do
assert_cannot :viewer, :read, %{status: :draft}
end
end
describe "write access" do
test "editor can update own posts" do
assert_can :editor, :update, %{author_id: "editor_001"}
end
test "editor cannot update others posts" do
assert_cannot :editor, :update, %{author_id: "other_user"}
end
test "viewer cannot update any posts" do
assert_cannot :viewer, :update
end
end
endAssertion Macros
| Macro | Description |
|---|---|
assert_can(actor, action) | Actor can perform action |
assert_can(actor, action, record) | Actor can access specific record |
assert_cannot(actor, action) | Actor cannot perform action |
assert_cannot(actor, action, record) | Actor cannot access specific record |
Action can be specified as:
-
Atom:
:read(shorthand foraction: :read) -
Keyword:
action: :approve(specific action name) -
Keyword:
action_type: :update(all actions of type)
YAML Format
Policy tests can also be written in YAML for non-developers or interchange:
resource: MyApp.Post
actors:
editor:
role: editor
id: "editor_001"
viewer:
role: viewer
tests:
- name: "editor can read all posts"
assert_can:
actor: editor
action: read
- name: "viewer can read published posts"
assert_can:
actor: viewer
action: read
record:
status: published
- name: "viewer cannot read drafts"
assert_cannot:
actor: viewer
action: read
record:
status: draft
- name: "editor can update own posts"
assert_can:
actor: editor
action: update
record:
author_id: "editor_001"
- name: "editor cannot update others posts"
assert_cannot:
actor: editor
action: update
record:
author_id: "other_user"Mix Tasks
Run policy tests:
# Run DSL tests
mix ash_grant.verify test/policy_tests/
# Run YAML tests
mix ash_grant.verify priv/policy_tests/document.yaml
# Verbose output
mix ash_grant.verify test/policy_tests/ --verboseExport policies:
# Export to YAML
mix ash_grant.export MyApp.Document --format=yaml
# Export to Mermaid diagram
mix ash_grant.export MyApp.Document --format=mermaid
# Export to Markdown documentation
mix ash_grant.export MyApp.Document --format=markdown
# Export to file
mix ash_grant.export MyApp.Document --format=markdown --output=docs/document.mdImport YAML to DSL:
# Generate DSL code from YAML (output to stdout)
mix ash_grant.import priv/policy_tests/document.yaml
# Generate and write to file
mix ash_grant.import priv/policy_tests/document.yaml --output=test/policy_tests/document_test.exsRunning Policy Tests
Use the AshGrant.PolicyTest.Runner module programmatically:
# Run a single module
results = AshGrant.PolicyTest.Runner.run_module(MyApp.PolicyTests.DocumentPolicyTest)
# Run all policy test modules
summary = AshGrant.PolicyTest.Runner.run_all()
# => %{passed: 10, failed: 0, results: [...]}
# Run specific modules
summary = AshGrant.PolicyTest.Runner.run_all(modules: [DocumentPolicyTest, PostPolicyTest])Dependencies
To use YAML format, add yaml_elixir to your dependencies:
def deps do
[
{:yaml_elixir, "~> 2.9"}
]
endAPI Reference
Modules
| Module | Description |
|---|---|
AshGrant |
Main extension module with check/1, filter_check/1, field_check/1, and explain/4 |
AshGrant.Introspect | Runtime permission introspection for UIs and APIs |
AshGrant.Explanation | Authorization decision explanation struct |
AshGrant.Explainer | Builds detailed authorization explanations |
AshGrant.Permission | Permission parsing and matching (4-part and 5-part formats) |
AshGrant.PermissionInput | Permission input with metadata for debugging |
AshGrant.Permissionable | Protocol for converting custom structs to permissions |
AshGrant.Evaluator | Deny-wins permission evaluation with field group support |
AshGrant.PermissionResolver | Behaviour for resolving permissions |
AshGrant.ScopeResolver | Behaviour for scope resolution (legacy) |
AshGrant.Check | SimpleCheck for write actions (with SAT solver callbacks) |
AshGrant.FilterCheck | FilterCheck for read actions (with SAT solver callbacks) |
AshGrant.FieldCheck |
SimpleCheck for field-level authorization in field_policies |
AshGrant.Info | DSL introspection helpers (scopes, field groups, configuration) |
AshGrant.PolicyTest | Policy configuration testing DSL |
AshGrant.PolicyTest.Runner | Test runner for policy tests |
AshGrant.PolicyExport | Export policies to various formats |
Testing
AshGrant includes comprehensive tests using Ash.Generator for fixture generation:
mix testThe test suite covers:
- Permission parsing - All format variants (4-part and 5-part) and edge cases
- Evaluator - Deny-wins semantics with property-based tests
- Field groups - Column-level authorization, inheritance, masking, integration
- DB Integration - Real database queries with scope filtering
- Business scenarios - 8 different authorization patterns:
- Status-based workflow (Document)
- Organization hierarchy (Employee)
- Geographic/Territory (Customer)
- Security classification (Report)
- Project/Team assignment (Task)
- Transaction limits (Payment)
- Time/Period based (Journal)
- Complex ownership + Multi-tenant (SharedDocument)
Each scenario tests both positive (can access) and negative (cannot access) cases, plus deny-wins semantics and edge conditions.
Disclosure
I've been a developer for about six years. I became interested in Elixir, Phoenix, and Ash a couple of years ago, but only started actually building with them about four months ago. This library was born out of my own needs, and honestly, my skills in this ecosystem aren't at the level where I'd normally attempt building something like this.
Most of AshGrant was developed through TDD with Claude Code—I described what I needed, Claude Code wrote the tests and implementation, and I reviewed the results. I treated it like any third-party library: if the tests pass and the code looks reasonable, I use it. I haven't read every line of code in detail, so I can't guarantee everything works perfectly.
I'm using this in production because I need it now, but please consider this more as a proof of concept—a proposal for how authorization could be handled in Ash. I'm sharing this publicly in hopes that it can be a starting point. If others find it useful and want to contribute, we could build something better together.
If you have suggestions or find issues, please feel free to open an issue or submit a PR—contributions are very welcome.
What made this possible is how exceptionally well-documented Elixir and Ash are. The clear abstractions—DSLs, Domains, Resources, Extensions—gave me a precise vocabulary to communicate my requirements to an LLM. These well-defined concepts provided both the courage to start and the foundation to actually ship something I use in production.
I'm deeply grateful to Zach for creating Ash Framework, the Ash Core Team, all the contributors, and the broader Elixir community. We have something special here.
License
MIT License - see LICENSE for details.