Skip to content

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:

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 ​

FieldTypeDefaultPurpose
Endpointstring— (REQUIRED)SpiceDB gRPC target, host:port.
PresharedKeystring—Dev-mode bearer token matched by SpiceDB's --grpc-preshared-key. Mutually exclusive with every MTLS* field.
PresharedKeyInsecureboolfalseDial without TLS (kind + testcontainers fixture only). Production preshared-key deployments MUST leave this false.
MTLSCertPath / MTLSKeyPath / MTLSCAPathstring—Client cert, private key, and CA bundle for the mTLS posture. Either all three are set or all three are empty.
TLSCAPathstring""PEM bundle that replaces the system roots when the preshared-key posture dials over TLS; empty keeps the system roots. Populated at boot from SPICEDB_TLS_CA_PATH; NewClient reads the file once, so a rotated CA takes a process restart. Belongs to the preshared-key posture alone.
KeepaliveTimetime.Duration30s (DefaultKeepaliveTime)gRPC keepalive PING interval. Matches SpiceDB's server min_time.
KeepaliveTimeouttime.Duration10s (DefaultKeepaliveTimeout)gRPC keepalive ACK timeout.
ExtraDialOptions[]grpc.DialOptionnilAppended 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:

  • Endpoint required,
  • exactly one of PresharedKey / MTLS* set (mixed posture returns ErrSpiceDBAuthMixed),
  • when the mTLS posture is chosen, all three mTLS paths must be populated,
  • TLSCAPath is empty in the mTLS posture, which reads MTLSCAPath for the same trust root,
  • TLSCAPath is empty when PresharedKeyInsecure is true, because a plaintext dial verifies no certificate.

The two TLSCAPath invariants return a plain error rather than a sentinel, so the message is the contract: authz: ClientConfig.TLSCAPath belongs to the preshared-key posture; the mTLS posture reads MTLSCAPath and authz: ClientConfig.TLSCAPath must be empty when PresharedKeyInsecure is true. bootstrap.SpiceDBConfig.Validate refuses the same two combinations with the same wording under a bootstrap: prefix, so the envvar parser and the client agree before either dials.

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.

NewClient also sets the channel's reconnect backoff: the first retry after one second, each later one 1.6 times the previous with 20 % jitter, capped at DefaultReconnectMaxDelay (5 s), and 20 s (DefaultMinConnectTimeout, gRPC's own default) for one connection attempt. gRPC's own cap is 120 s. With the lower cap a replica reconnects within about six seconds of a SpiceDB outage ending, and an API waiting for SpiceDB at boot notices it as soon.

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 ​

ValueKindMeaning
authz.MinimizeLatencyConsistencyKind = 1Lowest-latency cached revision. Used when no session zedtoken is captured and the caller did not set WithFullConsistency.
authz.AtLeastAsFreshConsistencyKind = 2Evaluate at or after the captured Session.Token(). Used for read-your-writes after a Write / Delete in the same request.
authz.FullyConsistentConsistencyKind = 3Evaluate 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):

  1. opts.FullConsistency == true → Consistency{Kind: FullyConsistent}
  2. s.Token() != "" → Consistency{Kind: AtLeastAsFresh, Zedtoken: …}
  3. otherwise → Consistency{Kind: MinimizeLatency}

Call options ​

SymbolKindPurpose
CallOptionsstructDe-normalised per-call config. FullConsistency, CorrelationID, PageSize.
Optionfunc(*CallOptions)Functional-option signature every Authorizer method accepts.
WithFullConsistency()OptionForce FullyConsistent posture regardless of the session zedtoken.
WithCorrelationID(id)OptionStamp audit rows with the inbound correlation id.
WithPage(n)OptionOverride the cursor page size; non-positive falls back to DefaultPageSize.
ResolveOptions(opts...)CallOptionsCanonical fold that every Authorizer method uses — re-use in tests that assert resolved defaults.
DefaultPageSizeconst = 200The 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 nil

Correlation 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 unset

The 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 constantCEL functionRequest-context keysTuple-side keys
authz.CaveatWithinTimeWindowwithin_time_windownow (RFC3339Nano string)until
authz.CaveatFromCIDRfrom_cidrclient_ipallowed_cidrs
authz.CaveatRequiresAssurancerequires_assuranceacr, amr, acr_freshness_secondsrequired_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:

FieldMeaning
CurrentDigestApplier'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.
DesiredDigestHex SHA-256 of the payload the caller passed in.
Appliedtrue when a WriteSchema call fired; false on digest match.
WrittenAtZedtoken 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 .

While SpiceDB does not answer, the boot waits for it. bootstrap.RegisterSchemaApplierProbe takes a bootstrap.SchemaApplyWait and runs each boot attempt under its own deadline (DefaultSchemaApplyAttemptTimeout, 10 s). An attempt that fails with authz.ErrUnavailable, which ApplySchema returns for a gRPC Unavailable or DeadlineExceeded answer, or that runs into its own deadline, is logged at INFO as waiting for SpiceDB before applying the authorization schema and retried after a pause that starts at DefaultSchemaApplyInitialBackoff (0.5 s) and doubles up to DefaultSchemaApplyMaxBackoff (5 s). Only the caller's context ends the wait. Meanwhile the API answers /livez and /readyz and refuses every other request; see Boot in the failure-modes runbook.

Every other ApplySchema error aborts boot: a rejected credential (Unauthenticated or PermissionDenied) or a schema SpiceDB refuses (ErrSchemaInvalid). Run returns the wrapped error and runMain exits with code 1 (cmd/plexsphere/main.go), so the API never serves its routes with a half-applied authz schema. Once it serves them, 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.

FieldTypeWritten byNotes
SubjectstringCheck / Write / Delete"type:id" or "type:id#relation".
RelationstringCheck / Write / DeleteRelation name on the resource.
ObjectstringCheck / Write / Delete"type:id".
ReasonAuditReasonCheck / Write / DeleteSee enum below.
RelationPath[]string(reserved — populated by a future debug-trace backfill)Empty until then.
CaveatContext[]stringCheck / WriteField names only.
CorrelationIDstringCheck / Write / DeleteInbound request id when one was plumbed via WithCorrelationID.
ZedtokenstringCheck / Write / DeleteThe zedtoken attached to the SpiceDB response.
Timestamptime.TimeCheck / Write / DeleteWall-clock instant, UTC.

AuditReason enum (ordinals stay locked to internal/audit.Reason):

ConstantOrdinalWhen emitted
AuditReasonGranted1Check observed HAS_PERMISSION; Write and Delete always emit under this label (administrative mutations, not decisions).
AuditReasonOutOfScope2Check observed NO_PERMISSION and the caller supplied no caveat context — the heuristic for "no binding at all".
AuditReasonInsufficientRelation3Check observed NO_PERMISSION and the caller supplied caveat context — the heuristic for "binding exists under a different relation".
AuditReasonCaveatViolation4Check 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 ​

SentinelWhen returnedSurface as
ErrPermissionDeniedSpiceDB answered PERMISSIONSHIP_NO_PERMISSION.403 to the caller (application/problem+json).
ErrRelationNotFoundCheck referenced a relation absent from the object type.500 — this is schema drift between caller and authz.zed, not an authz outcome.
ErrCaveatViolationGraph permitted but a CEL caveat evaluated false (or required a missing field).403 with reason: caveat_violation.
ErrSchemaInvalidSchemaApplier.ApplySchema received a SpiceDB InvalidArgument reply. Wraps the underlying compile error with %w.Operator-visible error on the boot log; bootstrap fails closed.
ErrUnavailableSpiceDB did not answer an Authorizer call or SchemaApplier.ApplySchema: a gRPC Unavailable or DeadlineExceeded status, or a bare context.DeadlineExceeded. Wraps the underlying error with %w.503 with problem code authz_unavailable on a request; at boot the schema apply waits and retries.
ErrSpiceDBAuthMixedBoot-time: ClientConfig or the envvar parser saw both a preshared key AND any mTLS field.Bootstrap aborts before dialling.
ErrCaveatContextUnsupportedValidateCaveatContext 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 ​

SymbolSignaturePurposeError contract
Consumer.Runfunc (c *Consumer) Run(ctx context.Context) errorDrives 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.Stopfunc (c *Consumer) Stop(ctx context.Context) errorBlocks 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.
MapEventfunc 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.

ConstantTypeMeaning
defaultBatchSizeint = 64Fallback BatchSize when ConsumerConfig.BatchSize is zero or negative. Bounds each PollUnappliedBatch call.
pollIntervalMintime.Duration = 250msLower bound of the idle-poll sleep AND the post-partial-batch sleep. Establishes the tight-loop floor when there is little or no work.
pollIntervalMaxtime.Duration = 5sUpper bound of the geometric idle-poll backoff. An idle consumer parks at 5 s rather than synthesising hidden minute-long quiet windows.
retryBackoffMintime.Duration = 250msInitial per-row retry sleep after a dispatch failure.
retryBackoffMaxtime.Duration = 30sCap 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.
maxRetriesPerRowint = 10Hard 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).
defaultStopWaitGractime.Duration = 10sDocumented 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 ​