← Auth & IdP overview

Zanzibar permissions guide

assay-engine v0.2.0's auth-zanzibar module implements Google's Zanzibar paper as a Keto / SpiceDB drop-in: namespaces, relation tuples, and a recursive-CTE walk that resolves check, expand, lookup_resources, and lookup_subjects on PG18 + SQLite.

Mental model

Three concepts:

Define a namespace schema

Schemas declare relations and computed-relation rewrites. Example: a document with owners, editors, and viewers, where every owner is automatically also an editor and every editor is automatically a viewer.

local auth = require("assay.auth")
local c    = auth.client({
  engine_url = "http://localhost:3000",
  api_key    = env.get("ASSAY_ADMIN_KEY"),
})

c.zanzibar:define_namespace({
  name = "doc",
  relations = {
    owner  = {},
    editor = { union = { "this", { computed_userset = "owner" } } },
    viewer = { union = { "this", { computed_userset = "editor" } } },
  },
})

Or write the equivalent JSON to auth.zanzibar_namespaces via the admin HTTP API. The schema is JSON-serialised; the parser validates it on write.

Write tuples

-- alice owns doc-42
c.zanzibar:write({
  object_type  = "doc",  object_id  = "doc-42",  relation = "owner",
  subject_type = "user", subject_id = "alice",
})

-- the eng group's members are viewers of doc-42
c.zanzibar:write({
  object_type  = "doc",   object_id  = "doc-42", relation = "viewer",
  subject_type = "group", subject_id = "eng",    subject_rel = "member",
})

-- bob is a member of the eng group
c.zanzibar:write({
  object_type  = "group", object_id  = "eng", relation = "member",
  subject_type = "user",  subject_id = "bob",
})

Check a permission

local can_view = c.zanzibar:check("doc", "doc-42", "viewer", "user", "bob")
-- true: bob ∈ eng#member ⊆ doc-42#viewer

local can_edit = c.zanzibar:check("doc", "doc-42", "editor", "user", "bob")
-- false: bob is only a viewer transitively

local alice_owns = c.zanzibar:check("doc", "doc-42", "owner", "user", "alice")
-- true: direct tuple

The check resolves via a single recursive CTE — no application-side traversal, no N+1. Identical SQL on PG18 and SQLite (recursive CTE is in both standards).

Expand a userset

local tree = c.zanzibar:expand("doc", "doc-42", "viewer")
-- {
--   relation = "viewer",
--   children = {
--     { type = "this", subjects = { "user:bob (via group:eng#member)" } },
--     { type = "computed_userset", relation = "editor", children = {
--         { type = "computed_userset", relation = "owner", children = {
--             { type = "this", subjects = { "user:alice" } } } } } },
--   },
-- }

HTTP surface

MethodPathPurpose
POST/auth/zanzibar/checkResolve (object, relation, subject) → bool
POST/auth/zanzibar/expandReturn the userset tree for a relation
POST/auth/zanzibar/writeInsert a tuple (admin)
DELETE/auth/zanzibar/tuplesDelete a tuple (admin)
GET/auth/admin/auth/zanzibar/namespacesList all namespaces
GET/auth/admin/auth/zanzibar/namespaces/{name}Get a single namespace schema

Storage

auth.zanzibar_namespaces (
  name        TEXT PRIMARY KEY,
  schema_json TEXT  -- serialised NamespaceSchema
)

auth.zanzibar_tuples (
  object_type  TEXT,
  object_id    TEXT,
  relation     TEXT,
  subject_type TEXT,
  subject_id   TEXT,
  subject_rel  TEXT  -- NULL for direct subjects, set for usersets
)

Indexed both forward ((object_type, object_id, relation, ...)) and reverse ((subject_type, subject_id, relation)) so check and lookup_resources/lookup_subjects are both fast.

Dashboard

The Zanzibar pane in /auth/console exposes:

See Auth & IdP overview for the rest of the v0.2.0 IdP surface, and Compare vs Ory for how this stacks up against Keto.