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:
- Choose and configure an adapter (
Licet,SpiceDBHttp, orMemory). - Author a ReBAC schema (
user_permissions.yamlor.zed). - Configure the
authzedDSL on anAsh.Resource(relation,external_role, andconditional_wildcard). - Write authorization policies with
rebac_can/1,2andrebac_can_assign/2. - Backfill existing database records using
mix ash_authzed.backfill.
1. Installation
Using Igniter (Recommended)
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"}
]
endAdd :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:
| Adapter | Best For | Storage & 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_idattribute, orstatic_subject).external_role: Syncs an enum/role attribute on the resource to a relation on an externaltargetobject (target: "platform:main"), with optionalrole_mappingand automatic old-role tuple cleanup on update.conditional_wildcard: Syncs a wildcard relation (subject_type:*) whenattributeis in the configuredinlist.
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
endDuring compilation:
AshAuthzed.TransformerattachesAshAuthzed.SyncChangeto:create,:update, and:destroyactions and registers@external_resourceforschema_path(and derives relations/policies whenfrom_zed: true/derive_policies: trueare set).AshAuthzed.Verifiers.VerifyZedSchemaverifies thatuser_profile,owner,platform,is_protected_admin, and all mappedexternal_rolerelations 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
end6. 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