authzed-codegen

May 10, 2026 · View on GitHub

Type-safe Go bindings for AuthZED / SpiceDB schemas. Each definition block in a .zed file becomes a .gen.go with typed constructors, relation writers, and per-permission Check / Lookup methods over the runtime engine in pkg/authz/.

Example

Given a schema:

definition menusvc/order {
    relation creator: menusvc/user | menusvc/customer
    relation belongs_company: menusvc/company
    permission write = creator + creator->manage + belongs_company->manage
}

The codegen produces typed bindings:

order := menusvc.Order("o-1")
user  := menusvc.User("u-1")

if err := order.CreateCreatorRelations(ctx, menusvc.OrderCreatorObjects{
    User: []menusvc.User{user},
}); err != nil {
    return err
}

ok, err := order.CheckWrite(ctx, menusvc.CheckOrderWriteInputs{
    User: []menusvc.User{user},
})

Each method dispatches through the authz.Engine interface; the SpiceDB client lives in pkg/authz/spicedb/.

Install

go install github.com/danhtran94/authzed-codegen/cmd/authzed-codegen@latest

Usage

authzed-codegen --output <out-dir> <schema.zed>

One .gen.go is emitted per definition block, grouped by namespace (menusvc/order<out-dir>/menusvc/order.gen.go). See example/ for a complete schema and its generated output.

Schema Support

ConstructStatus
Union (+), arrow (->)
Wildcard relations (type:*)✓ — Wildcards sub-struct on <Rel>Objects; sibling Read<Rel><Type>Wildcard read methods
Intersection (&), exclusion (-)
Caveats (with <caveat>)✓ — typed <Pascal>Args per caveat; nested Caveats sub-struct on <Rel>Objects and Check<Perm>Inputs; multi-caveat-per-permission supported
Expiration (with expiration)✓ — per-tuple TTL via Expirations sub-struct on <Rel>Objects; auto-switches to OPERATION_TOUCH; combines with caveats
Sub-relation references (foo#bar)✓ — typed userset write field (<TypeName><PascalSubRel>) on <Rel>Objects; userset Check input field; SubRelation on metadata struct
Functioned arrows (.any() / .all())✓ — server-side function semantic; .all() enforces strict-intersection across parent rows (dual-control / multi-approver patterns)
_self (use self)✓ — identity-match base case for recursive permissions (permission ancestor_or_self = self + parent->ancestor_or_self) — tree-walk patterns
_this, _nil✗ rejected at adapt time. _this is fully deprecated upstream per the SpiceDB proto; modern schemas don't emit it. _nil is a compiler-internal optimization marker users don't write directly.

Parsing delegates to github.com/authzed/spicedb/pkg/schemadsl/compiler — any schema SpiceDB accepts will parse. The codegen layer is narrower; rejected constructs surface schema-relative errors before any output is written. Rationale: docs/ADR-001-parser-migration.md.

Caveats

Relations and allowed types declared with <caveat> generate a typed <CaveatPascal>Args struct per caveat (one per namespace) plus a Caveats sub-struct on the relation's <Rel>Objects and the permission's Check<Perm>Inputs.

Caveat parameter type mapping:

SpiceDB typeGo typeNotes
bool*boolscalar; pointer for deferred binding
string*stringscalar
int*intscalar
uint*uintscalar
double*float64scalar
bytes[]bytenilable directly
list<T>[]Telement type recurses; nested lists supported
duration*time.Durationconverted via .String() at structpb encoding site
timestamp*time.Timeconverted via .Format(time.RFC3339)
ipaddress*stringcaller passes pre-formatted IP string
map<K,V>anycurrently fall-back; no typed Go mapping yet

Scalar pointer fields let callers defer individual parameters to check time (nil at write, supplied at check). Container types are nilable directly.

caveat extsvc/tenant_match(tenant string) {
    tenant == "acme"
}

definition extsvc/folder {
    relation tenanted_viewer: extsvc/user with extsvc/tenant_match
    permission tenanted_browse = tenanted_viewer
}

Pre-bind the policy at write time (caveat travels with the tuple):

folder.CreateTenantedViewerRelations(ctx, extsvc.FolderTenantedViewerObjects{
    User: []extsvc.User{user},
    Caveats: extsvc.FolderTenantedViewerCaveats{
        User: &extsvc.TenantMatchArgs{Tenant: new("acme")},
    },
})

Or defer all binding to check time (write attaches the caveat name with no pre-context; check supplies the value):

folder.CreateTenantedViewerRelations(ctx, extsvc.FolderTenantedViewerObjects{
    User: []extsvc.User{user},
    // Caveats omitted — write-time pre-context is nil
})

ok, err := folder.CheckTenantedBrowse(ctx, extsvc.CheckFolderTenantedBrowseInputs{
    User: []extsvc.User{user},
    Caveats: extsvc.CheckFolderTenantedBrowseCaveats{
        TenantMatch: &extsvc.TenantMatchArgs{Tenant: new("acme")},
    },
})

Per-key precedence is per SpiceDB's wire model: write-time values win on collision, unbound keys fall through to check-time. Permissions reaching 2+ distinct caveats are supported — Check<Perm>Caveats gets one field per unique caveat, the generated method merges all non-nil entries into one wire Context. Cross-caveat parameter-name collisions (two caveats declaring the same key) are detected at codegen and emit a clear error.

Lookup<Perm><Type>Resources and Lookup<Perm><Type>Subjects thread caveat context through too — for caveat-reaching permissions, both methods accept a Caveats argument (positional for Subjects, on the existing input struct for Resources) and route through LookupResourcesWithCaveat / LookupSubjectsWithCaveat. CONDITIONAL_PERMISSION results are filtered out of the returned slice, matching Check<Perm>'s collapse-to-deny semantics.

See docs/spec-002-caveat-codegen.md and docs/spec-003-write-time-caveat-codegen.md.

Expiration

Schemas declaring use expiration at the top can mark relations with with expiration. Tuples carry per-tuple TTL via OptionalExpiresAt; SpiceDB filters expired entries server-side from Check / Lookup / Read. The codegen surfaces a *time.Time field per expiring allowed type on a new Expirations sub-struct (parallel to Wildcards and Caveats):

use expiration

definition extsvc/folder {
    relation expiring_viewer: extsvc/user with expiration
    permission expiring_browse = expiring_viewer
}
expiresAt := time.Now().Add(1 * time.Hour)
folder.CreateExpiringViewerRelations(ctx, extsvc.FolderExpiringViewerObjects{
    User: []extsvc.User{user},
    Expirations: extsvc.FolderExpiringViewerExpirations{
        User: &expiresAt,
    },
})

Combined with caveats — relation gated: extsvc/user with extsvc/tenant_match and expiration — both Caveats and Expirations sub-structs are populated independently. The codegen routes through CreateRelationsWithExpiration (auto-switching to OPERATION_TOUCH because un-garbage-collected expired tuples may collide on tuple identity). See docs/spec-004-expiration-codegen.md.

Read with Metadata

Read<Rel><Type>Relations returns []<Rel><Type>Relation — a typed metadata struct per tuple carrying the subject ID alongside the caveat name, decoded caveat context, and expiration timestamp:

type FolderTenantedViewerUserRelation struct {
    ID            extsvc.User
    CaveatName    string         // "" when no caveat is attached
    CaveatContext map[string]any // nil when no caveat or empty pre-context
    ExpiresAt     *time.Time     // nil when no per-tuple TTL
}

The metadata fields are nil/empty for plain relations; they populate from SpiceDB's Relationship.OptionalCaveat and Relationship.OptionalExpiresAt for trait-bearing tuples. Use cases — admin/audit UIs that need to surface "user X has access via tenant=acme until 2026-Q4" without bypassing the codegen.

For callers that just want the IDs (matching the pre-v1.4.0 shape):

rels, _ := folder.ReadViewerUserRelations(ctx)
users := authz.IDsOf(rels)  // []User

authz.IDsOf is a generic helper; type inference resolves the typed slice from the single positional argument.

Wildcard reads return the same metadata struct alongside the presence bool:

meta, isWildcard, err := folder.ReadGuestUserWildcard(ctx)
if isWildcard && meta.ExpiresAt != nil {
    // public-for-everyone-until-timestamp pattern
}

See docs/spec-005-read-with-metadata.md for the full Engine surface and constraints (no auto-decoded <Caveat>Args, slice materialization vs streaming, wildcard split discipline).

Sub-relation References

Schemas declaring relation X: Type#SubRelation grant access via inheritance — anyone reaching Type#SubRelation (typically a permission or relation on the target type) is implicitly granted on the resource. The codegen surfaces userset writes as a typed field on <Rel>Objects:

definition extsvc/team {
    relation owner: extsvc/user
    relation manager: extsvc/user
    permission admin = owner + manager
}

definition extsvc/folder {
    relation collab: extsvc/team#admin
    permission collab_view = collab
}
// Grant team t1's admin set as a collaborator. SpiceDB stores
// (folder:f1, collab, team:t1#admin) — the wire keeps the team ID
// as the anchor; user resolution happens at Check time.
folder.CreateCollabRelations(ctx, extsvc.FolderCollabObjects{
    TeamAdmin: []extsvc.Team{team},
})

Common-case Check (does user u1 have access?) — SpiceDB walks the userset chain server-side:

// u1 must be owner or manager of t1 for this Check to grant.
ok, _ := folder.CheckCollabView(ctx, extsvc.CheckFolderCollabViewInputs{
    TeamAdmin: []extsvc.Team{team},  // userset-as-subject input
})

Permissions reaching userset allowed types expose userset input fields on Check<Perm>Inputs. The userset-as-subject Check (rare case) matches the literal userset reference — useful for "does this group itself have permission?" admin/audit tooling. Direct-subject Check (the common case) walks the chain transparently when the schema includes both branches.

Read-side rows surface a SubRelation field on the metadata struct — empty for direct subjects, non-empty for userset references. Mixed schemas (relation viewer: user | team#admin) produce distinct Read methods per subject type (ReadViewerUserRelations and ReadViewerTeamRelations); each returns disjoint rows.

See docs/spec-006-sub-relation-references.md for the wire-level walkthrough, the rare-case Check semantics (literal-match vs chain-walking), and the deferred Lookup-with-userset-results work.

Conditional Permission

SpiceDB returns CONDITIONAL_PERMISSION when a caveat reaches the Check chain but the request is missing parameter context. Check<Perm> paths surface this as a typed error so callers can distinguish recoverable failures (missing context) from hard denies:

err := folder.CheckTenantedBrowse(ctx, extsvc.CheckFolderTenantedBrowseInputs{
    User: []extsvc.User{user},
    // Caveats omitted — caller forgot to supply tenant
})

switch {
case err == nil:
    // granted

case errors.Is(err, authz.ErrConditionalPermission):
    var cpe *authz.ConditionalPermissionError
    errors.As(err, &cpe)
    // cpe.MissingKeys == ["tenant"] — fetch from request context and retry

case errors.Is(err, authz.ErrPermissionDenied):
    // hard deny — user genuinely lacks permission
}

The typed error's custom Is method matches both ErrConditionalPermission (for the rich-signal opt-in path) and ErrPermissionDenied (for backward compat with existing deny checks). Callers that only care about "denied vs. granted" keep working unchanged.

Lookup paths return a typed LookupResult partitioning definite grants from conditional grants — the same recovery hint surfaces on both Check and Lookup. Caller pattern:

result, err := folder.LookupTenantedBrowseUserSubjects(ctx, caveats)
// result.Definite — confirmed grants
// result.Conditional — partial grants; each has MissingKeys for caller to fetch and retry

for _, c := range result.Conditional {
    fetched := fetchTenantContext(c.MissingKeys)
    // retry Check / Lookup with the fetched context
}

Per-type <Type>LookupResult and <Type>ConditionalLookupEntry structs are generated once per object type and shared across every Lookup method returning that type. Wildcard subject methods (Lookup<Perm><Type>WildcardSubjects) keep their (bool, error) signature — they check result.Definite for the wildcard sentinel internally.

See docs/spec-007-conditional-permission-signal.md for the Check path, docs/spec-008-lookup-conditional-surfacing.md for the Lookup path.

Consistency

The *spicedb.Engine defaults to a time-based consistency policy: pin to AtExactSnapshot when a recent write token exists (read-your-own-writes), fall through to SpiceDB's MinimumLatency otherwise. For security-sensitive checks where stale reads are unacceptable, opt into FullyConsistent:

// Default behavior — recent-token-or-nil from the engine's time-based policy:
err := folder.CheckTenantedBrowse(ctx, input)

// Force fresh evaluation — bypasses cached snapshot:
ctx = authz.WithConsistency(ctx, authz.ConsistencyFullyConsistent)
err := folder.CheckTenantedBrowse(ctx, input)

The override is per-call via context. Caller scopes it at the request boundary; all downstream Check / Lookup / Read methods called with that ctx honor the mode automatically. Zero codegen change — ctx already flows through every generated method.

Token-based modes (AtLeastAsFresh, AtExactSnapshot with caller-supplied tokens) are deferred — the engine already uses AtExactSnapshot internally for read-your-own-writes. See docs/spec-009-consistency-mode-opt-in.md.

Schema Drift Detection

The codegen captures the source .zed bytes verbatim and emits <output-dir>/schema.gen.go with SchemaText, SchemaDigest, and a VerifySchema(ctx) helper. At startup, callers compare the binary's baseline against the deployed schema in SpiceDB and decide whether to proceed:

import authzed "github.com/danhtran94/authzed-codegen/example/authzed"

drift, err := authzed.VerifySchema(ctx)
if err != nil {
    log.Fatalf("schema verification failed: %v", err)
}
if drift.IsBreaking() {
    log.Fatalf("schema drift: %d removed, %d changed", len(drift.Removed), len(drift.Changed))
}
if !drift.IsClean() {
    log.Warnf("schema is ahead: %d added, %d cosmetic", len(drift.Added), len(drift.Cosmetic))
}

SchemaDrift partitions the typed diffs into four severity buckets:

  • Added — deployed schema has things baseline doesn't (additive, safe)
  • Removed — baseline expects things deployed lacks (breaking)
  • Changed — semantic divergence in permission / caveat expressions or caveat parameter types (breaking)
  • Cosmetic — doc comment changes only (safe)

DriftEntry.Raw exposes the typed *v1.ReflectionSchemaDiff for callers needing fine-grained handling. The package name of the generated file derives from the output dir's last segment (e.g. --output example/authzedpackage authzed).

Server-side normalisation happens in SpiceDB's DiffSchema RPC — whitespace, comment formatting, and ordering don't false-positive. See docs/spec-010-schema-drift-detection.md.

OPA Integration

The --emit-opa flag adds a per-package opa.gen.go exposing every Check<Perm> and Lookup<Perm>Resources method as an OPA custom builtin invocable from Rego policies. A policy can then mix attribute checks with SpiceDB relationship checks in one place:

authzed-codegen --output example/authzed --emit-opa example/schema.zed

Each generated file declares two registration functions:

// Per-instance — pass to rego.New(opts...). No global state.
func SpiceDBBuiltins(engine authz.Engine, ctx context.Context) []func(*rego.Rego)

// Process-global — runtime.NewRuntime and OPA's standard /v1/data endpoint
// build their compiler from the global builtin registry, so they need this
// rather than per-instance options. Call once at startup, before any
// concurrent compilation (ast.RegisterBuiltin is not concurrency-safe).
func RegisterSpiceDBBuiltinsGlobal(engine authz.Engine, ctx context.Context)

Builtin signatures, one pair per definition × permission:

<pkg>.check_<resource>_<perm>(subject, resource_id, caveat_context) -> bool
<pkg>.lookup_<resource>_<perm>_resources(subject, caveat_context) -> []string

subject is an object keyed by SpiceDB subject type, value a string id or a list of string ids — {"extsvc/user": "alice"} or {"extsvc/user": ["a","b"], "extsvc/group": ["g"]}. Check is AND across present keys (every present subject type must be granted), matching the typed Check<Perm> method; Lookup uses the first present key. caveat_context is always required; pass {} when no caveat applies. authz.ErrPermissionDenied / authz.ErrConditionalPermission map to false; any other engine error fails the Rego eval. Lookup builtins return definite results only.

A policy mixing RBAC and ReBAC:

package authz

default allow := false

# RBAC leg
granted if input.user.role == "admin"

# ReBAC leg — consult SpiceDB's relationship graph
granted if extsvc.check_folder_browse({"extsvc/user": input.user.id}, input.resource.id, {})

# deny override
deny contains msg if {
	input.user.id in data.blocklist
	msg := "blocklisted"
}

allow if {
	count(deny) == 0
	granted
}

Wire into OPA's runtime (the standard server — /v1/data, /v1/policies, /health):

extsvc.RegisterSpiceDBBuiltinsGlobal(engine, ctx) // before NewRuntime
rt, _ := runtime.NewRuntime(ctx, runtime.Params{Addrs: &[]string{":8181"}, Paths: []string{"policy"}})
rt.Serve(ctx)

Or into a plain rego.New evaluation:

opts := []func(*rego.Rego){rego.Query("data.authz.allow"), rego.Module("p.rego", policy), rego.Input(input)}
opts = append(opts, extsvc.SpiceDBBuiltins(engine, ctx)...)
rs, _ := rego.New(opts...).Eval(ctx)

example/opa-embed/ is a runnable demo: one Go binary composing SpiceDB, OPA's runtime, and the generated builtins, serving policy decisions over OPA's standard HTTP API. Run go run ./example/opa-embed and curl -X POST localhost:8181/v1/data/authz/allow -d '{"input":{...}}'.

The OPA bindings are opt-in. Without --emit-opa, codegen output is unchanged, and importing the generated package pulls in github.com/open-policy-agent/opa only when opa.gen.go is present. The generated authz.Engine works identically against embedded SpiceDB (in-process via github.com/authzed/spicedb) and remote SpiceDB — same code, swap the connection target.

See docs/RFC-001-policy-engine-integration-patterns.md for the integration framework (embedded SpiceDB, CEL bindings, OPA Rego helpers, OPA Go builtins — and which require codegen work), docs/ADR-004-opa-go-builtins.md for why OPA Go builtins, and docs/spec-013-opa-go-builtins-codegen.md for the codegen contract.

Behavior Notes

  • Permission chains. Check<Permission>Inputs exposes the full set of input types reachable through arrow expressions in referenced permissions, including cross-definition arrows. Cycles (permission p = p + q) exit non-zero with cycle detected.
  • Wildcards. Create<Rel>Relations accepts Wildcards{User: true} regardless of which permissions reference the relation. AuthZED's guidance is to grant wildcards only on read-side relations (e.g. viewer) to avoid universal write access; the codegen does not enforce this — callers own the discipline.

Relationship Cleanup

Delete<Rel>Relations revokes specific grants — you supply the subject IDs. For lifecycle cleanup (an object is gone, or a relation no longer applies) you want filter-based deletes that don't enumerate subjects first. Three generated verbs cover this:

folder := extsvc.Folder("f-1")

// One relation, every subject: "f-1 is no longer shared with anyone."
folder.PurgeViewerRelations(ctx)

// Every relation on this resource: "f-1 is deleted from our store."
folder.PurgeRelations(ctx)

// Wherever this object appears as a *subject* of another resource:
// "user u-1 left the org — strip every grant they hold."
extsvc.User("u-1").PurgeRelationsAsSubject(ctx)

Purge<Rel>Relations and PurgeRelations are emitted on every resource definition. PurgeRelationsAsSubject is emitted only on object types that appear as an allowed subject somewhere in the schema; under the hood it issues one filter-delete per referencing definition (deterministic order) and joins any errors with errors.Join — it is idempotent and best-effort, so re-run it on partial failure rather than treating it as transactional.

Why this matters — orphaned tuples are not harmless. SpiceDB stores only relationships; it has no object lifecycle. If you delete an entity in your own store but leave its tuples behind:

  • LookupResources (and LookupSubjects) keep returning the dead object as a ghost result — it satisfies the relationship graph even though the entity is gone.
  • Object-ID reuse is the sharp edge: create a new folder:f-1 and it silently inherits every grant the old f-1 ever had. Treat IDs as non-reusable, or purge on delete.

Lifecycle pattern. On object deletion, run two calls — PurgeRelations (resource-side) and, if the type is a subject anywhere, PurgeRelationsAsSubject (subject-side). They are independently idempotent, not jointly atomic; if your own delete runs inside a DB transaction, enqueue the purge as OPERATION_DELETE-style compensating work and retry until it succeeds.

For hand-written callers, the same primitive is on the runtime interface: authz.Engine.DeleteRelationsMatching(ctx, authz.RelationFilter{...}) — one transactional DeleteRelationships call against SpiceDB. An all-empty RelationFilter (which would match every tuple in the store) is rejected with authz.ErrEmptyRelationFilter; supply at least one field.

This is deliberately a set of verbs, not a workflow — there is no Delete<Type>() that cascades to children or re-parents them. The codegen emits the primitives; deciding the cleanup order for a given schema is the caller's call. See docs/scope-relationship-cleanup-verbs.md, docs/ADR-005-engine-filter-delete.md, and docs/spec-014-relationship-cleanup-codegen.md.

Verification

Round-trip the fixture (regression bar for the codegen itself):

go run ./cmd/authzed-codegen --output example/authzed example/schema.zed
git diff --quiet example/authzed/

End-to-end tests exercise the generated stubs against a real SpiceDB container via testcontainers-go. The harness lives in pkg/authz/spicedbtest/; the test packages are example/authzed/{bookingsvc,menusvc,extsvc} and pkg/authz/spicedb/.

go test ./pkg/authz/spicedb/... ./example/authzed/...

Tests skip cleanly when Docker is unavailable.

Versioning

Starting with v1.10.0, this project commits to Semantic Versioning:

  • Major (v2.0.0) — breaking changes to the Engine interface, runtime types in pkg/authz/, generated method signatures, or the codegen output structure.
  • Minor (v1.11.0+) — additive features: new generated methods, new runtime helpers, new schema constructs, new Engine methods.
  • Patch (v1.10.1) — bug fixes, doc corrections, codegen output stability fixes that don't change the typed surface.

The v1.0–v1.9 line was active development. Several minor releases included breaking changes to the Engine interface and generated method signatures (notably v1.4 changed ReadRelations return type, v1.7 changed Lookup* return types). That pattern ends at v1.10 — going forward, breaking changes require a major bump to v2.0.

A <output-dir>/schema.gen.go SchemaText constant pins the schema baseline of each binary release; pair with VerifySchema(ctx) at startup to catch deploy-time drift between the binary and the deployed SpiceDB schema. See docs/spec-010-schema-drift-detection.md.

License

MIT — see LICENSE.