README.md

# AshGrant

Permission-based authorization extension for [Ash Framework](https://ash-hq.org/).

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

**Verification & Tooling:**
- **`explain/4`** — trace why authorization succeeded or failed
- **`Introspect`** — 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`:

```elixir
def deps do
  [
    {:ash_grant, "~> 0.6"}
  ]
end
```

## Quick Start

### 1. Add the Extension to Your Resource

```elixir
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

```elixir
# 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:

```elixir
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:

```elixir
ash_grant do
  resolver MyApp.PermissionResolver
  # ...
end
```

### 4. Explicit Policies (Full Control)

For more control, disable `default_policies` and define policies explicitly:

```elixir
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:**

```elixir
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
```

## Resolver Patterns

### Permissions with Metadata

Return `AshGrant.PermissionInput` structs for enhanced debugging and `explain/4`:

```elixir
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:

```elixir
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:**

```elixir
"*:*: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 = `*`)

```elixir
"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):

```elixir
"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:

```elixir
# 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:**

```elixir
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

```elixir
# 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`) - Adds `WHERE id IN (instance_ids)` filter
- **Write actions** (`check/1`) - Validates access to specific instance

#### Instance Permission Read Example

```elixir
# 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:
- 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:

```elixir
# 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:

```elixir
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.

```elixir
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
end
```

### Scope Inheritance

Scopes can inherit from parent scopes:

```elixir
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**:

```elixir
# 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**:

```elixir
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:

```elixir
# 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:

```elixir
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:**

```elixir
# 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:

```elixir
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:

```elixir
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

### Business Scope Examples

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

#### Status-Based Workflow

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
ash_grant do
  resolver MyApp.PermissionResolver

  scope :all, true

  # Root group — no inheritance
  field_group :public, [:name, :department, :position]

  # Inherits all fields from :public, adds phone and address
  field_group :sensitive, [:public], [:phone, :address]

  # Inherits all fields from :sensitive (which includes :public)
  field_group :confidential, [:sensitive], [:salary, :email]
end
```

### Permission Strings with Field Groups

The 5th part of the permission string specifies the field group:

```elixir
"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`:

```elixir
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:

```elixir
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, [:public], [:phone, :address]
  field_group :confidential, [:sensitive], [:salary, :email]
end
```

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

```elixir
field_group :sensitive, [:public], [:phone, :address],
  mask: [:phone, :address],
  mask_with: fn value, _field ->
    if is_binary(value), do: String.replace(value, ~r/./, "*"), else: "***"
  end
```

**Masking 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:

```elixir
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:

```elixir
policy action(:destroy) do
  authorize_if AshGrant.check()
end
```

## DSL Configuration

```elixir
ash_grant do
  resolver MyApp.PermissionResolver       # Required
  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))
end
```

| Option | Type | Description |
|--------|------|-------------|
| `resolver` | module or function | **Required.** 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 |
| `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`:**

```elixir
policies do
  policy action_type(:read) do
    authorize_if AshGrant.filter_check()
  end

  policy action_type([:create, :update, :destroy]) do
    authorize_if AshGrant.check()
  end
end
```

## Advanced Usage

### Action Override

Map different Ash actions to the same permission:

```elixir
# 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_policies` **adds** policies, it doesn't replace existing ones.
You can combine them:

```elixir
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.

```elixir
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
                          |
            +-------------+-------------+-------------+
            |                           |             |
      +-----v-----+              +------v------+  +--v----------+
      |  Check    |              | FilterCheck |  | FieldCheck  |
      | (writes)  |              |  (reads)    |  | (fields)    |
      +-----+-----+              +------+------+  +--+----------+
            |                           |             |
            +-----------+---------------+-------------+
                        |
            +-----------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:

```elixir
# 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)
───────────────────────────────────────────────────────────────────
```

### Scope Descriptions

Add descriptions to scopes for better debugging output:

```elixir
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:

```elixir
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?

```elixir
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?

```elixir
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?

```elixir
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?

```elixir
# 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

```elixir
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:

```elixir
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/2` returns the ref unchanged (permissions are runtime-resolved)
- `implies?/3` returns `true` when check refs have identical module and options
- `conflicts?/3` returns `false` (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](#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:

```elixir
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:
- Atom: `:read` (shorthand for `action: :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:

```yaml
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:**

```bash
# 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:**

```bash
# 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:**

```bash
# 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:

```elixir
# 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:

```elixir
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:

```bash
mix test
```

The 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](LICENSE) for details.