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 diallingClientConfig, NewClient, Close, preshared-key vs mTLS, keepalive, correlation id interceptors.
  • AuthorizerCheck, Write, Delete, LookupResources, LookupSubjects, Relationship, DeleteFilter.
  • Consistency and SessionConsistencyKind, Consistency, Session, Fresher, WithSession / SessionFromContext.
  • Call optionsCallOptions, Option, WithFullConsistency, WithCorrelationID, WithPage, ResolveOptions, DefaultPageSize.
  • Principal mapping and correlation idMapper, WithMapper / MapperFromContext, CorrelationIDToContext / CorrelationIDFromContext.
  • Caveat contextCaveatRequestContext, BuildCaveatContext, CaveatContextFieldNames, RedactCaveatContext, ValidateCaveatContext, caveat name constants.
  • Schema applicationSchemaApplier, ApplySchema, SchemaApplyOutcome.
  • Audit contractAuditEntry, AuditReason.
  • Error taxonomyErrPermissionDenied, 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.
PresharedKeystringDev-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 / MTLSCAPathstringClient cert, private key, and CA bundle for the mTLS posture. Either all three are set or all three are empty.
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.

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

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 == trueConsistency{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 .

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.

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.
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