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 inboundX-Correlation-Idor a mintedrebac-<nanos>fallback),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.
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.