Skip to content

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 of Authorizer.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/authz HTTP surface (PostAuthzCheck, ListRelationTuples, CreateRelationTuple, PatchRelationTuple, DeleteRelationTuple, PostAuthzLookupResources, PostAuthzLookupSubjects) the Authorizer backs.
  • ../../../schema/authz.zed — the SpiceDB schema text the ApplySchema driver 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.

TermDefinitionCode anchor
AuthorizerThe 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
CheckThe 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 / DeleteThe 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 / LookupSubjectsThe 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
SessionThe 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
CaveatRequestContextThe 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
AuditEntryThe 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
PrincipalMapperThe 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
SchemaApplierThe 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.

OperationRPCReturnsAudit Reason on grant / deny
CheckCheckPermissionnil (granted) or ErrPermissionDenied / ErrCaveatViolation / ErrRelationNotFoundgranted / insufficient_relation / caveat_violation
WriteWriteRelationships (TOUCH)nil on success; SpiceDB error otherwisegranted (the caller is assumed pre-authorised — write gating is the caller's responsibility)
DeleteDeleteRelationshipsnil on successgranted (same rationale as Write)
LookupResourcesserver-streaming LookupResources[]string of resource idsgranted
LookupSubjectsserver-streaming LookupSubjects[]string of subject idsgranted

Two structural details govern the read decisions:

  1. Check always enables WithTracing. SpiceDB's PERMISSIONSHIP_CONDITIONAL_PERMISSION answer means "a grant exists, but its caveat could not be evaluated". The trace lets the Authorizer walk the CheckDebugTrace tree and decide whether the caveat returned false (true caveat violation → ErrCaveatViolation) or the caveat had MISSING_EXPRESSION inputs the call site failed to supply (configuration bug → ErrCaveatViolation with a different annotation). Without the trace, the two would be indistinguishable.
  2. Read-your-writes via Session. Every Write / Delete captures the response zedtoken on the request's Session value. A follow-up Check consults the session and, when a zedtoken is present, switches the consistency mode from MinimizeLatency (the default) to AtLeastAsFresh(zedtoken). This is what makes POST /v1/authz/relations immediately followed by POST /v1/authz/check observe 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:

CaveatTuple-side parametersRequest-time inputsWire form
within_time_windowuntil (timestamp)now (RFC 3339 timestamp string)structpb timestamp
from_cidrallowed_cidrs (list of CIDR strings)client_ip (IPv4/IPv6 string)structpb ipaddress
requires_assurancerequired_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:

  1. No fabricated defaults. A request-context field that was not populated is simply omitted; the Authorizer never substitutes client_ip = "0.0.0.0" or acr = "1" to make a caveat pass. SpiceDB observes the absent field as MISSING_EXPRESSION and the WithTracing walk maps that to ErrCaveatViolation — fail-closed by design.
  2. Now is timezone-normalised. When unset, BuildCaveatContext substitutes time.Now().UTC() so every caveat in a single Check sees the same instant. The wire form is RFC 3339 nano because structpb has no native timestamp kind.
  3. Audit context redaction. RedactCaveatContext returns the list of field NAMES present in the context, never the values. The audit row records "this Check exercised client_ip and acr" but not the actual IP or claim — that mirrors the policy in ../audit/index.md where 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 stamped

The 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:

  1. 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/health does not silently bypass a hypothetical /v1/healthcare.
  2. Map principal to Subject. PrincipalMapper.Map projects the authn.Principal resolved by the authentication chain onto the ReBAC Subject string. An unauthenticated request (or a mapper error) writes the canonical 403 PermissionDenied body and records an insufficient_relation audit row stamped with the request path as the Object — the policy decision is "no resolved principal", and the audit trail records the attempt rather than leaving a silent gap.
  3. Install request context. On a permitted request, the middleware stitches together the request context with: WithSession (a fresh per-request Session for zedtoken capture), WithMapper (when the mapper implements authz.Mapper), CorrelationIDToContext (the id the platform correlation middleware resolved onto the request context, or the inbound X-Correlation-Id header, or a minted UUID when a chain runs without that middleware), WithSubject (the projected Subject), and WithAuthorizer (so per-route decorators can call Check without re-plumbing the Authorizer through handler signatures).
  4. Panic recovery. A panic in Map, encoding, or sink.Record surfaces as a best-effort 500 internal error rather 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:

FieldSourceNotes
SubjectAuthorizer argumentVerbatim ReBAC Subject string (user:<uuid>, service:<uuid>, …).
RelationAuthorizer argumentVerbatim relation name as it appears in schema/authz.zed.
ObjectAuthorizer argumentVerbatim object descriptor (domain:<uuid>, project:<uuid>, …).
ReasonAuditReasongranted / insufficient_relation / caveat_violation / relation_not_found.
RelationPathSpiceDB trace walkThe graph path the check actually traversed (e.g. ["user:42", "domain:abc#admin"]); empty on a non-tracing path.
CaveatContextRedactCaveatContextThe field NAMES present in the request context, never the values.
ZedtokenCheckPermissionResponseThe 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:

  • Metricauthz_audit_record_dropped_total, a counter labelled by the decision reason (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 alongside authz_stale_check_served_total.
  • Log — a structured WARN line ("authz audit sink dropped a decision record") carrying the reason, correlation_id, and the underlying error, 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. registerMapper panics 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. MapEvent is a thin lookup that returns ErrUnmappedEvent on 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 registerMapper call (or a new mapping_<context>.go file); it never edits MapEvent or another context's file.

Feeding the tuple sync from a new source context

To mirror a new context's events into the ReBAC graph:

  1. Add mapping_<newctx>.go under internal/authz/sync/ with an init() that calls registerMapper for every event-type literal the context emits — a real mapper for graph writes/deletes, or mapNoOp for events that carry no graph edge. The completeness gate TestEventMappers_EveryConsumedEventTypeRegisteredExactlyOnce fails until every consumed literal is registered exactly once.
  2. Because internal/authz is 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-authz depguard rule in ../../../.golangci.yml, so the events package may be imported;
    • the require directive 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).

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:

FailureDisposition
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:

SentinelPathMeaning
ErrPermissionDeniedCheck on PERMISSIONSHIP_NO_PERMISSIONGrant path exhausted, no caveat violation. The most common deny.
ErrRelationNotFoundCheck when the schema lacks the named relation on the object typeSchema drift between caller and authz.zed — surface as a 500-class error, never a 403.
ErrCaveatViolationCheck on PERMISSIONSHIP_CONDITIONAL_PERMISSION after the trace walkTuple permits the relation but the CEL caveat returned false against the request context (or had missing inputs).
ErrSchemaInvalidApplySchema on SpiceDB compile failureEmbedded schema syntax error, unknown caveat, forward reference — wrapped with %w so operators can errors.Unwrap the underlying compile diagnostic.
ErrSpiceDBAuthMixedClientConfig.Validate at bootOperator set both SPICEDB_PRESHARED_KEY and SPICEDB_MTLS_* — the two modes are mutually exclusive, and accepting both would silently pick one.
ErrCaveatContextUnsupportedValidateCaveatContextA 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