Appearance
Request authorisation — Authorizer, caveats, ApplySchema, middleware
This document is the authoritative bounded-context reference for the request-authorisation context that ships under internal/authz. It covers the ubiquitous language, the Authorizer aggregate and its five operations, the CEL caveat-context builder, the idempotent ApplySchema driver, the per-route ReBAC middleware, the audit emission contract, and the closed sentinel-error taxonomy. Direct imports of github.com/authzed/authzed-go outside this package are denied by the no-authzed-go-outside-authz depguard rule in ../../../.golangci.yml; the Authorizer is the sanctioned single entry point.
The ReBAC schema (the per-resource-type relation roster, the permission expressions, and the zedtoken consistency flow) lives in the sibling reference ../identity/rebac.md; this document is the implementation-side companion that covers how plexsphere reaches SpiceDB, not what the schema declares.
For the bounded-context siblings and upstream platform references see:
../identity/rebac.md— ReBAC schema, zedtoken flow, per-resource-type relation roster, and the dual-check invariant the Label Registry layers on top ofAuthorizer.Check.../../contributing/layout.md— the bounded-context map row that lists this context, its sub-packages, and the depguard rules that enforce cross-context isolation.../../reference/api/authz.md— the/v1/authzHTTP surface (PostAuthzCheck,ListRelationTuples,CreateRelationTuple,PatchRelationTuple,DeleteRelationTuple,PostAuthzLookupResources,PostAuthzLookupSubjects) theAuthorizerbacks.../../../schema/authz.zed— the SpiceDB schema text theApplySchemadriver installs.../../../README.md#rebac-authorisation-via-relation-tuples— top-level product narrative; this reference is the implementation-side companion to that specification.
Ubiquitous language
The terms below travel verbatim across the Go code, the audit log, the OpenAPI contract, the schema text, and operator-facing tooling. Names are preserved in error messages and structured log attributes.
| Term | Definition | Code anchor |
|---|---|---|
| Authorizer | The thin, concurrency-safe wrapper around the SpiceDB PermissionsService that every /v1 handler reaches for a ReBAC decision. Holds only immutable pointers (the gRPC service, the audit sink, the clock); safe for concurrent use. | Authorizer in ../../../internal/authz/authorizer.go |
| Check | The Boolean read decision — does subject hold relation on object under the current caveat context? Always enables WithTracing so a CONDITIONAL_PERMISSION answer can be disambiguated into a true caveat violation. | Authorizer.Check in ../../../internal/authz/authorizer.go |
| Write / Delete | The mutating relation-tuple operations. Write is upsert-shaped (TOUCH); Delete is the dual. Both pin the response zedtoken on the per-request Session so a follow-up Check reads at-least-as-fresh. | Authorizer.Write / Authorizer.Delete in ../../../internal/authz/authorizer.go |
| LookupResources / LookupSubjects | The server-streaming filter projections. Used by the cursor-paginated list endpoints to compute "every object the caller may see" and by the dashboard to populate impersonation-side filter UIs. The Authorizer wraps the stream with a Recv interface so unit tests can substitute a channel-backed fake. | Authorizer.LookupResources / Authorizer.LookupSubjects in ../../../internal/authz/authorizer.go |
| Session | The per-request value object that holds the zedtoken returned by the last mutating call. Read-your-writes consistency is achieved by having Check consult the session and, when present, send AtLeastAsFresh(zedtoken) rather than the default MinimizeLatency. | Session, NewSession, WithSession in ../../../internal/authz/zedtoken.go |
| CaveatRequestContext | The immutable value object that carries the per-request inputs the Authorizer harvests from the transport layer (Now, ClientIP, ACR, AMR, ACRFreshnessSeconds) and projects into the map[string]any SpiceDB expects as the caveat context. | CaveatRequestContext, BuildCaveatContext in ../../../internal/authz/caveats.go |
| AuditEntry | The shape every Check / Write / Delete decision records through the configured audit.Sink. Duplicated from internal/audit.Entry so this module's go.mod does not depend on the audit module; the production wiring in cmd/plexsphere adapts one to the other via a trivial type assertion. | AuditEntry, AuditReason in ../../../internal/authz/authorizer.go |
| PrincipalMapper | The transport-layer port that maps the authn.Principal resolved by the authentication middleware onto a ReBAC Subject string (user:<uuid>, service:<uuid>, group:<uuid>, …). Returning an error denies the request before any Check runs. | PrincipalMapper, DefaultMapper in ../../../internal/authz/middleware/mapper.go |
| SchemaApplier | The idempotent driver that compares the embedded schema digest against the one SpiceDB has installed and only re-writes when they disagree. Stamps a plexsphere-schema-digest: marker line so re-runs against the same bytes are no-ops. | SchemaApplier, ApplySchema in ../../../internal/authz/schema.go |
The five operations on Authorizer
Authorizer is a thin façade — every method projects one PermissionsService RPC into a typed plexsphere shape, attaches the caveat context, captures the response zedtoken on the per-request Session, and emits exactly one AuditEntry.
| Operation | RPC | Returns | Audit Reason on grant / deny |
|---|---|---|---|
Check | CheckPermission | nil (granted) or ErrPermissionDenied / ErrCaveatViolation / ErrRelationNotFound | granted / insufficient_relation / caveat_violation |
Write | WriteRelationships (TOUCH) | nil on success; SpiceDB error otherwise | granted (the caller is assumed pre-authorised — write gating is the caller's responsibility) |
Delete | DeleteRelationships | nil on success | granted (same rationale as Write) |
LookupResources | server-streaming LookupResources | []string of resource ids | granted |
LookupSubjects | server-streaming LookupSubjects | []string of subject ids | granted |
Two structural details govern the read decisions:
Checkalways enablesWithTracing. SpiceDB'sPERMISSIONSHIP_CONDITIONAL_PERMISSIONanswer means "a grant exists, but its caveat could not be evaluated". The trace lets the Authorizer walk theCheckDebugTracetree and decide whether the caveat returnedfalse(true caveat violation →ErrCaveatViolation) or the caveat hadMISSING_EXPRESSIONinputs the call site failed to supply (configuration bug →ErrCaveatViolationwith a different annotation). Without the trace, the two would be indistinguishable.- Read-your-writes via Session. Every
Write/Deletecaptures the response zedtoken on the request'sSessionvalue. A follow-upCheckconsults the session and, when a zedtoken is present, switches the consistency mode fromMinimizeLatency(the default) toAtLeastAsFresh(zedtoken). This is what makesPOST /v1/authz/relationsimmediately followed byPOST /v1/authz/checkobserve the freshly-written tuple — without the session, SpiceDB's replicated read could lag and the check would return a stale answer.
CEL caveats and the context builder
Three CEL caveats are declared in ../../../schema/authz.zed; their parameters and request-time inputs are pinned in ../../../internal/authz/caveats.go:
| Caveat | Tuple-side parameters | Request-time inputs | Wire form |
|---|---|---|---|
within_time_window | until (timestamp) | now (RFC 3339 timestamp string) | structpb timestamp |
from_cidr | allowed_cidrs (list of CIDR strings) | client_ip (IPv4/IPv6 string) | structpb ipaddress |
requires_assurance | required_acr, min_amr (list), max_age (seconds) | acr, amr (list), acr_freshness_seconds (integer) | scalar / list scalars |
BuildCaveatContext is the single function that projects a CaveatRequestContext onto the map[string]any SpiceDB expects. Three contract details are load-bearing:
- No fabricated defaults. A request-context field that was not populated is simply omitted; the Authorizer never substitutes
client_ip = "0.0.0.0"oracr = "1"to make a caveat pass. SpiceDB observes the absent field asMISSING_EXPRESSIONand theWithTracingwalk maps that toErrCaveatViolation— fail-closed by design. Nowis timezone-normalised. When unset,BuildCaveatContextsubstitutestime.Now().UTC()so every caveat in a single Check sees the same instant. The wire form isRFC 3339 nanobecause structpb has no native timestamp kind.- Audit context redaction.
RedactCaveatContextreturns the list of field NAMES present in the context, never the values. The audit row records "this Check exercisedclient_ipandacr" but not the actual IP or claim — that mirrors the policy in../audit/index.mdwhere the chain captures the relation referenced, not its inputs.
ApplySchema — idempotent boot-time schema sync
SchemaApplier.ApplySchema is the driver cmd/plexsphere calls on startup to install schema/authz.zed. It is idempotent: the function compares the SHA-256 digest of the embedded schema bytes against the // plexsphere-schema-digest: marker SpiceDB returns from ReadSchema and only rewrites when the two differ.
The state machine:
text
ReadSchema → compare digest → match?
│
┌────────┴────────┐
│ │
yes no
│ │
▼ ▼
no-op WriteSchema
│
▼
digest stampedThe SchemaApplyOutcome returned to the caller carries {Applied bool, FromDigest, ToDigest string} so an operator log can diff "what changed" without re-reading the schema. A SpiceDB rejection (syntax error, unknown caveat, forward reference) wraps through ErrSchemaInvalid with %w so the boot-time failure path preserves identity for errors.Is.
Middleware — the per-route ReBAC gate
internal/authz/middleware is the transport seam every authenticated /v1 route passes through. The middleware's job is not to call Authorizer.Check itself — the relation/object pair is route-policy data that lands in per-operation handler code — but to do four things no individual handler should redo:
- Bypass the unauthenticated leaves.
DefaultBypass()returns the canonical allow-list of pre-auth and probe paths (/v1/auth/sign-in,/v1/auth/callback,/v1/auth/device-code,/v1/auth/device-token,/v1/auth/service/token,/v1/health,/v1/version,/v1/openapi.json,/v1/register,/v1/docs,/v1/docs/assets/). An entry with a trailing slash keeps strict prefix semantics; an entry without one requires an exact match so/v1/healthdoes not silently bypass a hypothetical/v1/healthcare. - Map principal to Subject.
PrincipalMapper.Mapprojects theauthn.Principalresolved by the authentication chain onto the ReBAC Subject string. An unauthenticated request (or a mapper error) writes the canonical 403PermissionDeniedbody and records aninsufficient_relationaudit row stamped with the request path as theObject— the policy decision is "no resolved principal", and the audit trail records the attempt rather than leaving a silent gap. - Install request context. On a permitted request, the middleware stitches together the request context with:
WithSession(a fresh per-requestSessionfor zedtoken capture),WithMapper(when the mapper implementsauthz.Mapper),CorrelationIDToContext(the id the platform correlation middleware resolved onto the request context, or the inboundX-Correlation-Idheader, or a minted UUID when a chain runs without that middleware),WithSubject(the projected Subject), andWithAuthorizer(so per-route decorators can callCheckwithout re-plumbing the Authorizer through handler signatures). - Panic recovery. A panic in
Map, encoding, orsink.Recordsurfaces as a best-effort500 internal errorrather than a silent truncated response with no audit row. The deferred recover logs the panic and falls through to the standard error path.
Audit emission contract
Authorizer.Check emits exactly one audit row per call. The shape is pinned in AuditEntry:
| Field | Source | Notes |
|---|---|---|
Subject | Authorizer argument | Verbatim ReBAC Subject string (user:<uuid>, service:<uuid>, …). |
Relation | Authorizer argument | Verbatim relation name as it appears in schema/authz.zed. |
Object | Authorizer argument | Verbatim object descriptor (domain:<uuid>, project:<uuid>, …). |
Reason | AuditReason | granted / insufficient_relation / caveat_violation / relation_not_found. |
RelationPath | SpiceDB trace walk | The graph path the check actually traversed (e.g. ["user:42", "domain:abc#admin"]); empty on a non-tracing path. |
CaveatContext | RedactCaveatContext | The field NAMES present in the request context, never the values. |
Zedtoken | CheckPermissionResponse | The post-decision zedtoken; lets a downstream replay anchor to the exact graph state. |
The middleware-side denial (no resolved principal, mapper error) emits an insufficient_relation row with Object = r.URL.Path so the audit trail still records the attempt; the RelationPath is empty because no SpiceDB call ran.
Observing audit-sink drops
Recording is best-effort against the response path: a Check or Write never fails because the audit sink was momentarily unavailable. That resilience used to hide a real regression — a sink that started failing left no signal at all, silently degrading the audit trail. The Authorizer now surfaces every write failure through two channels:
- Metric —
authz_audit_record_dropped_total, a counter labelled by the decisionreason(granted,out_of_scope,insufficient_relation,caveat_violation,stale_cache). A non-zero rate is the alertable signal that the audit trail is degrading. The composition root registers it against the default Prometheus registerer, so it appears on the operator dashboard alongsideauthz_stale_check_served_total. - Log — a structured
WARNline ("authz audit sink dropped a decision record") carrying thereason,correlation_id, and the underlyingerror, so a single drop is debuggable from logs alone even when no metrics backend is wired.
The hash-chained sink that will eventually make drops impossible is a separate, later change; until then the drop is at least loud instead of silent.
Event-to-tuple mapper registration
internal/authz/sync is the single sanctioned funnel that mirrors outbox-shaped domain events into ReBAC relation tuples. Its translation layer is table-driven: a package-private registry keyed on the event-type literal maps each consumed event to a mapper function.
The registry and its MapEvent lookup live in ../../../internal/authz/sync/mapping.go, and every source context registers its own mappers from a dedicated mapping_<context>.go file — one per context that feeds the sync (mapping_tenancy.go, mapping_identity.go, mapping_mesh.go, mapping_signing.go, mapping_policy.go, mapping_provisioning.go, mapping_observability.go). Registration happens in each file's init():
go
registerMapper(string(tenancyevents.TypeProjectCreated), mapProjectCreated)
registerMapper(string(tenancyevents.TypeDomainUpdated), mapNoOp)Three invariants hold:
- One literal, one mapper.
registerMapperpanics at package init on a duplicate type literal, so a copy-paste that classifies the same event twice fails fast rather than letting the second registration silently win. - Every event is classified. A graph-relevant event registers a mapper that returns the tuple write/delete; an event whose bounded context is deliberately not mirrored registers the shared
mapNoOp.MapEventis a thin lookup that returnsErrUnmappedEventon a miss — unchanged from the switch it replaced — which the consumer treats as a programmer error and acks to avoid a livelock. - Open for extension, closed for modification. Adding an event to an existing context, or a whole new source context, adds a
registerMappercall (or a newmapping_<context>.gofile); it never editsMapEventor another context's file.
Feeding the tuple sync from a new source context
To mirror a new context's events into the ReBAC graph:
- Add
mapping_<newctx>.gounderinternal/authz/sync/with aninit()that callsregisterMapperfor every event-type literal the context emits — a real mapper for graph writes/deletes, ormapNoOpfor events that carry no graph edge. The completeness gateTestEventMappers_EveryConsumedEventTypeRegisteredExactlyOncefails until every consumed literal is registered exactly once. - Because
internal/authzis its own Go module, a context that is new to authz also touches the pre-existing cross-context lockstep surfaces (these stay untouched when the context already feeds the sync):- the allow-list entry in the
no-cross-context-imports-authzdepguard rule in../../../.golangci.yml, so the events package may be imported; - the
requiredirective in../../../internal/authz/go.mod; - the scanned-directory and count expectations in the sync test gates (the registry completeness test's directory list and the hand-maintained counts in
mapping_test.go).
- the allow-list entry in the
When the consumer gives a row up
A row that never applies is disposed of by the reason it failed, and the three reasons behave differently on purpose:
| Failure | Disposition |
|---|---|
| The payload does not map (unknown event type, malformed JSON) | Acked immediately, with a log line. MapEvent is a deterministic switch, so re-polling would livelock the outbox. |
| The dependency rejects the row (a tuple SpiceDB's schema will not accept) | Retried in-cycle, then across MaxSyncAttempts Run cycles; then sync_failed_at is stamped, the row drops out of the poll predicate, and authz_sync_outbox_surrendered_total increments. |
| The dependency is unavailable (SpiceDB down, pool exhausted, deadline crossed) | Retried without bound. The attempts counter is not touched. |
The last row is the load-bearing one. Outbox rows carry relation-tuple deletes — those deletes are access revocations — so a row given up during an outage would leave SpiceDB serving a grant the source of truth already revoked, with nothing but a log line to say so. Surrender is reserved for the row the dependency rejects on its merits; the classifier is ../../../internal/authz/sync/transient.go.
A non-zero authz_sync_outbox_surrendered_total therefore always means a row the mirror cannot represent, never a row lost to an outage. An operator who fixes the underlying mapping or schema re-arms a surrendered row by setting its sync_failed_at back to NULL.
Closed sentinel taxonomy
Every error the Authorizer or the SchemaApplier returns is exactly one of:
| Sentinel | Path | Meaning |
|---|---|---|
ErrPermissionDenied | Check on PERMISSIONSHIP_NO_PERMISSION | Grant path exhausted, no caveat violation. The most common deny. |
ErrRelationNotFound | Check when the schema lacks the named relation on the object type | Schema drift between caller and authz.zed — surface as a 500-class error, never a 403. |
ErrCaveatViolation | Check on PERMISSIONSHIP_CONDITIONAL_PERMISSION after the trace walk | Tuple permits the relation but the CEL caveat returned false against the request context (or had missing inputs). |
ErrSchemaInvalid | ApplySchema on SpiceDB compile failure | Embedded schema syntax error, unknown caveat, forward reference — wrapped with %w so operators can errors.Unwrap the underlying compile diagnostic. |
ErrSpiceDBAuthMixed | ClientConfig.Validate at boot | Operator set both SPICEDB_PRESHARED_KEY and SPICEDB_MTLS_* — the two modes are mutually exclusive, and accepting both would silently pick one. |
ErrCaveatContextUnsupported | ValidateCaveatContext | A field's Go value is not a structpb-friendly scalar (bool / string / numeric / list of those). Defends against a future caveat author accidentally passing a struct. |
Callers MUST test with errors.Is so the sentinel can be wrapped with the tuple triple (subject, relation, object) for the audit log without breaking identity on the wire.
See also
../../reference/api/authz.md— the/v1/authzHTTP surface (Check, relation-tuple CRUD, lookup) every operation in this document backs.../identity/rebac.md— ReBAC schema, the per-resource-type relation roster, the zedtoken consistency flow, theinternal/authz/syncconsumer that materialises tuples from outbox events.../../contributing/layout.md— the bounded-context map row forinternal/authz.../../../schema/authz.zed— the SpiceDB schema text theApplySchemadriver installs and the caveat declarations the context builder serialises.../../../internal/authz/— package source; thedoc.gopackage comment is the ubiquitous-language marker this reference expands on.../../../internal/authz/schema_invariants_test.go— workspace test that parsesschema/authz.zedand pins the invariants the documented behaviour relies on.