Getting Started with AshAuthzed

Copy Markdown View Source

AshAuthzed brings Google Zanzibar-style Relationship-Based Access Control (ReBAC) to Ash Framework. Instead of scattering complex role joins across dozens of resource policies, you declare how your Ash resources map to Zanzibar objects, relations, and roles, and AshAuthzed handles compile-time schema verification, batched tuple synchronization, and policy evaluation.

In this guide, you will learn how to:

  1. Choose and configure an adapter (Licet, SpiceDBHttp, or Memory).
  2. Author a ReBAC schema (user_permissions.yaml or .zed).
  3. Configure the authzed DSL on an Ash.Resource (relation, external_role, and conditional_wildcard).
  4. Write authorization policies with rebac_can/1,2 and rebac_can_assign/2.
  5. Backfill existing database records using mix ash_authzed.backfill.

1. Installation

If your application includes Igniter, run:

mix igniter.install ash_authzed --yes

Manual Installation

Add :ash_authzed to mix.exs:

def deps do
  [
    {:ash_authzed, "~> 0.2.0"},
    # Required only if using AshAuthzed.Adapters.SpiceDBHttp:
    {:req, "~> 0.5"}
  ]
end

Add :ash_authzed to .formatter.exs so DSL blocks format cleanly:

[
  import_deps: [:ash, :ash_authzed],
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

2. Choosing an Adapter

AshAuthzed implements a pluggable AshAuthzed.Adapter behaviour with three built-in backends:

AdapterBest ForStorage & Schema
AshAuthzed.Adapters.Licet (:licet)Single-app Elixir/Phoenix deployments that want ReBAC inside their existing Postgres database without running a separate service.Uses your Ecto Postgres repo via Licet + a .zed or YAML validation bundle (priv/licet/user_permissions.yaml).
AshAuthzed.Adapters.SpiceDBHttp (:spicedb_http)Multi-service architectures or high-scale deployments using a dedicated SpiceDB or AuthZed cluster.Communicates over HTTP/JSON (Req) with a SpiceDB server (/v1/permissions/*, /v1/relationships/write).
AshAuthzed.Adapters.Memory (:memory)Fast, isolated async: true unit tests and CI pipelines without external infrastructure.Caller-supervised in-memory Licet tuple store.

Option A: Configuring Licet (Embedded Postgres)

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

Option B: Configuring SpiceDBHttp (SpiceDB / AuthZed HTTP)

# 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")
  ],
  schema_path: "priv/authzed/schema.zed"

Option C: Configuring Memory for Tests

start_supervised!(
  {AshAuthzed.Adapters.Memory,
   name: MyTestAuthzed,
   schema: {:file, "priv/licet/schema.zed"}}
)

3. Designing Your Schema

Before wiring up a resource, define your object types, relations, and permissions. AshAuthzed.Verifiers.VerifyZedSchema inspects this schema at compile time to ensure your Ash resources never reference undefined object types, relations, or permissions.

definition user {}

definition platform {
  relation super_admin: user
  relation admin: user
  relation member: user

  permission assign_user        = super_admin + admin
  permission assign_admin       = super_admin
  permission assign_super_admin = super_admin
}

definition user_profile {
  relation owner: user
  relation platform: platform
  relation is_protected_admin: user:*

  permission view        = owner + platform->admin + platform->super_admin
  permission edit        = owner + platform->super_admin + (platform->admin - is_protected_admin)
  permission delete      = (platform->super_admin - owner) + (platform->admin - is_protected_admin - owner)
  permission change_role = (platform->super_admin - owner) + (platform->admin - is_protected_admin - owner)
}

4. Configuring the authzed DSL on an Ash.Resource

Add AshAuthzed to extensions and Ash.Policy.Authorizer to authorizers. Inside the authzed block, map your resource's attributes to Zanzibar tuples:

  • relation: Syncs a direct relationship tuple (subject_type + subject_id attribute, or static_subject).
  • external_role: Syncs an enum/role attribute on the resource to a relation on an external target object (target: "platform:main"), with optional role_mapping and automatic old-role tuple cleanup on update.
  • conditional_wildcard: Syncs a wildcard relation (subject_type:*) when attribute is in the configured in list.
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"

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

    conditional_wildcard :is_protected_admin,
      subject_type: "user",
      attribute: :role,
      in: [:admin, :super_admin]
  end

  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

  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

During compilation:

  1. AshAuthzed.Transformer attaches AshAuthzed.SyncChange to :create, :update, and :destroy actions and registers @external_resource for schema_path (and derives relations/policies when from_zed: true / derive_policies: true are set).
  2. AshAuthzed.Verifiers.VerifyZedSchema verifies that user_profile, owner, platform, is_protected_admin, and all mapped external_role relations exist in your schema.

5. Writing Policies with authzed_can/1,2 and authzed_can_assign/2

Add a policies block to your resource using the imported AshAuthzed.PolicyMacros (or set from_zed: true and derive_policies: true to auto-generate standard action policies directly from your .zed permissions):

  policies do
    # 1. Read actions: filters the query via `lookup_resources` to only records the actor can `:view`
    policy action(:read) do
      authorize_if authzed_can(:view)
    end

    # 2. Update & Destroy actions: checks `:edit` / `:delete` permission on the target record
    policy action(:update_profile) do
      authorize_if authzed_can(:edit)
    end

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

    # 3. Role Assignment: verifies both `platform:main#assign_<new_role>` AND `user_profile:<id>#change_role`
    policy action(:change_role) do
      forbid_unless authzed_can_assign(:role, on: "platform:main")
      authorize_if authzed_can(:change_role)
    end
  end

6. Applying Schemas & Backfilling Existing Data

Applying Your Schema (mix ash_authzed.schema.apply)

Push your .zed or .yaml schema file directly to your configured backend (Licet, SpiceDBHttp, or Memory):

mix ash_authzed.schema.apply

Backfilling Existing Data (mix ash_authzed.backfill)

If you are integrating AshAuthzed into an application that already has rows in PostgreSQL, run the backfill task to stream and reconcile relationship tuples in batches:

mix ash_authzed.backfill

7. Benchmarking and Telemetry

Telemetry Events

AshAuthzed.Adapter emits :telemetry.span/3 events ([:ash_authzed, :check, ...], [:ash_authzed, :lookup_resources, ...], [:ash_authzed, :write_tuples, ...], and [:ash_authzed, :apply_schema, ...]) so you can monitor latency and batch sizes in production.

Benchmarking Adapters

To compare throughput and latency across Memory, Licet, and SpiceDBHttp, run:

mix ash_authzed.bench --count 200

8. Going Further: Fine-Grained Capabilities (admin_create, admin_edit, admin_delete)

While the User profile walkthrough above models classic coarse roles (super_admin, admin, member), Zanzibar schemas also let you decompose roles into action-level capabilities without changing your adapter configuration.

For example, in priv/licet/admin_capabilities.yaml, a full admin automatically inherits admin_create, admin_edit, and admin_delete, while delegated users can be granted individual capabilities (such as can_edit -> admin_edit) without receiving destructive admin_delete access:

definition user {}

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

  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
}

In your Ash.Resource, each action checks its corresponding capability via authzed_can/1,2:

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"

    relation :catalog, static_subject: "catalog:main"

    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