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:

UI Integration:

Verification & Tooling:

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"}
  ]
end

Quick 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.
end

How it works:

  1. Actor (%{role: :editor, id: "user_123"}) is passed to the resolver
  2. Resolver returns permission strings like "post:*:update:own"
  3. Permission post:*:update:own references scope :own
  4. Scope :own adds filter author_id == actor.id to 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 == :published

3. 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
end

Then reference it in your resource:

ash_grant do
  resolver MyApp.PermissionResolver
  # ...
end

4. 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
end

Resolver 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
end

Domain-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:

When NOT to use:

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
end

Resources 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

  # ...
end

Inheritance 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)
end

A 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
end

Custom 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
end

Permission 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 blogs

Instance 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 post

Instance 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"))
end

How it works:

  1. For read actions: filter_check adds the scope filter to the query
  2. For write actions: check evaluates 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:

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_xyz

When combined with RBAC permissions, users can access:

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:

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)))
end

Scope 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 == :active

Scope 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 posts

Scope 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

  # ...
end

Usage 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:

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: override

You 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))
end

See 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])
end

Security 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
end

Transaction 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
end

Multi-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))
end

Time/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))
end

Geographic/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))
end

Field-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
end

Mode 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]
end

This 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: "***"
  end

Masking rules:

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()
end

check/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()
end

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 :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?
end

Explicit 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
end

DSL-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
end

Advanced 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")
end

Combining 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
end

Evaluation order:

  1. Bypass policies (if any)
  2. Custom policies defined in policies do
  3. 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
end

Architecture

                    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&#39;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))
end

Inheritance: 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 denied

Resolution functions:

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"
end

Access 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/1 requires inline scope definitions in the DSL. Resources using scope_resolver will 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:

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
end

Assertion 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:

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/ --verbose

Export 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.md

Import 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.exs

Running 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"}
  ]
end

API 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 test

The test suite covers:

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.