Appearance
internal/authz
internal/authz is the sole sanctioned surface for SpiceDB-backed authorisation. Every bounded context that needs a permission check, a relation write, or a cursor walk of resources / subjects calls through this package; direct imports of github.com/authzed/authzed-go/** from any other path are rejected by the no-authzed-go-outside-authz depguard rule in .golangci.yml.
This document is the authoritative reference for:
- Client configuration and dialling —
ClientConfig,NewClient,Close, preshared-key vs mTLS, keepalive, correlation id interceptors. - Authorizer —
Check,Write,Delete,LookupResources,LookupSubjects,Relationship,DeleteFilter. - Consistency and Session —
ConsistencyKind,Consistency,Session,Fresher,WithSession/SessionFromContext. - Call options —
CallOptions,Option,WithFullConsistency,WithCorrelationID,WithPage,ResolveOptions,DefaultPageSize. - Principal mapping and correlation id —
Mapper,WithMapper/MapperFromContext,CorrelationIDToContext/CorrelationIDFromContext. - Caveat context —
CaveatRequestContext,BuildCaveatContext,CaveatContextFieldNames,RedactCaveatContext,ValidateCaveatContext, caveat name constants. - Schema application —
SchemaApplier,ApplySchema,SchemaApplyOutcome. - Audit contract —
AuditEntry,AuditReason. - Error taxonomy —
ErrPermissionDenied,ErrRelationNotFound,ErrCaveatViolation,ErrSchemaInvalid,ErrSpiceDBAuthMixed,ErrCaveatContextUnsupported.
See also:
docs/contexts/identity/rebac.md— bounded-context explanation: schema walk-through, hierarchy derivations, zedtoken consistency flow, caveat-context table, audit contract, authentication posture.docs/how-to/authorization/apply-the-rebac-schema.md— operator runbook for applying ReBAC schema changes, including thezed validateCI gate and rollback posture.docs/architecture/storage-topology.md— SpiceDB and Postgres wiring.docs/reference/platform/db.md— core pgx pool and SpiceDB migration bootstrap (0001_spicedb_bootstrap.sql).deploy/local/README.md— dev kind deployment of SpiceDB (preshared-key posture).
Client
authz.Client wraps the authzed.Client returned by the authzed-go library. It owns the underlying gRPC connection and exposes the two typed accessors bounded contexts need: Permissions() for the Authorizer seam and Schema() for the SchemaApplier seam. The depguard rule confines authzed-go imports to this package, so neither the raw *authzed.Client nor the generated v1 proto types ever cross the package boundary.
ClientConfig
| Field | Type | Default | Purpose |
|---|---|---|---|
Endpoint | string | — (REQUIRED) | SpiceDB gRPC target, host:port. |
PresharedKey | string | — | Dev-mode bearer token matched by SpiceDB's --grpc-preshared-key. Mutually exclusive with every MTLS* field. |
PresharedKeyInsecure | bool | false | Dial without TLS (kind + testcontainers fixture only). Production preshared-key deployments MUST leave this false. |
MTLSCertPath / MTLSKeyPath / MTLSCAPath | string | — | Client cert, private key, and CA bundle for the mTLS posture. Either all three are set or all three are empty. |
KeepaliveTime | time.Duration | 30s (DefaultKeepaliveTime) | gRPC keepalive PING interval. Matches SpiceDB's server min_time. |
KeepaliveTimeout | time.Duration | 10s (DefaultKeepaliveTimeout) | gRPC keepalive ACK timeout. |
ExtraDialOptions | []grpc.DialOption | nil | Appended to the computed dial options only for bufconn injection in tests. Production code paths MUST NOT populate this field. |
ClientConfig.Validate() enforces the posture invariant:
Endpointrequired,- exactly one of
PresharedKey/MTLS*set (mixed posture returnsErrSpiceDBAuthMixed), - when the mTLS posture is chosen, all three mTLS paths must be populated.
NewClient and Close
go
ctx := context.Background()
cfg := authz.ClientConfig{
Endpoint: "spicedb:50051",
PresharedKey: os.Getenv("SPICEDB_PRESHARED_KEY"),
}
client, err := authz.NewClient(ctx, cfg)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
authorizer, err := authz.NewAuthorizer(client.Permissions(), auditSink)
if err != nil {
return err
}
schemaApplier := client.Schema()NewClient installs a unary and a stream interceptor that copy authz.CorrelationIDFromContext(ctx) onto the outgoing gRPC metadata under the x-correlation-id key. A context without a captured correlation id produces a no-op interceptor pass — the client never synthesises an id.
Close releases the underlying gRPC connection. It is safe to call multiple times; the second call returns nil.
Authorizer
authz.Authorizer is the primary ReBAC decision surface. It holds a narrowed permissionsService interface and an audit sink; both are supplied at construction time and every method is safe for concurrent use.
go
authorizer, err := authz.NewAuthorizer(client.Permissions(), auditSink)Check
go
err := authorizer.Check(
ctx,
"user:01HW...", // subject ("type:id" or "type:id#relation")
"reader", // relation ("view", "member", "admin", …)
"resource:01JK...", // object ("type:id")
map[string]any{ // caveat context (optional)
"now": time.Now().UTC().Format(time.RFC3339Nano),
"client_ip": "10.1.2.3",
},
authz.WithFullConsistency(),
)
switch {
case errors.Is(err, authz.ErrPermissionDenied):
return http.StatusForbidden
case errors.Is(err, authz.ErrCaveatViolation):
return http.StatusForbidden
case errors.Is(err, authz.ErrRelationNotFound):
// Schema drift — surface as 500, not 403.
return http.StatusInternalServerError
case err != nil:
return http.StatusInternalServerError
default:
return http.StatusOK
}Check writes one AuditEntry per call — granted, denied, or caveat violation — and captures the returned zedtoken into the context-scoped Session so subsequent reads in the same request see read-your-writes consistency. Caveat-context values are never persisted in the audit row; only the field names are .
Write and Delete
go
zed, err := authorizer.Write(ctx, []authz.Relationship{
{
Subject: "user:01HW...",
Relation: "member",
Object: "group:eng",
},
{
Subject: "user:01HW...",
Relation: "reader",
Object: "project:ops",
CaveatName: authz.CaveatWithinTimeWindow,
CaveatContext: map[string]any{"until": "2026-12-31T00:00:00Z"},
},
})
zed, err = authorizer.Delete(ctx, authz.DeleteFilter{
ResourceType: "project",
ResourceID: "ops",
Relation: "reader",
SubjectType: "user",
SubjectID: "01HW...",
})Write is atomic across the batch — SpiceDB applies every tuple or none. An empty rels slice is rejected so a silent no-op cannot downgrade the session's consistency posture. One AuditEntry is emitted per relationship under AuditReasonGranted because a Write is an administrative mutation, not a decision.
DeleteFilter requires ResourceType; a filter with only OptionalResourceID would allow a wholesale wipe. The other fields narrow the match. Both calls capture the returned zedtoken into the request-scoped Session.
LookupResources and LookupSubjects
go
ids, err := authorizer.LookupResources(
ctx,
"user:01HW...",
"reader",
"project",
nil, // caveat context (optional)
authz.WithPage(500), // override DefaultPageSize
)
subjects, err := authorizer.LookupSubjects(
ctx,
"user", // subject type to enumerate
"admin",
"domain:acme",
nil,
)Both calls walk SpiceDB's cursor pagination transparently and return a single []string of object / subject ids. An infinite-loop guard bails out after 10 000 pages so a SpiceDB cursor bug cannot hang the caller. Ordering is SpiceDB's insertion order — sort at the call site if lexicographic order is required.
WithPage(n) overrides the default page size (DefaultPageSize = 200). Non-positive sizes are tolerated — they fall back to the default rather than producing a zero-sized loop.
Consistency and Session
| Value | Kind | Meaning |
|---|---|---|
authz.MinimizeLatency | ConsistencyKind = 1 | Lowest-latency cached revision. Used when no session zedtoken is captured and the caller did not set WithFullConsistency. |
authz.AtLeastAsFresh | ConsistencyKind = 2 | Evaluate at or after the captured Session.Token(). Used for read-your-writes after a Write / Delete in the same request. |
authz.FullyConsistent | ConsistencyKind = 3 | Evaluate at the latest committed SpiceDB revision. Used when the caller passes authz.WithFullConsistency() — typically UI list endpoints that must not show a stale read after a user-visible write. |
Session
authz.Session is a lock-guarded zedtoken holder. The middleware creates one per request, stores it via authz.WithSession, and the Authorizer recovers it through authz.SessionFromContext on every call. Write and Delete Capture the returned zedtoken; Check does too, so a check that issues a fresh zedtoken also pins later reads to at-least-as-fresh consistency.
go
session := authz.NewSession()
ctx = authz.WithSession(ctx, session)
_, _ = authorizer.Write(ctx, rels) // captures zedtoken into session
_ = authorizer.Check(ctx, subj, rel, obj, nil) // reads at AtLeastAsFresh(token)Fresher
Fresher(s *Session, opts CallOptions) Consistency is pure — no network, no mutation — so it is unit-testable without a SpiceDB stub. Resolution order (first match wins):
opts.FullConsistency == true→Consistency{Kind: FullyConsistent}s.Token() != ""→Consistency{Kind: AtLeastAsFresh, Zedtoken: …}- otherwise →
Consistency{Kind: MinimizeLatency}
Call options
| Symbol | Kind | Purpose |
|---|---|---|
CallOptions | struct | De-normalised per-call config. FullConsistency, CorrelationID, PageSize. |
Option | func(*CallOptions) | Functional-option signature every Authorizer method accepts. |
WithFullConsistency() | Option | Force FullyConsistent posture regardless of the session zedtoken. |
WithCorrelationID(id) | Option | Stamp audit rows with the inbound correlation id. |
WithPage(n) | Option | Override the cursor page size; non-positive falls back to DefaultPageSize. |
ResolveOptions(opts...) | CallOptions | Canonical fold that every Authorizer method uses — re-use in tests that assert resolved defaults. |
DefaultPageSize | const = 200 | The LookupResources / LookupSubjects fallback page size. |
Principal mapping and correlation id
The authz package MUST NOT import internal/identity — the dependency direction is identity → authz, never the reverse. The Mapper interface is the indirection that keeps the contract clean: the transport layer implements Mapper in internal/authz/middleware, wires it into ctx via WithMapper, and per-operation handlers recover it via MapperFromContext .
go
type Mapper interface {
Subject(ctx context.Context, principal any) (subject, relation string, err error)
}
ctx = authz.WithMapper(ctx, middlewareMapper)
m := authz.MapperFromContext(ctx) // may return nilCorrelation id helpers plumb the inbound request id through the same ctx:
go
ctx = authz.CorrelationIDToContext(ctx, r.Header.Get("X-Correlation-Id"))
id := authz.CorrelationIDFromContext(ctx) // "" when unsetThe gRPC client interceptors installed by NewClient pick up that id and attach it as the x-correlation-id metadata key on every outgoing SpiceDB RPC — unary and server-streaming.
Caveat context
The three CEL caveats shipped in schema/authz.zed consume the request-time context the Authorizer harvests from the transport layer. CaveatRequestContext is the value object the middleware fills; BuildCaveatContext encodes it into the map[string]any shape Check / LookupResources / LookupSubjects forward to SpiceDB.
| Caveat name constant | CEL function | Request-context keys | Tuple-side keys |
|---|---|---|---|
authz.CaveatWithinTimeWindow | within_time_window | now (RFC3339Nano string) | until |
authz.CaveatFromCIDR | from_cidr | client_ip | allowed_cidrs |
authz.CaveatRequiresAssurance | requires_assurance | acr, amr, acr_freshness_seconds | required_acr, min_amr, max_age |
go
caveatCtx := authz.BuildCaveatContext(authz.CaveatRequestContext{
Now: time.Now().UTC(),
ClientIP: "10.1.2.3",
ACR: "urn:mace:incommon:iap:silver",
AMR: []string{"mfa", "otp"},
ACRFreshnessSeconds: 45,
})
if err := authz.ValidateCaveatContext(caveatCtx); err != nil {
return err // ErrCaveatContextUnsupported wrapped with the offending field
}Fields not populated on CaveatRequestContext are omitted from the result rather than substituted with a zero value. A caveat that references an absent field evaluates under SpiceDB's CONDITIONAL_PERMISSION posture and the Authorizer maps that to ErrCaveatViolation — fail closed.
Audit redaction
CaveatContextFieldNames(m) returns the sorted field names; the Authorizer writes those into AuditEntry.CaveatContext. Values are never persisted. Diagnostic log lines that need the full shape can call RedactCaveatContext(m) to get a copy with every value replaced by "<redacted>".
Schema application
authz.SchemaApplier is the boot-time surface that reconciles the embedded schema/authz.zed payload against the SpiceDB cluster. It is idempotent: the applier SHA-256-hashes the desired payload and compares it against a digest marker comment (// plexsphere-schema-digest: <hex>) prepended to the schema on the previous WriteSchema call. The marker survives SpiceDB's canonical reformat (caveats lifted above definitions, caveat parameters alphabetised) intact, so a warm boot reads the marker back and short-circuits without another write. A legacy schema without a marker falls back to normalised-SHA-256 comparison against the echoed text.
go
applier := client.Schema()
outcome, err := applier.ApplySchema(ctx, schemaBytes)
if err != nil {
if errors.Is(err, authz.ErrSchemaInvalid) {
// SpiceDB rejected the payload (syntax, unknown caveat, forward ref).
return err
}
return err
}
logger.Info("authz schema reconciled",
slog.String("current_digest", outcome.CurrentDigest),
slog.String("desired_digest", outcome.DesiredDigest),
slog.Bool("applied", outcome.Applied),
slog.String("written_at", outcome.WrittenAt),
)SchemaApplyOutcome:
| Field | Meaning |
|---|---|
CurrentDigest | Applier's view of the digest already in SpiceDB: the embedded digest marker when present, else the normalised SHA-256 of the echoed schema. Empty on a fresh cluster. |
DesiredDigest | Hex SHA-256 of the payload the caller passed in. |
Applied | true when a WriteSchema call fired; false on digest match. |
WrittenAt | Zedtoken returned by SpiceDB when Applied == true; empty otherwise. |
A zero-length schemaText is rejected before the gRPC boundary so a misconfigured bootstrap cannot silently blank SpiceDB's schema. See docs/how-to/authorization/apply-the-rebac-schema.md for the operator runbook.
Schema-applier on boot
Production boot calls SchemaApplier.ApplySchema between PG migrations and the bootstrap-tokens reconciler. The wiring lives in cmd/plexsphere/app.go (search for ApplySchema): a synchronous boot-time write that is registered as the spicedb-schema-apply /readyz probe so subsequent ticks re-run the reconciliation against the live cluster.
The applier is idempotent across reboots: the SHA-256 digest of the embedded schema/authz.zed payload is compared against the // plexsphere-schema-digest: <hex> marker the previous WriteSchema prepended to the schema, so a re-boot with an unchanged schema short- circuits without another write. This makes the apply call safe to run on every container start without thrashing SpiceDB .
Failure mode: an ApplySchema error aborts boot — Run returns the wrapped error and runMain exits with code 1 (cmd/plexsphere/main.go). The binary refuses to bind its HTTP listener, so a half-applied authz schema cannot serve traffic. Once the listener is up, a later apply failure flips /readyz to 503 via the registered probe instead of crashing the process — operators see the regression on the readiness gate.
See docs/contexts/identity/rebac.md#production-wiring for the bounded-context narrative of the boot order.
Audit contract
authz.AuditEntry mirrors internal/audit.Entry one-for-one. The duplication is deliberate: the audit package owns the wire shape (frozen by the audit context), and this package carries a local copy so the authz module's go.mod does not pull the audit module's transitive graph or invite an import cycle.
| Field | Type | Written by | Notes |
|---|---|---|---|
Subject | string | Check / Write / Delete | "type:id" or "type:id#relation". |
Relation | string | Check / Write / Delete | Relation name on the resource. |
Object | string | Check / Write / Delete | "type:id". |
Reason | AuditReason | Check / Write / Delete | See enum below. |
RelationPath | []string | (reserved — populated by a future debug-trace backfill) | Empty until then. |
CaveatContext | []string | Check / Write | Field names only. |
CorrelationID | string | Check / Write / Delete | Inbound request id when one was plumbed via WithCorrelationID. |
Zedtoken | string | Check / Write / Delete | The zedtoken attached to the SpiceDB response. |
Timestamp | time.Time | Check / Write / Delete | Wall-clock instant, UTC. |
AuditReason enum (ordinals stay locked to internal/audit.Reason):
| Constant | Ordinal | When emitted |
|---|---|---|
AuditReasonGranted | 1 | Check observed HAS_PERMISSION; Write and Delete always emit under this label (administrative mutations, not decisions). |
AuditReasonOutOfScope | 2 | Check observed NO_PERMISSION and the caller supplied no caveat context — the heuristic for "no binding at all". |
AuditReasonInsufficientRelation | 3 | Check observed NO_PERMISSION and the caller supplied caveat context — the heuristic for "binding exists under a different relation". |
AuditReasonCaveatViolation | 4 | Check observed CONDITIONAL_PERMISSION — the graph permits but a CEL caveat returned false. |
The Granted vs Out-of-scope / Insufficient-relation distinction on NO_PERMISSION is a heuristic backed by the caveat-context shape. A future audit workstream replaces it with a DEBUG_TRACE-driven classifier that inspects the actual relation path; until then the heuristic keeps audit rows usable without blocking the bootstrap. The decision block in internal/authz/authorizer.go's classifyDenial is the source of truth.
Error taxonomy
| Sentinel | When returned | Surface as |
|---|---|---|
ErrPermissionDenied | SpiceDB answered PERMISSIONSHIP_NO_PERMISSION. | 403 to the caller (application/problem+json). |
ErrRelationNotFound | Check referenced a relation absent from the object type. | 500 — this is schema drift between caller and authz.zed, not an authz outcome. |
ErrCaveatViolation | Graph permitted but a CEL caveat evaluated false (or required a missing field). | 403 with reason: caveat_violation. |
ErrSchemaInvalid | SchemaApplier.ApplySchema received a SpiceDB InvalidArgument reply. Wraps the underlying compile error with %w. | Operator-visible error on the boot log; bootstrap fails closed. |
ErrSpiceDBAuthMixed | Boot-time: ClientConfig or the envvar parser saw both a preshared key AND any mTLS field. | Bootstrap aborts before dialling. |
ErrCaveatContextUnsupported | ValidateCaveatContext saw a field with a type structpb cannot encode (channel, func, non-map[string]any struct, …). | 400 when triggered by caller input; 500 when the offending value is server-side. |
Every sentinel's error message is tagged with (PX-0011, REQ-xxx) so a grep 'PX-0011' across the logs surfaces every authz-caused failure in one sweep.
Outbox dual-write sync
The package at internal/authz/sync/ is the only writer to SpiceDB in production. It consumes the plexsphere.outbox_events Postgres table, translates each row into []authz.Relationship writes plus []authz.DeleteFilter deletes via MapEvent, and applies them through the production *authz.Authorizer. Retries are idempotent: SpiceDB Write is TOUCH-semantic, so a row re-picked up after a partial failure converges to the same tuple state without duplicating audit fan-out. See docs/contexts/identity/rebac.md#dual-write-contract and docs/contexts/identity/rebac.md#event-to-tuple-mapping for the contextual narrative.
API reference
| Symbol | Signature | Purpose | Error contract |
|---|---|---|---|
Consumer.Run | func (c *Consumer) Run(ctx context.Context) error | Drives the poll loop until ctx is cancelled. Polls Scanner.PollUnappliedBatch, dispatches each row through mapOutboxRow and applyOnce, and acks success via Scanner.MarkApplied. Cadence adapts to load (immediate re-poll on full batch, geometric idle backoff up to pollIntervalMax). | Returns ctx.Err() on cancellation. Per-row dispatch failures are retried up to maxRetriesPerRow times with exponential backoff and ±25% jitter; an exhausted row is logged at ERROR and left applied_at IS NULL for the next cycle. Unmapped or undecodable payloads are logged and ACKed to avoid livelock. |
Consumer.Stop | func (c *Consumer) Stop(ctx context.Context) error | Blocks until the Run goroutine has exited or ctx deadline elapses. Caller is expected to cancel the Run-side ctx FIRST and then call Stop to await the in-flight batch drain. Safe to call multiple times. | Returns nil once Run has exited; returns ctx.Err() if the supplied ctx fires first. |
MapEvent | func MapEvent(e Event) (writes []authz.Relationship, deletes []authz.DeleteFilter, err error) | Pure translation layer: dispatches on Event.Type and returns the SpiceDB tuple writes/deletes that mirror the aggregate change. Both slices may be empty (no-op event); ResourceMoved is the only case that returns both. No I/O — safe to unit-test without a SpiceDB stub. | Returns ErrUnmappedEvent for an unrecognised Event.Type. Returns a wrapped error tagged (REQ-PX-0036-008, PX-0036) when a required payload field is missing or has the wrong type, or when principal_kind is outside the {user, service_identity, group} enum. |
The Authorizer and Scanner interfaces declared in consumer.go are intentionally narrow so unit tests substitute in-memory fakes; the production *authz.Authorizer and the scanner_pg.go adapter satisfy them without bespoke shims.
Dual-write SLA constants
The following const block in internal/authz/sync/consumer.go governs cadence and retry envelopes. The constants are unexported by design — the consumer is the only call site — but they pin the visibility-latency and back-pressure SLA the rest of the platform relies on.
| Constant | Type | Meaning |
|---|---|---|
defaultBatchSize | int = 64 | Fallback BatchSize when ConsumerConfig.BatchSize is zero or negative. Bounds each PollUnappliedBatch call. |
pollIntervalMin | time.Duration = 250ms | Lower bound of the idle-poll sleep AND the post-partial-batch sleep. Establishes the tight-loop floor when there is little or no work. |
pollIntervalMax | time.Duration = 5s | Upper bound of the geometric idle-poll backoff. An idle consumer parks at 5 s rather than synthesising hidden minute-long quiet windows. |
retryBackoffMin | time.Duration = 250ms | Initial per-row retry sleep after a dispatch failure. |
retryBackoffMax | time.Duration = 30s | Cap on per-row exponential retry backoff. The 250 ms→500 ms→1 s→2 s→4 s→8 s→16 s→30 s schedule the spec calls out is a direct consequence of doubling from retryBackoffMin. |
maxRetriesPerRow | int = 10 | Hard ceiling on per-row retry attempts. An exhausted row is logged at ERROR and surrendered to the next Run cycle (the applied_at IS NULL filter re-picks it up). |
defaultStopWaitGrac | time.Duration = 10s | Documented default grace window for Stop callers. (Constant is currently unused inside the package — Stop honours the caller-supplied ctx — and exists as a single source of truth for the operator-facing default.) |
Cross-references
docs/contexts/identity/rebac.md— bounded-context explanation (schema, zedtoken, caveats, audit, auth posture).docs/how-to/authorization/apply-the-rebac-schema.md— operator runbook for schema changes.docs/architecture/storage-topology.md— SpiceDB↔Postgres wiring.docs/reference/platform/db.md— shared Postgres pool and SpiceDB bootstrap migration.schema/authz.zed— canonical ReBAC schema.internal/authz/doc.go— package doc.internal/authz/sync/— outbox dual-write consumer + event-to-tuple mapping.docs/contexts/identity/rebac.md#production-wiring— boot-order narrative for the SpiceDB schema apply.docs/contexts/identity/rebac.md#dual-write-contract— dual-write contract between Postgres outbox and SpiceDB.docs/contexts/identity/rebac.md#event-to-tuple-mapping— domain-event → SpiceDB tuple translation table.