AshAuthzed (also aliased as AshRebac) provides Zanzibar-style Relationship-Based Access Control (ReBAC) for Ash.Resource modules via Ash.Policy.Authorizer, automatic batched tuple lifecycle synchronization (AshAuthzed.SyncChange), compile-time schema drift verification (AshAuthzed.Verifiers.VerifyZedSchema), and pluggable backends (Licet, SpiceDBHttp, and Memory).
Core Invariants
- Always Include Both Extensions: Every resource using
AshAuthzedMUST includeauthorizers: [Ash.Policy.Authorizer]andextensions: [AshAuthzed]inuse Ash.Resource. - Never Manually Add
AshAuthzed.SyncChangeto Actions:AshAuthzed.Transformerautomatically attacheschange {AshAuthzed.SyncChange, on: [:create, :update, :destroy]}and registers@external_resourceforschema_path. Do not manually addchange AshAuthzed.SyncChangeinside individual actions. - Compile-Time Schema Alignment:
AshAuthzed.Verifiers.VerifyZedSchemavalidatesobject_type,relation,external_role,conditional_wildcard,rebac_can, andrebac_can_assignagainst your configured.zedor.yamlschema at compile time. Always update your schema file before referencing new relations or permissions in a resource. - Consult Generated DSL Docs: For exact option types and defaults, check
documentation/dsls/DSL-AshAuthzed.mdor runmix usage_rules.search_docs "AshAuthzed".
Resource DSL (authzed / rebac)
# ✅ DO: Declare `object_type`, `relation`, `external_role`, and `conditional_wildcard` in the `authzed` block
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"
# Direct relation: user_profile:<id>#owner@user:<id>
relation :owner, subject_type: "user", subject_id: :id
# Static subject relation: user_profile:<id>#platform@platform:main
relation :platform, static_subject: "platform:main"
# External role relation on another object: platform:main#<mapped_role>@user:<id>
# Automatically deletes old role tuples on update or backfill reconciliation!
external_role :role,
target: "platform:main",
subject_type: "user",
subject_id: :id,
role_mapping: %{user: "member", admin: "admin", super_admin: "super_admin"}
# 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
end
# ✅ DO: Use `from_zed: true` (or `from_zed: "priv/authzed/schema.zed"`) and `derive_policies: true`
# when you want single-subject relations (`:<relation>_id`) and `Ash.Policy.Authorizer` policies
# automatically derived from the `.zed` definition (grouped by `action_type` with OR semantics).
# Override static subjects or union/subject-set relations (`user | group#member`) explicitly in `authzed`:
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 pass a keyword list/map: `derive_policies [view: :read, edit: :update, delete: :destroy]`
derive_policies true
# Explicit relations take precedence over auto-derived ones:
relation :platform, static_subject: "platform:main"
end
end
# ❌ DON'T: Do not invent `resource_type`, `field:`, `condition: expr(...)`, or nested `sync do ... end` blocksAuthorization Policies (authzed_can/1,2 & authzed_can_assign/2)
AshAuthzed.PolicyMacros is automatically imported into every resource that uses AshAuthzed:
authzed_can(permission, opts \\ [])(aliasrebac_can/1,2):- Expands to
{AshAuthzed.Checks.Can, [permission: permission, ...]}(Ash.Policy.FilterCheck). - On read actions, filters queries via
AshAuthzed.Adapter.lookup_resources/4(expr(^ref(id_attr) in ^allowed_ids)). - On update/destroy actions and
Ash.can?, checks whether the current actor holdspermissionon the target record(s). - On create actions (where the record does not exist yet in the ReBAC store), pass
target: "type:id"(oron: "type:id") to evaluatepermissionon the parent container object viaAshAuthzed.Adapter.check?/4.
- Expands to
authzed_can_assign(attribute, opts)(aliasrebac_can_assign/2):- Expands to
{AshAuthzed.Checks.CanAssign, [attribute: attribute, on: target, ...]}(Ash.Policy.SimpleCheck). - Inspects the incoming attribute value on create/update changesets and verifies
Adapter.check?(opts[:on] || opts[:target], "#{prefix}#{new_val}", subject, adapter_opts)(defaults toprefix: "assign_").
- Expands to
# ✅ DO: Use `authzed_can/1` for resource permissions and `authzed_can_assign/2` for role/attribute assignment guards
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
# ✅ DO: Prefer capability-named permissions in `authzed_can/1,2` (e.g., `authzed_can(:admin_create, target: "catalog:main")`,
# `authzed_can(:admin_edit)`, `authzed_can(:admin_delete)`) over checking role names directly in Ash
# policies. For `:create` actions (where the record ID does not exist yet in the ReBAC store), pass
# `target: "type:id"` (or `on: "type:id"`) to evaluate the capability on the parent/container object synchronously:
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
# ❌ DON'T: Check coarse role names or raw attributes in Ash policies when capabilities
# are modeled in your schema—this bypasses delegated capability grants (like `can_edit`):
policies do
policy action(:update) do
# Bad: blocks actors who have `can_edit` or inherited `catalog->admin_edit`
authorize_if authzed_can(:admin)
end
endAdapters & Testing (async: true)
| Adapter | Module | Key Options |
|---|---|---|
:licet | AshAuthzed.Adapters.Licet | instance: MyApp.Licet, consistency: :minimize_latency | :full, write_mode: :touch | :create |
:spicedb_http | AshAuthzed.Adapters.SpiceDBHttp | url: "http://localhost:8443", token: "secret", req_options: [...] |
:memory | AshAuthzed.Adapters.Memory | store: MyTestStore, timeout: 5_000 |
Testing & Runtime Schema Lifecycle Rules
- Caller-Supervised Memory Store: Start
AshAuthzed.Adapters.Memoryviastart_supervised!/1with a unique:nameper test forasync: truesafety. - Applying Schemas Programmatically: Use
AshAuthzed.apply_schema(path_or_zed_text, adapter_opts)to push.zedor.yamlvalidation schemas at runtime to:licet,:spicedb_http, or:memory. - Seed Fixtures with
authorize?: false: When creating prerequisite records in testsetupblocks, passauthorize?: falsesoAshAuthzed.SyncChangewrites initial tuples without requiring an already-authorized actor. - Asserting Read vs. Write Policies:
- Reads filter unauthorized records rather than raising: assert on the returned list.
- Mutations raise
Ash.Error.Forbidden: assert withassert_raise Ash.Error.Forbidden, fn -> ... end.
Operational Mix Tasks
mix igniter.install ash_authzed --yes— Installs and configuresAshAuthzedvia Igniter (always pass--yesin non-interactive sessions).mix ash_authzed.schema.apply— Applies the.zedor.yamlschema file to the configured adapter (--schema,--adapter).mix ash_authzed.backfill— Streams existing Ash resources and reconciles relationship tuples in batches (--resource,--domain,--batch-size,--dry-run,--no-reconcile).mix ash_authzed.bench— Benchmarks single creates, bulk creates,Ash.can?, collection reads, and atomic bulk updates across adapters.mix spark.cheat_sheets --extensions AshAuthzed— Regeneratesdocumentation/dsls/DSL-AshAuthzed.mdafter modifying the Spark DSL schema.