Scopes define row-level filters referenced by permission strings. They are written inline using the scope entity with Ash expr() expressions.

Scope DSL

ash_grant do
  resolver MyApp.PermissionResolver

  # Boolean scope - no filtering
  scope :always, 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).

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 :always, 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)

Two Approaches

ApproachUse 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) and check (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.

Tip: use the foreign key column directly when the check is really about it.

Expressions like expr(not is_nil(team.id)) reach through a belongs_to relationship to check something the record already knows — its own FK column:

# ❌ Traverses the relationship; forces the DB-query fallback on writes.
scope :has_team, expr(not is_nil(team.id))

# ✅ Direct FK — evaluates in memory, no DB round-trip.
scope :has_team, expr(not is_nil(team_id))

Use the relationship form only when you genuinely need a value stored on the related record (and for multi-hop cases, prefer the argument-based pattern below).

For write-action authorization that reaches through relationships (e.g., refund → order → center_id), prefer an argument-based scope paired with resolve_argument. The scope stays in-memory-evaluable and the resource populates the argument from its own relationships:

ash_grant do
  scope :at_own_unit, expr(^arg(:center_id) in ^actor(:own_org_unit_ids))
  resolve_argument :center_id, from_path: [:order, :center_id]
end

See Argument-Based Scope for the full pattern.

Deprecated: write: override

The write: option was introduced as an escape hatch when the main filter could not be evaluated in memory on write actions. It is deprecated as of 0.14 — prefer argument-based scopes + resolve_argument for multi-hop cases, or use a separate scope name for read-only semantics.

Using write: still works but emits a compile-time deprecation warning.

Business Scope Examples

AshGrant supports a wide variety of business scenarios. Here are common patterns:

Status-Based Workflow

ash_grant do
  scope :always, 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