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 inbound X-Correlation-Id or a minted rebac-<nanos> fallback), 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.

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