| Field | Value |
|---|---|
| Status | Accepted |
| Created | 2026-05-10 |
| Author | Danh Tran |
| Implements | docs/scope-opa-go-builtins-codegen.md |
This SPEC operationalises the codegen feature that emits per-package opa.gen.go files containing OPA custom-builtin registrations for every generated Check<Perm> and Lookup<Perm>Resources method. The codegen is opt-in via a new --emit-opa CLI flag. Builtins follow a uniform shape — 3-arg (subj string, res string, ctx object) → bool for Check and 2-arg (subj string, ctx object) → []string for Lookup — with caveat context always required at the call site (callers pass {} when no caveat applies). The closure body converts the Rego context map to *structpb.Struct via structpb.NewStruct(m) (per A1), dispatches to the typed Check / Check-with-caveat or Lookup variant, and maps the typed return to OPA's ast.Term shape (per A2).
What this component does: Add --emit-opa flag to cmd/authzed-codegen/main.go. Add OPATemplate []byte embed in internal/templates/embed.go backed by a new internal/templates/opa.go.tmpl. Add Generator.GenerateOPASource(...) method in internal/generator/generator.go that produces one opa.gen.go per package after the existing per-namespace generation. Generated file declares func RegisterSpiceDBBuiltins(r *rego.Rego, engine authz.Engine, ctx context.Context) plus internal helpers termToStructpb and astValueToInterface. Per-method closures in RegisterSpiceDBBuiltins register one rego.Function3 per Check method and one rego.Function2 per Lookup method — alphabetically sorted for deterministic output. Engine errors propagate as types.NewErr (Rego eval fails — distinguishable from policy false denial). Caveat-context conversion uniformly handles all AUZ-018 caveat parameter types via structpb; SpiceDB's server-side CEL evaluator coerces structpb fields to caveat parameter types at evaluation time (per A3). Round-trip regenerates byte-identically; --emit-opa off leaves existing output unchanged.
What this component does not do: Add codegen for Create<Rel>Relations (write side effects don't fit Rego). Add separate _with_context variant builtins (Shape A from scope) — uniform 3-arg / 2-arg shape replaces it. Generate per-caveat typed marshalling code — structpb.NewStruct(m) is the universal conversion path. Wire the OPA HTTP server via runtime.NewRuntime — caller's domain. Configure decision logs / bundle distribution / Discovery. Surface LookupResult.Conditional entries in Rego — Lookup builtins return Definite only with a Go doc comment naming the limitation. Add build-tag opt-in inside generated files — --emit-opa is the opt-in mechanism. Modify pkg/authz/ runtime contract — generated bindings call existing Engine methods unchanged. Modify internal/generator/adapter.go — caveat-aware variant detection already exists per AUZ-007.
New flag added to the existing flag.NewFlagSet:
emitOPA := fs.Bool("emit-opa", false, "emit opa.gen.go per package with OPA custom-builtin registrations for Check/Lookup methods (adds opa/rego runtime dep on consumers)")After the existing per-namespace generation loop (Generator.GenerateObjectSource(...)), invoke:
if *emitOPA {
for _, pkg := range generatedPackages {
if err := g.GenerateOPASource(string(templates.OPATemplate), pkg); err != nil {
return fmt.Errorf("generate OPA source for %s: %w", pkg.Namespace, err)
}
}
}Where generatedPackages is the existing per-package collection — caveat awareness travels in the same *DefinitionView already used for the per-namespace generation.
Add a sibling embed declaration to the existing ObjectTemplate:
//go:embed opa.go.tmpl
var OPATemplate []byte// GenerateOPASource emits opa.gen.go for the given package. The package
// must already be adapted (DefinitionViews resolved) by AdaptDefinitions
// + GenerateObjectSource. The generated file imports the package's own
// types (User, Folder, etc.) and the authz.Engine interface; it does not
// duplicate type definitions.
//
// The output is sorted by (Resource, Permission) name pairs for
// deterministic round-trip behavior (per C1).
func (g *Generator) GenerateOPASource(templateText string, pkg *PackageView) errorWhere *PackageView is the existing per-package aggregation already used by the per-namespace generation. No new resolver work; the template walks the existing data.
Package-scoped exports:
// Package <pkg> generated by authzed-codegen — DO NOT EDIT.
package <pkg>
import (
"context"
"fmt"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/types"
"google.golang.org/protobuf/types/known/structpb"
"<module>/pkg/authz"
)
// RegisterSpiceDBBuiltins registers SpiceDB-backed Rego builtins on r.
//
// Builtin signatures:
// <pkg>.check_<resource>_<perm>(subject_id, resource_id, caveat_context) -> bool
// <pkg>.lookup_<resource>_<perm>_resources(subject_id, caveat_context) -> []string
//
// Pass {} for caveat_context when no caveat applies. Engine errors
// propagate as types.NewErr (Rego eval fails) — distinct from policy
// denial. Lookup builtins return LookupResult.Definite only;
// Conditional entries are not surfaced to Rego.
//
// The closure captures engine and ctx by reference. Recreate the
// registration per request OR commit to a long-lived ctx with
// cancellation. The codegen does not pick the pattern; the SPEC's
// Sequence section documents both.
func RegisterSpiceDBBuiltins(r *rego.Rego, engine authz.Engine, ctx context.Context)As-shipped naming (AUZ-019 / AUZ-021): the function above was sketched
during spec-writing before the rego.New initialization order was known. As
implemented, the generated file declares two exported functions instead:
// Per-instance — pass to rego.New(opts...). Returns options; no global state.
// (AUZ-019; see jobs/AUZ-019 Discoveries for why this replaced the mutate-r form.)
func SpiceDBBuiltins(engine authz.Engine, ctx context.Context) []func(*rego.Rego)
// Process-global — registers into ast.Builtins + the topdown map via
// rego.RegisterBuiltin2/3. This is what runtime.NewRuntime / the standard
// /v1/data endpoint pick up (they have no hook for per-instance options).
// Call ONCE at startup before any concurrent compilation — ast.RegisterBuiltin
// is not concurrency-safe; double-calling appends duplicate ast.Builtins entries.
// (AUZ-021.)
func RegisterSpiceDBBuiltinsGlobal(engine authz.Engine, ctx context.Context)Both forms use the same per-Check / per-Lookup decl + closure fragments
(factored into shared {{ define }} template blocks); only the registration
mechanism differs.
Subject argument — keyed object (AUZ-022, supersedes the AUZ-019 "type:id" string):
the first builtin argument is an object keyed by SpiceDB subject type, value
a single id string or a list of id strings:
extsvc.check_folder_browse({"extsvc/user": "alice"}, "demo-folder", {})
extsvc.check_folder_browse({"extsvc/user": ["a","b"], "extsvc/group": ["g"]}, "demo-folder", {})The argument is typed as a fully-dynamic object — types.NewObject(nil, types.NewDynamicProperty(types.S, types.NewAny(types.S, types.NewArray(nil, types.S)))). (Static per-permission key sets were tried and rejected: OPA's
static object properties are effectively required-all, so declaring the
allowed-type keys would force a caller to pass every type, breaking the
common single-key call. A bogus subject type therefore isn't caught at
policy-compile time — it surfaces as a runtime error from SpiceDB's
CheckPermission.) The value type is compile-time checked: a non-string,
non-array value fails the OPA type checker.
Check semantics across multiple present keys: AND — every present
subject-type key must be granted, matching the generated typed Check<X>
method (which loops over non-empty subject slices and returns false on the
first denial). For the common single-key call this is just one check.
Lookup uses the first present key (sorted namespace order), matching the
typed Lookup<Perm><Resource>Resources method.
The parseSubject / errMalformedSubject helpers from the string-form
draft are gone; the object is parsed by subjectEntries (returns
namespace-sorted []subjectEntry{Namespace, []authz.ID}) and idsFromTerm
(string or array of strings → []authz.ID).
Internal helpers (unexported, file-local):
// termToStructpb converts an OPA ast.Term holding an Object to a
// *structpb.Struct. Returns (nil, nil) for empty objects — the caller
// dispatches to the no-caveat Engine path when this is nil. Returns a
// non-nil error if the term is not an Object or contains values not
// representable in structpb (e.g. non-string keys, sets).
func termToStructpb(t *ast.Term) (*structpb.Struct, error)
// astValueToInterface walks an ast.Value and produces the Go any
// equivalent suitable for structpb.NewStruct. Coverage:
// ast.Null -> nil
// ast.Boolean -> bool
// ast.Number -> int64 if integral; float64 otherwise
// ast.String -> string
// *ast.Array -> []any (recursive)
// ast.Object -> map[string]any (recursive; non-string keys dropped)
// ast.Set -> error (sets cannot cross to structpb)
func astValueToInterface(v ast.Value) (any, error)Per-method closure shape (one per Check method; one per Lookup method):
// Check<Resource><Permission> binding — registered alphabetically per (Resource, Permission)
rego.Function3(
®o.Function{
Name: "<pkg>.check_<resource>_<perm>",
Decl: types.NewFunction(
types.Args(
types.S,
types.S,
types.NewObject(nil, types.NewDynamicProperty(types.S, types.A)),
),
types.B,
),
},
func(_ rego.BuiltinContext, subjTerm, resTerm, ctxTerm *ast.Term) (*ast.Term, error) {
subjID, ok := subjTerm.Value.(ast.String)
if !ok {
return nil, types.NewErr("expected subject_id string, got %T", subjTerm.Value)
}
resID, ok := resTerm.Value.(ast.String)
if !ok {
return nil, types.NewErr("expected resource_id string, got %T", resTerm.Value)
}
caveatCtx, err := termToStructpb(ctxTerm)
if err != nil {
return nil, types.NewErr("invalid caveat_context: %v", err)
}
var granted bool
if caveatCtx == nil {
granted, err = engine.Check<Resource><Permission>(ctx,
<SubjectType>{ID: string(subjID)},
<ResourceType>{ID: string(resID)})
} else {
granted, err = engine.Check<Resource><Permission>WithCaveat(ctx,
<SubjectType>{ID: string(subjID)},
<ResourceType>{ID: string(resID)},
caveatCtx)
}
if err != nil {
return nil, types.NewErr("engine.Check<Resource><Permission>: %v", err)
}
return ast.BooleanTerm(granted), nil
},
)(r)Lookup closure shape:
rego.Function2(
®o.Function{
Name: "<pkg>.lookup_<resource>_<perm>_resources",
Decl: types.NewFunction(
types.Args(
types.S,
types.NewObject(nil, types.NewDynamicProperty(types.S, types.A)),
),
types.NewArray(nil, types.S),
),
},
func(_ rego.BuiltinContext, subjTerm, ctxTerm *ast.Term) (*ast.Term, error) {
subjID, ok := subjTerm.Value.(ast.String)
if !ok {
return nil, types.NewErr("expected subject_id string, got %T", subjTerm.Value)
}
caveatCtx, err := termToStructpb(ctxTerm)
if err != nil {
return nil, types.NewErr("invalid caveat_context: %v", err)
}
var result authz.LookupResult
if caveatCtx == nil {
result, err = engine.Lookup<Resource><Permission>Resources(ctx,
<SubjectType>{ID: string(subjID)})
} else {
result, err = engine.Lookup<Resource><Permission>ResourcesWithCaveat(ctx,
<SubjectType>{ID: string(subjID)},
caveatCtx)
}
if err != nil {
return nil, types.NewErr("engine.Lookup<Resource><Permission>Resources: %v", err)
}
// result.Definite only; result.Conditional dropped per scope Out of Scope item 8
ids := make([]*ast.Term, len(result.Definite))
for i, id := range result.Definite {
ids[i] = ast.StringTerm(string(id))
}
return ast.ArrayTerm(ids...), nil
},
)(r)No changes. Caveat-applicability data is already preserved on each *DefinitionView per AUZ-007 / AUZ-018. The template determines whether to emit the WithCaveat dispatch branch by walking the existing view.
No changes. Generated bindings call engine.Check<X> / engine.Check<X>WithCaveat / engine.Lookup<X>Resources / engine.Lookup<X>ResourcesWithCaveat through the existing typed interface (per A4).
The caveat-context object passed from Rego maps through structpb to SpiceDB's CEL caveat parameter types as follows. SpiceDB's CEL evaluator handles per-type coercion server-side (per A3); the codegen does not generate per-caveat marshalling.
| Rego value | OPA ast.Value |
Go intermediate | structpb stored as | SpiceDB CEL caveat param types accepted |
|---|---|---|---|---|
true / false |
ast.Boolean |
bool |
BoolValue |
bool |
Integer 42 |
ast.Number |
int64 |
NumberValue (lossless for ≤ 2^53) |
int, uint, double |
Float 3.14 |
ast.Number |
float64 |
NumberValue |
double, int (truncates), uint (truncates) |
"hello" |
ast.String |
string |
StringValue |
string, bytes (base64-decoded), duration, timestamp, ipaddress |
null |
ast.Null |
nil |
NullValue |
any nullable; CEL caveat param treated as absent |
[1, 2, 3] |
*ast.Array |
[]any |
ListValue |
list<T> (CEL coerces elements per T) |
{"k": "v"} |
ast.Object |
map[string]any |
Struct (recursive) |
map<K, V> |
Set {1, 2} |
ast.Set |
error | — | unrepresentable; binding returns types.NewErr |
Caveat parameter types from AUZ-018 — duration, timestamp, ipaddress — accept Rego strings: "1h30m", "2026-05-10T14:30:00Z", "10.0.0.0/24" respectively. SpiceDB's CEL evaluator parses these at evaluation time. The codegen emits no special handling for these types; structpb stores them as StringValue and SpiceDB does the rest.
authz.LookupResult {
Definite []ID ← extracted as []string
Conditional []LookupConditionalEntry ← dropped (per C5)
}
The Lookup builtin returns Definite as a Rego []string. Conditional entries are dropped silently with a Go doc comment naming the limitation on each Lookup binding.
Per-request lifecycle of one Rego eval that invokes a generated builtin:
Caller goroutine
│
▼
r := rego.New(rego.Query(...), rego.Module(...))
<pkg>.RegisterSpiceDBBuiltins(r, engine, ctx) ◀ closure captures engine + ctx
prepared, _ := r.PrepareForEval(ctx)
result, _ := prepared.Eval(ctx, rego.EvalInput(...))
│
▼
Rego compiler resolves builtin names against registered set
│
▼
Rego runtime invokes registered closure for `<pkg>.check_X_Y(...)`
│ args: subjTerm (ast.Term), resTerm (ast.Term), ctxTerm (ast.Term)
▼
Closure:
1. subjTerm.Value.(ast.String) → string subject_id
2. resTerm.Value.(ast.String) → string resource_id
3. termToStructpb(ctxTerm) → *structpb.Struct or nil
│
▼
Dispatch:
caveatCtx == nil → engine.Check<X><Y>(captured_ctx, ...) ──┐
caveatCtx != nil → engine.Check<X><Y>WithCaveat(...) ──┤
│
▼ │
Engine performs gRPC Check via existing client ◀─────────────┘
│
▼
Engine returns (bool, error)
│
▼
Closure mapping:
err != nil → return nil, types.NewErr(...) ◀ Rego eval fails
err == nil → return ast.BooleanTerm(granted), nil
│
▼
Rego runtime continues policy evaluation with the bool result
Two valid context-propagation patterns the caller chooses:
Pattern P1 — Per-request registration (default for the e2e test, SC9):
Caller request → new rego.Rego → RegisterSpiceDBBuiltins(r, engine, requestCtx) → Eval → discard
Advantage: requestCtx is fresh; deadlines and tracing propagate. Cost: registration overhead on every request (~100µs for ~30 builtins per package).
Pattern P2 — Long-lived registration:
Server start → rego.Rego → RegisterSpiceDBBuiltins(r, engine, serverCtx) → reuse for many Eval calls
Advantage: amortized registration cost across requests. Cost: serverCtx must outlive every Eval; cancellation propagates to all in-flight Evals.
| Error class | Trigger | Caller recovery |
|---|---|---|
types.NewErr("expected subject_id string, got %T") |
Rego call passes non-string for subject_id | Caller fixes Rego policy — pass a string |
types.NewErr("expected resource_id string, got %T") |
Rego call passes non-string for resource_id (Check builtins only) | Caller fixes Rego policy |
types.NewErr("invalid caveat_context: <reason>") |
termToStructpb returns error — non-Object term, set value, non-string key |
Caller fixes Rego policy — pass an object literal {} |
types.NewErr("engine.Check<X>: <wrapped>") |
Engine call fails — gRPC error, SpiceDB error, context cancellation | Caller distinguishes from policy-denial-as-bool by checking eval error; retry / log / fail open per app policy |
types.NewErr("engine.Lookup<X>Resources: <wrapped>") |
Engine Lookup call fails | Same as Check |
Engine errors do not silently convert to false (per C4). A Rego policy reading the builtin's result sees either a bool result OR the eval fails with a typed error. This preserves the distinction between "policy correctly denies" and "system couldn't evaluate the policy."
-
C1 — Deterministic output ordering. Builtins are registered in the generated
RegisterSpiceDBBuiltinsbody sorted alphabetically by(ResourceTypeName, PermissionName)for Check methods, then sorted alphabetically by the same pair for Lookup methods. Round-trip regression (scope SC7) requires byte-identical regeneration; non-deterministic ordering breaks this immediately. -
C2 — Public-API-only OPA imports. Generated
opa.gen.goand the codegen template import only fromgithub.com/open-policy-agent/opa/{ast,rego,types}andgoogle.golang.org/protobuf/types/known/structpb. Nointernal/package import — those are not stable API surface. SpiceDB upgrades that changeinternal/are not contract-affecting (per A5). -
C3 — Uniform structpb conversion. All caveat-context conversion goes through
termToStructpb+structpb.NewStruct. The codegen does not emit per-caveat-type marshalling (e.g. no special path for duration / timestamp / ipaddress). SpiceDB's CEL evaluator handles type coercion server-side per A3. Implication: a Rego policy passing the wrong shape (e.g. integer when string expected) fails at SpiceDB's caveat eval, not at the binding boundary. The error surfaces astypes.NewErr("engine.Check<X>: caveat evaluation: ..."). -
C4 — Engine errors propagate as
types.NewErr. A failing Engine call never returnsast.BooleanTerm(false)from the closure. The Rego eval fails; callers reading the eval result must check for runtime errors separately from policy result. Documented inRegisterSpiceDBBuiltinsGo doc. -
C5 — Lookup returns
Definiteonly. Each Lookup binding emits a Go doc comment stating "returns LookupResult.Definite as []string; Conditional entries are dropped — caveat-aware Lookup with conditional surfacing is out of scope per scope-opa-go-builtins-codegen.md Out of Scope item 8." -
C6 — OPA version pinned in
go.mod. The repo'sgo.modpinsgithub.com/open-policy-agent/opaat a specific version (named in the implementation job's Discoveries). Round-trip determinism (C1) depends on stable OPA AST/term layout; version drift surfaces as a dependency-upgrade PR, not a silent codegen diff. -
C7 —
--emit-opais the only opt-in switch. Without the flag,cmd/authzed-codegenproduces the same output as the current commit (scope SC8). No build tags inside generated.gofiles. No environment variables. No config file. The flag is the contract for "I want OPA bindings." -
C8 —
RegisterSpiceDBBuiltinsGlobalmust be called once at startup, before concurrent compilation (AUZ-021). It mutates OPA's process-global registry viaast.RegisterBuiltin, which is not concurrency-safe (OPA's source warns of "concurrent map read/write panics"). Calling it more than once appends duplicate entries toast.Builtins. Callers usingrego.Newdirectly should prefer the per-instanceSpiceDBBuiltins() []func(*rego.Rego)form, which has no global state; the global form exists specifically becauseruntime.NewRuntime(and the OPA server's/v1/dataendpoint) build their compiler at construction time from the global builtin universe and have no hook for per-instance options. -
C9 — subject-object key iteration is deterministic (AUZ-022).
subjectEntriessorts the parsed(namespace, ids)pairs by namespace before returning, because OPA'sast.Objectiteration order is not stable. This makes the Check AND short-circuit order and the Lookup "first present key" choice reproducible — and keeps the generatedopa.gen.gobyte-identical across regenerations (C1).
(none)
Two design choices were considered and resolved in this SPEC:
- OPA version target — resolved by C6 (pinned in
go.mod; specific version recorded in the implementation job's Discoveries; version drift acceptable as long as C2's listed APIs remain stable). - Empty-object detection in
termToStructpb— resolved by returning(nil, nil)only forobj.Len() == 0. Objects containing null values ({"k": null}) are non-empty and pass through to structpb; SpiceDB's CEL evaluator interprets null as "field absent" — filtering at the binding boundary would mask caller intent.
-
A1 [VERIFIED]:
google.golang.org/protobuf/types/known/structpb.NewStruct(map[string]any) (*Struct, error)acceptsbool,int*,uint*,float*,string,[]byte,[]any,map[string]any,nil. Evidence: stdlib documentation at https://pkg.go.dev/google.golang.org/protobuf/types/known/structpb#NewStruct; existing usage inpkg/authz/spicedb/crud.goper AUZ-007 confirms. -
A2 [EXTERNAL FACT]: OPA's
github.com/open-policy-agent/opa/rego.Function3(andFunction1,Function2) registration takes a*rego.Functiondefinition withDecl *types.Functionand a closurefunc(rego.BuiltinContext, *ast.Term, *ast.Term, *ast.Term) (*ast.Term, error). Evidence: package documentation at https://pkg.go.dev/github.com/open-policy-agent/opa/rego. -
A3 [VERIFIED]: SpiceDB's CEL caveat evaluator coerces structpb fields to caveat parameter types at evaluation time — duration / timestamp / ipaddress accept string forms; numeric types coerce between int / uint / double. Evidence:
pkg/caveats/types/{basic,ipaddress,custom}.goingithub.com/authzed/spicedb@v1.52.0register CEL converters per type; AUZ-018 caveat parameter expansion exercises duration / timestamp / ipaddress fixtures end-to-end. -
A4 [VERIFIED]:
pkg/authz/EngineexposesCheck<X>(ctx, subj, res) (bool, error),Check<X>WithCaveat(ctx, subj, res, *structpb.Struct) (bool, error),Lookup<X>Resources(ctx, subj) (LookupResult, error),Lookup<X>ResourcesWithCaveat(ctx, subj, *structpb.Struct) (LookupResult, error)for every generated Resource × Permission pair. Evidence: existing generated<entity>.gen.gofiles inexample/authzed/{bookingsvc,menusvc,extsvc}/after AUZ-007 (caveat-aware variants) and AUZ-013 (LookupResult shape). -
A5 [HYPOTHESIS]: OPA library upgrades within the v1.x major version preserve the public API listed in C2 (
ast,rego,typespackages — specific symbols enumerated). Verification deferred — confirm at implementation time by inspecting the OPA changelog between the pinned version and the latest. If a public API breaks, C2 lists the affected symbols; the SPEC needs revision, not the upgrade. -
A6 [VERIFIED]:
internal/templates/embed.godeclares each embedded template explicitly via//go:embed <name>.tmpl+ a siblingvar <Name>Template []byte. Evidence:ObjectTemplateandSchemaTemplatefollow this pattern;OPATemplatematches without restructuring the embed setup.