CI Hex.pm HexDocs Hex.pm Downloads License

Declarative Relationship-Based Access Control (ReBAC) and Zanzibar-style permissions for the Ash Framework.

AshAuthzed extends Ash.Resource with a Spark DSL that bridges Ash.Policy.Authorizer and resource lifecycle hooks with AuthZed / SpiceDB or Licet (an embedded Elixir/Postgres Zanzibar engine). Define your resource relationships once, verify them against your .zed or .yaml schema at compile time, and let AshAuthzed automatically keep your permission tuples in sync across single and bulk actions.


Quick Look

defmodule MyApp.Workspace.Document do
  use Ash.Resource,
    domain: MyApp.Workspace,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthzed]

  authzed do
    object_type "document"
    schema_path "priv/authzed/schema.zed"

    relation :organization, subject_type: "organization", subject_id: :organization_id
    relation :owner, subject_type: "user", subject_id: :owner_id
    external_role :role, target: "organization:main", subject_type: "user", subject_id: :owner_id
  end

  policies do
    # Filters collection reads via lookup_resources & guards actions via check_permission
    policy action_type(:read) do
      authorize_if authzed_can(:view)
    end

    policy action(:update) do
      authorize_if authzed_can(:edit)
    end
  end
end

Key Features

  • Declarative & Schema-First Resource DSL — Map Zanzibar object_type, direct relation tuples, external_role mappings, and conditional_wildcard rules inside your Ash.Resource definitions (authzed / rebac DSL sections)—or set from_zed: true and derive_policies: true to automatically derive relations and policies directly from your .zed schema.
  • Compile-Time Schema Verification — Validates your resource's object_type, relations, enum role mappings, and policy check permissions against your .zed or .yaml schema at compile time—complete with "Did you mean?" Jaro-distance suggestions and automatic @external_resource recompilation when your schema file changes.
  • Native Ash Policy Checks — Use authzed_can/1,2 and authzed_can_assign/2 (or their rebac_* aliases) inside policies blocks to filter collection reads via lookup_resources and authorize mutations or role assignments via check?.
  • Automatic & Batched Tuple Synchronization — Automatically wires lifecycle hooks onto :create, :update, and :destroy actions. Supports single-record mutations, atomic updates, and deduplicated batch writes across Ash.bulk_create!, Ash.bulk_update!, and Ash.bulk_destroy!.
  • Built-In Mix Tasks — Includes mix ash_authzed.install (Igniter installer), mix ash_authzed.schema.apply (runtime .zed / .yaml schema deployment across adapters), mix ash_authzed.backfill (keyset-paginated tuple reconciliation for existing records), and mix ash_authzed.bench (cross-adapter throughput benchmarking).

Pluggable Adapters

Choose the backing engine that fits your environment without changing a line of resource or policy code:

AdapterOptionBacking StoreBest For
AshAuthzed.Adapters.Licet:licetExisting Ecto.Repo (Postgres)Zero-infrastructure setups needing native Ecto SQL Sandbox & transaction support
AshAuthzed.Adapters.SpiceDBHttp:spicedb_httpSpiceDB / AuthZed HTTP API (Req)Multi-service or high-scale production clusters (/v1/permissions/*, /v1/relationships/write)
AshAuthzed.Adapters.Memory:memoryCaller-supervised in-memory LicetSub-millisecond, zero-DB async: true ExUnit tests

Installation

If your project uses Igniter, install and configure ash_authzed automatically:

# Default (embedded Licet + Ecto Repo)
mix igniter.install ash_authzed --yes

# Or configure for an external SpiceDB HTTP server
mix igniter.install ash_authzed --adapter spicedb --yes

Manual Installation

  1. Add :ash_authzed to your dependencies in mix.exs:
def deps do
  [
    {:ash_authzed, "~> 0.2.0"},
    # Required only if using AshAuthzed.Adapters.SpiceDBHttp:
    {:req, "~> 0.5"}
  ]
end
  1. Add :ash_authzed to your .formatter.exs so mix format preserves DSL syntax without parentheses:
[
  import_deps: [:ash, :ash_authzed],
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

Configuration

AshAuthzed uses a behaviour-driven adapter interface (AshAuthzed.Adapter) configurable per resource (adapter and adapter_opts in the authzed DSL block) or globally via application configuration.

1. Embedded Licet Adapter (:licet — Default)

Evaluates .zed schemas and stores relationship tuples inside your existing Postgres database via Licet:

# config/config.exs
import Config

config :ash_authzed,
  adapter: AshAuthzed.Adapters.Licet,
  adapter_opts: [instance: MyApp.Licet, consistency: :minimize_latency],
  schema_path: "priv/licet/user_permissions.yaml"

2. SpiceDB HTTP Adapter (:spicedb_http)

Communicates with a SpiceDB or AuthZed HTTP API (/v1/permissions/check, /v1/permissions/resources, /v1/relationships/write) using Req:

# config/runtime.exs
import Config

config :ash_authzed,
  adapter: AshAuthzed.Adapters.SpiceDBHttp,
  adapter_opts: [
    url: System.fetch_env!("SPICEDB_HTTP_URL"),
    token: System.fetch_env!("SPICEDB_TOKEN")
  ]

Usage in an Ash.Resource

Add AshAuthzed to extensions alongside Ash.Policy.Authorizer:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    domain: MyApp.Accounts,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthzed]

  authzed do
    object_type "user_profile"
    actor_type "user"
    id_attribute :id
    adapter :licet
    adapter_opts instance: MyApp.Licet
    schema_path "priv/licet/user_permissions.yaml"

    # Writes: user_profile:<id>#owner@user:<id>
    relation :owner, subject_type: "user", subject_id: :id

    # Writes: user_profile:<id>#platform@platform:main
    relation :platform, static_subject: "platform:main"

    # Writes: platform:main#<mapped_role>@user:<id> (and deletes old role tuple on update!)
    external_role :role,
      target: "platform:main",
      subject_type: "user",
      subject_id: :id,
      role_mapping: %{user: "member", admin: "admin", super_admin: "super_admin"}

    # Writes wildcard tag: user_profile:<id>#is_protected_admin@user:* when role in [:admin, :super_admin]
    conditional_wildcard :is_protected_admin,
      subject_type: "user",
      attribute: :role,
      in: [:admin, :super_admin]
  end

  # Note: AshAuthzed.Transformer automatically attaches AshAuthzed.SyncChange
  # to :create, :update, and :destroy actions!
  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:id, :name, :email, :role]
    end

    update :update_profile do
      accept [:name]
    end

    update :change_role do
      accept [:role]
    end
  end

  policies do
    policy action(:read) do
      authorize_if authzed_can(:view)
    end

    policy action(:update_profile) do
      authorize_if authzed_can(:edit)
    end

    policy action(:destroy) do
      authorize_if authzed_can(:delete)
    end

    policy action(:change_role) do
      forbid_unless authzed_can_assign(:role, on: "platform:main")
      authorize_if authzed_can(:change_role)
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :email, :string, allow_nil?: false, public?: true

    attribute :role, :atom do
      allow_nil? false
      default :user
      public? true
      constraints one_of: [:user, :admin, :super_admin]
    end
  end
end

Schema-First Resource & Policy Derivation (from_zed & derive_policies)

When your .zed file is the single source of truth, enable from_zed and derive_policies to automatically derive single-subject relations (matching :<relation>_id attributes on the resource) and Ash.Policy.Authorizer policies directly from your Zanzibar definition at compile time:

defmodule MyApp.Workspace.Document do
  use Ash.Resource,
    domain: MyApp.Workspace,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthzed]

  attributes do
    uuid_primary_key :id
    attribute :owner_id, :uuid, allow_nil?: false, public?: true
    attribute :organization_id, :uuid, allow_nil?: false, public?: true
  end

  actions do
    defaults [:read, :create, :update, :destroy]
  end

  authzed do
    object_type "document"
    from_zed "priv/authzed/schema.zed"
    # Or `derive_policies [view: :read, edit: :update, delete: :destroy]`
    derive_policies true

    # Explicit relations take precedence over auto-derived ones
    # (e.g. for static subjects or union/subject-set relations):
    relation :platform, static_subject: "platform:main"
  end
end

Permission Modeling Patterns

AshAuthzed supports both classic role hierarchies and fine-grained capability permissions.

Pattern 1: Role-Based Permissions (RBAC in ReBAC)

Map an attribute or relationship (such as :role) to Zanzibar relations and check coarse permissions (:view, :edit, :delete) in your Ash policies:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    domain: MyApp.Accounts,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthzed]

  authzed do
    object_type "user_profile"
    schema_path "priv/licet/user_permissions.yaml"

    relation :owner, subject_type: "user", subject_id: :id
    relation :platform, static_subject: "platform:main"

    external_role :role,
      target: "platform:main",
      subject_type: "user",
      subject_id: :id,
      role_mapping: %{user: "member", admin: "admin", super_admin: "super_admin"}
  end

  policies do
    policy action_type(:read) do
      authorize_if authzed_can(:view)
    end
  end
end

Pattern 2: Capability-Based Permissions (admin with Granular Capabilities)

Model granular action capabilities (admin_create, admin_edit, admin_delete) in your schema. A full admin automatically inherits all three capabilities, while delegated actors can be granted individual capabilities (such as admin_edit only) without receiving destructive admin_delete access.

1. Define the schema (.zed or .yaml):

definition user {}

definition catalog {
  // Full admin role
  relation admin: user

  // Optional direct capability grants
  relation can_create: user
  relation can_edit: user
  relation can_delete: user

  // Computed capabilities: full admin OR specific capability grant
  permission admin_create = admin + can_create
  permission admin_edit   = admin + can_edit
  permission admin_delete = admin + can_delete
}

definition managed_item {
  relation catalog: catalog
  relation admin: user
  relation can_create: user
  relation can_edit: user
  relation can_delete: user

  permission admin_create = admin + can_create + catalog->admin_create
  permission admin_edit   = admin + can_edit + catalog->admin_edit
  permission admin_delete = admin + can_delete + catalog->admin_delete
}

2. Guard each Ash action with its specific capability:

defmodule MyApp.Catalog.ManagedItem do
  use Ash.Resource,
    domain: MyApp.Catalog,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthzed]

  authzed do
    object_type "managed_item"
    schema_path "priv/licet/admin_capabilities.yaml"

    # Links item to catalog: `managed_item:<id>#catalog@catalog:main`
    relation :catalog, static_subject: "catalog:main"

    # Syncs per-item capability (`managed_item:<id>#<capability>@user:<admin_id>`);
    # pass `target: "catalog:main"` when granting catalog-wide roles instead
    external_role :capability,
      subject_type: "user",
      subject_id: :admin_id
  end

  policies do
    policy action(:create) do
      authorize_if authzed_can(:admin_create, target: "catalog:main")
    end

    policy action(:update) do
      authorize_if authzed_can(:admin_edit)
    end

    policy action(:destroy) do
      authorize_if authzed_can(:admin_delete)
    end

    policy action_type(:read) do
      authorize_if authzed_can(:admin_edit)
    end
  end
end

Testing (async: true)

AshAuthzed supports fast, isolated async: true ExUnit tests out of the box—either via Licet with Ecto's SQL Sandbox or via AshAuthzed.Adapters.Memory, which runs a caller-supervised in-memory tuple store without requiring Postgres or a SpiceDB container:

defmodule MyApp.Accounts.UserPolicyTest do
  use ExUnit.Case, async: true

  setup do
    start_supervised!(
      {AshAuthzed.Adapters.Memory,
       name: MyTestAuthzed,
       schema: {:file, "priv/licet/schema.zed"},
       relationships: ["platform:main#super_admin@user:root"]}
    )

    :ok
  end

  test "authorizes profile updates for the owner" do
    user =
      MyApp.Accounts.User
      |> Ash.Changeset.for_create(:create, %{id: "u1", name: "Alice", email: "alice@example.com"})
      |> Ash.create!(authorize?: false)

    assert Ash.can?({user, :update_profile}, user)
  end
end

Compile-Time Schema Verification

AshAuthzed.Verifiers.VerifyZedSchema runs automatically after DSL compilation and verifies against your .zed file or Licet .yaml validation bundle:

  1. object_type Existence: Ensures definition <object_type> exists in your schema.
  2. Entity Relations (relation & conditional_wildcard): Ensures every declared relation exists on definition <object_type>.
  3. External Role Enum Exhaustiveness (external_role): Inspects the attribute's one_of constraints (or Ash.Type.Enum values), applies role_mapping, and verifies that every possible enum value maps to a defined relation on the target definition.
  4. Policy Checks (authzed_can & authzed_can_assign): Verifies that every permission checked in policies exists on the resource definition, and that every assign_<role> permission exists on the target definition for authzed_can_assign.

If any mismatch is detected, compilation fails immediately with a Spark.Error.DslError and a Jaro-distance "Did you mean ...?" hint.


Mix Tasks

Applying Schemas at Runtime (mix ash_authzed.schema.apply)

Push your .zed or .yaml Zanzibar schema directly to the configured ReBAC backend (Licet, SpiceDBHttp, or Memory) via AshAuthzed.apply_schema/2 or the Mix task:

# Apply the default schema (`config :ash_authzed, :schema_path`) to the configured adapter
mix ash_authzed.schema.apply

# Apply an explicit .zed or .yaml schema file to SpiceDB
mix ash_authzed.schema.apply --schema priv/authzed/schema.zed --adapter spicedb_http

Backfilling Existing Data (mix ash_authzed.backfill)

When adding AshAuthzed to an existing application with populated database tables, stream and reconcile relationship tuples in batches:

# Backfill all AshAuthzed resources in the current project
mix ash_authzed.backfill

# Backfill a specific resource with custom batch size
mix ash_authzed.backfill --resource MyApp.Accounts.User --batch-size 250

# Preview tuple operation counts without writing to the adapter
mix ash_authzed.backfill --dry-run

Schema Validation (zed validate & Licet.Validate)

AshAuthzed ships with validation suites (priv/licet/user_permissions.yaml and priv/licet/admin_capabilities.yaml) that are compatible with both Licet.Validate (pure Elixir) and the official AuthZed CLI (zed validate):

# Validate using the AuthZed CLI (zero warnings)
zed validate priv/licet/user_permissions.yaml priv/licet/admin_capabilities.yaml

# Or run the ExUnit schema & contract validation suite
mix test test/ash_authzed/schema_validation_test.exs

Benchmarking Adapters (mix ash_authzed.bench)

AshAuthzed includes a built-in benchmark and parity verification task (lib/mix/tasks/ash_authzed.bench.ex) that measures single create, bulk create (after_batch/3), Ash.can?, collection read (lookup_resources), and atomic bulk role updates across all supported backends:

# Run across all 4 backends (Memory, Licet on Postgres, SpiceDB on Memory, SpiceDB on Postgres)
mix ash_authzed.bench --all --count 500 --batch-size 100

# Run a specific adapter or comma-separated list (`memory`, `licet`, `spicedb_http`, `spicedb_postgres`)
mix ash_authzed.bench --adapter memory,licet --count 200

# Benchmark a custom Ash resource in your own application
mix ash_authzed.bench --resource MyApp.Accounts.User --count 200

External Tools & Dependencies for Live Benchmarks & Schema Testing

While AshAuthzed.Adapters.Memory and Licet.Validate run purely in Elixir with zero external dependencies, running the full live benchmark (--all) and CLI schema validation uses:

Tool / ServiceUsed ByPurpose & Default Configuration
PostgreSQL (>= 14)AshAuthzed.Adapters.Licet, :spicedb_postgresAutomatically provisions and tears down isolated temporary schemas/databases via POSTGRES_URL (defaults to ecto://postgres:postgres@localhost/postgres).
SpiceDB (spicedb binary in PATH)AshAuthzed.Adapters.SpiceDBHttpAutomatically spawns an ephemeral spicedb serve HTTP/gRPC server (and runs spicedb datastore migrate head for :spicedb_postgres) if SPICEDB_URL is not already running.
AuthZed CLI (zed binary in PATH)zed validate / schema_validation_test.exsValidates .zed and .yaml schema definitions, relationship fixtures, and permission assertions offline and in GitHub Actions CI (authzed/action-spicedb-validate@v1).

Reference Benchmark Results (500 records, batch size 100, full consistency)

Adapter / Backend1x CreateBulk Create (500 rows)1x Ash.can?Read List (500 rows)Atomic Bulk Update (50 rows)
AshAuthzed.Adapters.Memory88.61 ms171.18 ms (0.342 ms/row)7.89 ms28.91 ms27.74 ms
AshAuthzed.Adapters.Licet (PG)6.42 ms819.39 ms (1.639 ms/row)2.85 ms34.28 ms122.23 ms
AshAuthzed.Adapters.SpiceDBHttp (Mem)4.16 ms360.70 ms (0.721 ms/row)1.27 ms28.07 ms77.13 ms
AshAuthzed.Adapters.SpiceDBHttp (PG)7.25 ms598.46 ms (1.197 ms/row)7.57 ms31.36 ms110.09 ms

Telemetry & Observability

AshAuthzed.Adapter wraps all permission checks, resource lookups, and tuple writes in :telemetry events so you can monitor authorization latency and batch sizes:

EventMeasurementsMetadata
[:ash_authzed, :check, :start | :stop | :exception]duration, monotonic_timeadapter, resource, permission, subject, status
[:ash_authzed, :lookup_resources, :start | :stop | :exception]duration, monotonic_timeadapter, resource_type, permission, subject, status
[:ash_authzed, :write_tuples, :start | :stop | :exception]duration, monotonic_timeadapter, operation_count, operations, status
[:ash_authzed, :apply_schema, :start | :stop | :exception]duration, monotonic_timeadapter, status
[:ash_authzed, :adapter, :write_tuples]tuple_countadapter, operations

Adapter and validation failures return structured %AshAuthzed.Error{reason: atom(), details: term()} exceptions.


Documentation & AI Usage Rules


License

Released under the MIT License.