Skip to content

Identity & ReBAC — SpiceDB schema, zedtoken consistency, audit contract

This document is the authoritative bounded-context reference for the ReBAC authorisation layer that ships under internal/authz, internal/authz/middleware, internal/audit, and the canonical schema at schema/authz.zed. It covers the schema walk-through, the cross-hierarchy permission derivations, the zedtoken consistency flow that gives every inbound request read-your-writes semantics, the three CEL caveats the Authorizer feeds, the audit contract that freezes the wire shape for the hash-chained audit sink, and the two authentication postures (dev preshared-key vs production mTLS). The invariant-to-test matrix at the bottom pins every requirement to at least one automated test.

For the bounded-context siblings see tenancy.md (Domain, Project, Resource, Node aggregates under internal/identity/tenancy), groups.md (Group, GroupMembership, GroupParent aggregates under internal/identity/groups), and idp.md (per-Domain IdP binding, User, UserSession, ServiceIdentity, APIToken aggregates under internal/identity/{idp,users,services,tokens,authn}). For the package-level API reference see ../../reference/platform/authz.md; for the operator runbook covering schema changes see ../../how-to/authorization/apply-the-rebac-schema.md. For the SpiceDB↔Postgres topology see ../../architecture/storage-topology.md.

Doc grouping vs. package layout. This page lives under docs/contexts/identity/ because ReBAC is a facet of the identity domain — it gates access to identity-owned aggregates and consumes the user / serviceaccount subjects the identity context produces. The code, however, lives in its own bounded-context package at ../../../internal/authz/ — distinct from internal/identity/ and enforced as a separate seam by the no-cross-context-imports depguard rule in ../../../.golangci.yml. When this doc says "the Authorizer" it means a type in internal/authz/, not internal/identity/; do not look for internal/identity/rebac/.

Schema walk-through

The canonical ReBAC schema lives at schema/authz.zed. It is applied idempotently on core-binary startup by authz.SchemaApplier.ApplySchema (see ../../reference/platform/authz.md) and validated in CI by zed validate (the make authz-lint target). The schema carries ten object definitions grouped into three families plus three CEL caveats.

Subjects (2 definitions)

DefinitionRoleRelations / permissionsSubject-id shape
userHuman principal resolved by the authn layer.parent: domain; read = parent->readtenancy.ID of the identity record.
serviceaccountMachine principal resolved by the authn layer (API tokens, SPIFFE SVIDs, OIDC client-credentials).parent: domain; read = parent->readtenancy.ID of the service identity record.

Each subject carries one parent: domain relation plus read = parent->read. The parent edge backs the per-Domain identities listing (GET /v1/domains/{id}/identities): the handler issues Authz.Check(caller, "read", "user:<uuid>") per row, which resolves to the owning Domain's read, so any principal with domain:<id>#read observes the cohort. The tuple is written by the authz/sync MapEvent arms for identity.UserProvisioned / identity.ServiceIdentityProvisioned (see Event-to-tuple mapping) and by cmd/plexsphere-bootstrap's admin-seed pass; without it the listing returned empty for every caller. internal/authz/middleware.PrincipalMapper maps an authn Principal into a subject triple at the transport boundary so the authz package never imports internal/identity.

The canonical wire form is produced by authn.Subject(principal) (internal/identity/authn/subject.go): KindUser → user:<uuid>, KindService → serviceaccount:<uuid>, KindAPIToken → apitoken:<uuid>, KindUnknown → unknown:<uuid>. Every per-aggregate transport package wraps this helper from its local helpers.go so the canonical form is a single drift seam; bypassing it surfaces as is not of the form type:id in the authz parser (the audit-side regression that drove the hoist is captured in ../audit/threat-model.md).

Grouping primitive (1 definition)

DefinitionParentRelations
groupdomain (exactly one; cross-Domain nesting is rejected by the schema).parent: domain, member: user | serviceaccount | group#member

A group-kind Membership (group:ops#member) expands at SpiceDB Check time — the Authorizer never enumerates members, SpiceDB walks the userset graph. The groups.Resolver contract from groups.md feeds this expansion via the outbox-driven SpiceDB write path.

Tenancy hierarchy (3 definitions)

domain → project → resource is the cross-hierarchy derivation spine. Each child definition carries a parent relation whose only permitted type is the tier above, so Domain-admin rights flow transparently down through Project and Resource without an explicit tuple on every leaf. See the Hierarchy derivations table below for the full relation → permission map.

Privileged object types (4 definitions)

DefinitionParentPrivileged relationInherits from parent?
secretdomainassignerNo — only explicit grants.
clouddomainoperatorNo — explicit grants only.
cloudcredentialcloudassignerNo — no derivation from cloud.operator.
blueprintdomainpublisherNo — explicit grants only.

The "no derivation" column is the escalation-control contract: a Domain admin cannot silently escalate themselves into arbitrary secret assignment or blueprint publication. The manage permission on every privileged type is owner only; the assign / operate / publish surfaces take explicit additive grants on the narrower relation.

Label registry (1 definition)

labeldefinition models the Label Registry entries the labels bounded context owns. The schema enforces the "labels never grant permissions" invariant structurally: no relation anywhere in schema/authz.zed lists labeldefinition (or any labeldefinition#… userset) as a permitted subject type. The internal/authz/schema_invariants_test.go test parses the schema via SpiceDB's compiler API and fails loudly if a future edit introduces such a subject.

CEL caveats (3)

CaveatRequest-time inputsTuple-time parametersPurpose
within_time_windownow (timestamp)until (timestamp)Expiring grants; step-down by clock.
from_cidrclient_ip (ipaddress)allowed_cidrs (list<string>)Network-bounded grants (bastion IP, VPN egress).
requires_assuranceacr (string), amr (list<string>), acr_freshness_seconds (int)required_acr (string), min_amr (list<string>), max_age (int)Step-up authentication for sensitive actions.

See the Caveat-context table below for the exact field-name mapping the Authorizer writes into the caveat context at request time, and the Audit contract for how caveat field NAMES (not values) reach the audit row.

Hierarchy derivations table

Every permission derivation the schema carries is listed below. Read each row as "the listed permission on the listed object is granted when the left-hand union matches, where -> denotes a lookup via the parent relation into the parent definition".

ObjectPermissionDerivation
userreadparent->read
serviceaccountreadparent->read
domainmanageowner + admin
domainreadowner + admin + auditor + member
projectmanageadmin + parent->manage
projectdeployadmin + maintainer + parent->manage
projectactadmin + maintainer + operator + parent->manage
projectobserveadmin + maintainer + operator + viewer + parent->read
resourcemanageowner + maintainer + parent->manage
resourceactowner + maintainer + operator + parent->act
resourceobserveowner + maintainer + operator + viewer + parent->observe
secretmanageowner (no parent derivation)
secretassignowner + assigner (no parent derivation)
secretreadowner + assigner + reader (no parent derivation)
cloudmanageowner (no parent derivation)
cloudoperateowner + operator (no parent derivation)
cloudobserveowner + operator + auditor (no parent derivation)
cloudcredentialmanageowner (no parent derivation)
cloudcredentialassignowner + assigner (no parent derivation)
cloudcredentialreadowner + assigner + reader (no parent derivation)
blueprintmanageowner (no parent derivation)
blueprintpublishowner + publisher (no parent derivation)
blueprintreadowner + publisher + reader (no parent derivation)
labeldefinitionmanageowner (no parent derivation)
labeldefinitionassignowner + assigner (no parent derivation)
labeldefinitionreadowner + assigner + reader (no parent derivation)

The two derivation chains worth reading aloud:

  • Domain admin → resource manage. A tuple user:alice -[admin]-> domain:acme gives alice resource#manage on every Resource whose Project's parent is domain:acme, via resource.manage = … + parent->manageproject.manage = … + parent->managedomain.manage = admin. This is the chainsaw e2e scenario at tests/e2e/identity/rebac-hierarchy/chainsaw-test.yaml .
  • Privileged types stop inheritance. A user:alice -[admin]-> domain:acme tuple does NOT grant secret#assign on Secrets under acme. The secret.assign derivation is owner + assigner with no parent->… term — by design, so a Domain admin cannot silently exfiltrate credentials. Operators that want Domain-wide secret assignment attach an explicit domain.membersecret.assigner tuple per Secret, or use a Group subject.

Zedtoken consistency flow

SpiceDB is eventually consistent by default — a read after a write may land on a replica that has not yet observed the write. The Authorizer gives every inbound request read-your-writes semantics by threading a per-request authz.Session that captures zedtokens on every mutation and forwards the latest one as AtLeastAsFresh on every subsequent read.

text
  inbound HTTP request                                                  SpiceDB
  (authn → authz middleware → handler)                                  cluster
      |                                                                    |
      |  session := authz.NewSession()                                     |
      |  ctx     := authz.WithSession(ctx, session)                        |
      |                                                                    |
      |--- handler calls Authorizer.Write(ctx, rels…) ------------------->|
      |                                                                   |
      |<---- WriteRelationshipsResponse { WrittenAt: zedtoken_W } ---------|
      |                                                                    |
      |  session.Capture(zedtoken_W)                                       |
      |                                                                    |
      |--- handler calls Authorizer.Check(ctx, …) ---------------.        |
      |                                                          |        |
      |   Fresher(session, opts) picks AtLeastAsFresh(zedtoken_W)|        |
      |                                                          v        |
      |   CheckPermissionRequest {                                        |
      |     Consistency: AtLeastAsFresh(zedtoken_W), …  }---------------->|
      |                                                                   |
      |<---- CheckPermissionResponse { CheckedAt: zedtoken_C } <-----------|
      |                                                                    |
      |  session.Capture(zedtoken_C) // zedtoken monotonicity              |
      |                                                                    |
      |--- Authorizer.LookupResources(ctx, …) (same session) ----.        |
      |                                                          |        |
      |   Fresher still returns AtLeastAsFresh(zedtoken_C)       |        |
      |                                                          v        |
      |   LookupResourcesRequest { Consistency: AtLeastAsFresh(…)}------->|
      |                                                                   |
      |<---- stream of LookupResourcesResponse ---------------------------|

Resolution order inside authz.Fresher:

  1. opts.FullConsistency (set via authz.WithFullConsistency()) → FullyConsistent. Zedtoken is ignored. Use for UI list endpoints that MUST NOT show a stale view.
  2. session.Token() != ""AtLeastAsFresh(session.Token()). This is the read-your-writes path.
  3. otherwise → MinimizeLatency. The common default for read-only endpoints that did not perform a mutation in the same request.

Session lifecycle

The middleware in internal/authz/middleware/rebac.go creates one *authz.Session per request, stores it with authz.WithSession, and lets the handler share it with every Authorizer call via ctx. Session.Capture is safe for concurrent use — a write-under-lock guarantees torn reads are impossible — and an empty zedtoken is intentionally ignored so a no-op Write does not clear the session.

Cross-request monotonicity

There is no cross-request zedtoken sharing. Each inbound request starts with a fresh Session. Two parallel requests from the same client never pollute each other's consistency posture; a client that wants "observe after write" across requests must carry the zedtoken out-of-band (e.g. in a dedicated response header) — this context does not ship that plumbing. Dashboard list endpoints rely on WithFullConsistency() where "no stale view" is a UX requirement.

Caveat-context table

Every caveat field SpiceDB evaluates is either populated by the Authorizer from the request context, or written with the tuple at Write time. The split is worth freezing in one table because the audit contract names the field names — never the values — and the split determines which side of the contract a given field belongs to.

CaveatFieldWritten bySourceNotes
within_time_windownowAuthorizertime.Now().UTC() (or CaveatRequestContext.Now when populated)RFC3339Nano string; SpiceDB's CEL timestamp type.
within_time_windowuntilTupleCaller-supplied expiry on the Write.Tuple-side expiry — the grant naturally lapses.
from_cidrclient_ipAuthorizerCaveatRequestContext.ClientIP (extracted from X-Forwarded-For by the transport policy).Empty → caveat fails closed.
from_cidrallowed_cidrsTupleCaller-supplied list of prefixes.The caveat returns true when client_ip sits in any prefix.
requires_assuranceacrAuthorizerOIDC acr claim from the session.Empty → caveat fails closed.
requires_assuranceamrAuthorizerOIDC amr claim from the session.Nil → caveat fails closed.
requires_assuranceacr_freshness_secondsAuthorizerSeconds since the last acr re-auth event.Negative → caveat fails closed.
requires_assurancerequired_acrTupleCaller-supplied minimum acr.Tuple-side policy bar.
requires_assurancemin_amrTupleCaller-supplied required subset of amr.Tuple-side policy bar.
requires_assurancemax_ageTupleCaller-supplied maximum freshness in seconds.Tuple-side policy bar.

The authz.BuildCaveatContext helper assembles the Authorizer-written fields from a CaveatRequestContext. Fields the request did not populate are omitted from the context map; SpiceDB treats an absent field as "caveat depends on missing context" and returns CONDITIONAL_PERMISSION, which the Authorizer maps to ErrCaveatViolation with the missing field NAMES attached to the audit row.

Fail-closed semantics

The Authorizer never fabricates a default to make a caveat pass. A request missing client_ip under a from_cidr grant yields CONDITIONAL_PERMISSIONErrCaveatViolation → 403 problem+json with reason: "caveat_violation" and the missing field names in caveat_context. Extending a caveat to a new request-context field means teaching the Authorizer to populate it; silently defaulting it is a bug.

Audit contract

The internal/audit.Entry value object is the frozen wire shape every authorisation decision (grant and denial) produces. The follow-on hash-chained audit sink will swap the audit.NewSlogSink implementation for a hash-chained Postgres + object-store sink WITHOUT changing Entry — that is the point of freezing the shape now.

go
type Entry struct {
    Subject       string      // "user:01HW…" or "serviceaccount:01HW…"
    Relation      string      // "maintainer", "assign", …
    Object        string      // "resource:web-01"
    Reason        Reason      // granted | out_of_scope | insufficient_relation | caveat_violation
    RelationPath  []string    // the granted or closest-approaching path, outermost-first
    CaveatContext []string    // caveat-context field NAMES referenced, never values
    CorrelationID string      // inbound request correlation id
    Zedtoken      string      // per-request zedtoken captured by the session
    Timestamp     time.Time   // decision time, UTC
}

Reason enum

The four-value enum is the same set PermissionDenied exposes on the /v1 surface (see api/openapi/plexsphere-v1.yaml):

ReasonMeaning
grantedCheck observed a tuple graph permitting the subject for the requested relation.
out_of_scopeNo relevant subject binding existed in the graph (unknown subject, unrelated Domain).
insufficient_relationA binding existed but carried a weaker relation than the one required.
caveat_violationThe graph permitted the relation but a CEL caveat returned false.

The ordinals are stable across releases: new values MUST be appended and MUST NOT reuse existing ordinals so persisted audit rows keep their meaning forever.

Caveat-value redaction

audit.Entry.CaveatContext is a []string of NAMES; the type structurally prevents a caveat value from leaking into an audit row because no slot exists for it. authz.RedactCaveatContext gives a names-only view for the rare diagnostic log line that needs the shape. Neither replaces caller diligence: a handler that logs the caveat-context map itself is its own bug.

One entry per decision

Authorizer.Check emits exactly one audit.Entry per call — granted, denied, or caveat violation. Authorizer.Write emits one entry per relationship in the batch (all with Reason = granted because a Write is an administrative mutation, not a decision). Authorizer.Delete emits one entry derived from the filter. The middleware in internal/authz/middleware/rebac.go does NOT emit entries on its own — that would double-count the handler's own Check. Bypass-path requests (health, version, openapi, auth endpoints) produce zero audit entries by design: they carry no user data and would dominate the audit stream.

Authentication posture (preshared vs mTLS)

The SpiceDB client in internal/authz/client.go supports two mutually exclusive authentication postures. The boot-time envvar parser in internal/platform/bootstrap returns authz.ErrSpiceDBAuthMixed when both are set — the pair is exclusive by construction so a misconfigured rollout never silently picks one and leaves the other dangling.

AspectPreshared key (dev)mTLS (production)
AuthenticationShared bearer token (SPICEDB_PRESHARED_KEY) sent on every gRPC call.Client certificate + private key + trusted CA bundle.
TransportTLS by default; PresharedKeyInsecure=true downgrades for kind-local fixtures.TLS 1.2+ with mutual authentication (tls.LoadX509KeyPair).
EnvvarsSPICEDB_ENDPOINT, SPICEDB_PRESHARED_KEY.SPICEDB_ENDPOINT, SPICEDB_MTLS_CERT_PATH, SPICEDB_MTLS_KEY_PATH, SPICEDB_MTLS_CA_PATH.
Threat modelEvery pod that can reach spicedb:50051 speaks as any caller. Acceptable only inside trusted namespaces.Per-workload identity; a compromised sidecar cannot speak as a peer without its own cert/key.
Deployed viadeploy/local/base/spicedb/ dev + the preshared-key Secret (test-key) the kind cluster bootstraps.The dashboard signing-key rotation track will eventually ship SPIFFE-issued certs + per-workload mTLS rollout; not shipped today.
RotationRolling restart with a new SPICEDB_PRESHARED_KEY env. Trivial.Cert rotation via cert-manager / SPIRE with a grace window; operator-driven.

The production mTLS posture is not shipped today — the preshared-key path is the current production on-ramp with the understanding that the per-workload mTLS rollout replaces it. The deploy/local/README.md section spells out the dev-posture caveats (single static key, every pod can impersonate), and the ClientConfig.Validate function keeps the two postures from ever overlapping.

gRPC keepalive and correlation id

Independent of the auth posture, the client installs:

  • Keepalive — PING every 30 s (DefaultKeepaliveTime), 10 s ACK timeout (DefaultKeepaliveTimeout), matching the SpiceDB server min_time setting so the client is never disconnected for being "too chatty".
  • Correlation-id interceptor — reads authz.CorrelationIDFromContext(ctx) and writes it to the x-correlation-id gRPC metadata header on every outgoing call. The same id reaches the audit row via CallOptions.CorrelationID so a single request threads through transport, SpiceDB, and audit with one identifier.

Production wiring

The production-wiring pass retired the per-handler static*Authorizer shims and threads the real *authz.Authorizer through every production composition root. Each *_factory_prod.go under cmd/plexsphere/domains_factory_prod.go, projects_factory_prod.go, audit_factory_prod.go, identities_factory_prod.go, invitations_factory_prod.go, labels_factory_prod.go — now constructs the Authorizer from the live spicedbClient.Permissions() surface and the audit sink, and the workspace gate tests/workspace/cmd_plexsphere_no_static_authorizer_test.go walks the cmd/plexsphere/ AST to fail the build the moment any new identifier matching (?i)static.*Authorizer is reintroduced .

The boot order for the SpiceDB-side wiring is fixed and observable in cmd/plexsphere/app.go — every step must succeed before the listener binds, so a misconfigured cluster fails closed at process start instead of at the first 403:

  1. SpiceDB clientBuildProductionSpiceDBClient resolves bootstrap.LoadSpiceDBConfig, validates the preshared-key / mTLS exclusivity (ErrSpiceDBAuthMixed), and dials the SPICEDB_ENDPOINT target.
  2. Schema-applierbootstrap.RegisterSchemaApplierProbe calls *authz.SchemaApplier.ApplySchema with the embedded authz.SchemaText(); the digest cache short-circuits when the live schema already matches, so a re-roll without a schema change does not call WriteSchema.
  3. Bootstrap-tokens reconciler — registered on the same boot.Registry so the admin surface is only declared ready after token expiry sweeps are scheduled.
  4. Readiness probebootstrap.RegisterSpiceDBReadinessProbe wraps *authz.Client.Ping (a ReadSchema round-trip); a SpiceDB outage flips /readyz red within the probe's 30 s window .
  5. Outbox consumeroutboxConsumerSeams constructs *authzsync.Consumer and Runs it in a dedicated goroutine AFTER the listener bind so a transient SpiceDB stall during boot does not block readiness; Stop is registered on the shutdown hook chain with a 10 s drain budget so the in-flight batch lands or surrenders cleanly on SIGTERM .
text
   PostgreSQL                       plexsphere                       SpiceDB
   plexsphere.outbox_events          outbox consumer                  cluster
        |                                 |                              |
        |  INSERT outbox row in same TX   |                              |
        |  as the aggregate write         |                              |
        |<--------------------------------|                              |
        |                                 |                              |
        |  SELECT ... WHERE applied_at IS NULL                           |
        |  ORDER BY id LIMIT N FOR UPDATE SKIP LOCKED                    |
        |-------------------------------->|                              |
        |                                 |                              |
        |   batch of OutboxRow            |                              |
        |<--------------------------------|                              |
        |                                 |                              |
        |                                 |  MapEvent(row) -> writes/dels|
        |                                 |                              |
        |                                 |  Authorizer.Write/Delete     |
        |                                 |----------------------------->|
        |                                 |                              |
        |                                 |       ZedToken / OK          |
        |                                 |<-----------------------------|
        |                                 |                              |
        |  UPDATE outbox SET applied_at = now()                          |
        |  WHERE id = row.id              |                              |
        |<--------------------------------|                              |

The consumer is the only writer to SpiceDB on the application hot path; handlers never call Authorizer.Write directly. See ../../reference/platform/authz.md#schema-applier-on-boot for the schema-applier API contract and ../../../internal/authz/sync/ for the consumer package source.

Operator note — Domain creator owner tuple. The consumer mirrors every tenancy.* event listed in the Event-to-tuple mapping table EXCEPT the domain:<id>#owner@user:<creator> tuple for newly-created Domains: the tenancy.DomainCreated payload omits the actor user ID, so the consumer cannot derive that tuple from the outbox row alone. A naïve flow that creates a Domain through POST /v1/domains and then calls GET /v1/domains/{id} returns 403 insufficient_relation UNLESS a separate seed path has written the owner tuple (admin Group membership or cmd/plexsphere-bootstrap reconciliation). See DomainCreated owner-tuple seed for the full taxonomy and the two seed paths operators rely on while the payload widening is deferred.

Dual-write contract

Every aggregate write that affects authorisation is committed to PostgreSQL inside the same transaction that appends an outbox_events row; the outbox consumer at internal/authz/sync/consumer.go is the only writer to SpiceDB. The split keeps the aggregate write atomic with the intent-to-mirror, and the consumer turns that intent into the corresponding Authorizer.Write / Authorizer.Delete call against SpiceDB. There is no second in-process path: a handler never reaches SpiceDB on the write side without going through the outbox first.

The SLA constants the consumer enforces live as const declarations in internal/authz/sync/consumer.go:

ConstantValueMeaning
pollIntervalMin250 msLower bound on the idle poll interval; a partial batch resets to this.
pollIntervalMax5 sUpper bound on the geometric idle backoff (visibility-latency target on an idle system).
retryBackoffMin250 msFirst-attempt retry sleep on a per-row dispatch failure.
retryBackoffMax30 sRetry-backoff envelope; the per-row schedule doubles 250 ms → 500 ms → 1 s → 2 s → 4 s → 8 s → 16 s → 30 s with ±25 % jitter.
maxRetriesPerRow10Per-row attempt budget before the row surrenders to the next Run cycle (applied_at stays NULL).
defaultBatchSize64Rows pulled per PollUnappliedBatch call when no operator override is supplied.

A FULL batch loops immediately (no sleep) so a backlog drains as fast as the upstream allows; a partial or empty batch sleeps from pollIntervalMin and grows geometrically toward pollIntervalMax, so an idle system spends most of its time asleep rather than hammering Postgres.

FailureDetectionRecovery
PG commit succeeded, SpiceDB Write failedconsumer retry log (authz/sync: dispatch failed, retrying) keyed on outbox_id + attempt_countexponential backoff up to 30 s, then dead-letter to outbox_events.applied_at IS NULL for the next loop
SpiceDB Write succeeded, consumer crashed before applied_at ackduplicate redelivery on the next PollUnappliedBatch (the row is still applied_at IS NULL)idempotent re-apply (Authorizer.Write is TOUCH-semantic on identical relationships, so the second call is a no-op)
PG outageproducer-side error inside the application transactionrequest fails fast at the API boundary (no half-write — neither aggregate row nor outbox row lands)
Mapping returns ErrUnmappedEvent (or any other mapping error)authz/sync: unmapped event type — acking to avoid livelock WARN linerow is acked to avoid a livelock; the drift surfaces through the WARN line and the TestEventTupleMap_CoversAllAggregateEvents reflective gate

The first three rows are the canonical dual-write failure-mode taxonomy and are exercised end-to-end by tests/integration/dual_write_failure_modes_test.go; the fourth row pins the consumer's posture toward a programmer error in the event-to-tuple switch.

Event-to-tuple mapping

internal/authz/sync.MapEvent is a pure translation layer: it reads a discriminated outbox row (Type + JSONB Payload) and returns the []authz.Relationship to write and the []authz.DeleteFilter to delete. Both slices may be empty (a no-op event); both may be non-empty (today only ResourceMoved). The canonical mapping is defined in internal/authz/sync/mapping.go; this table mirrors the truth there and MUST stay in lockstep .

Aggregate eventWriteDelete
tenancy.DomainCreated(owner tuple deferred; see DomainCreated owner-tuple seed below and mapping.go DECISION block)
tenancy.DomainAdminGranteduser:<user_id> -[<relation>]-> domain:<domain_id> (relation ∈ {admin, auditor, member}; the post-login seed path documented in DomainCreated owner-tuple seed below)
tenancy.DomainApproverGranteduser:<user_id> -[approver]-> domain:<domain_id> (relation fixed by the event type, never read from the payload; TOUCH semantics keep a re-emission idempotent)
tenancy.DomainEmergencyApproverGranteduser:<user_id> -[emergency_approver]-> domain:<domain_id> (emergency_approver counterpart of DomainApproverGranted; relation fixed by the event type)
tenancy.DomainApproverRevokednarrow (domain:<id>, approver, user, <user_id>) — sibling approver grants on the same Domain untouched
tenancy.DomainEmergencyApproverRevokednarrow (domain:<id>, emergency_approver, user, <user_id>) — sibling emergency_approver grants untouched
tenancy.DomainUpdated
tenancy.DomainDeletedwholesale-purge domain:<id>
tenancy.ProjectCreateddomain:<domain_id> -[parent]-> project:<project_id>
tenancy.ProjectUpdated
tenancy.ProjectDeletedwholesale-purge project:<project_id>
tenancy.ResourceCreatedproject:<project_id> -[parent]-> resource:<resource_id>
tenancy.ResourceMovedproject:<to_project_id> -[parent]-> resource:<resource_id>narrow (resource:<id>, parent, project, <from_project_id>)
tenancy.ResourceDeletedwholesale-purge resource:<resource_id>
tenancy.NodeRegistered(no node definition in schema/authz.zed)
tenancy.NodeReachabilityChanged
identity.GroupCreateddomain:<domain_id> -[parent]-> group:<group_id>
identity.GroupRenamed
identity.GroupDeletedwholesale-purge group:<group_id>
identity.GroupMemberAdded (alias GroupMembershipChanged)<principal_subject> -[member]-> group:<group_id> (subject = user:<id> | serviceaccount:<id> | group:<id>#member)
identity.GroupMemberRemoved (alias GroupMembershipRevoked)narrow (group:<gid>, member, <subjectType>, <subjectID>) — sibling memberships untouched
identity.GroupParentAdded / GroupParentRemoved / GroupIdPSyncDrift
identity.UserProvisioneddomain:<domain_id> -[parent]-> user:<user_id> (the per-row identity-visibility edge — see Subjects)
identity.ServiceIdentityProvisioneddomain:<domain_id> -[parent]-> serviceaccount:<service_identity_id> (service-cohort counterpart of UserProvisioned)
tenancy.PolicyRevisionCreateduser:<created_by> -[owner]-> policy:<policy_id> AND project:<project_id> -[parent]-> policy:<policy_id> (both writes emitted on every revision; Authorizer.Write has TOUCH semantics so later revisions are SpiceDB no-ops — see the DECISION block in mapping.go)
tenancy.PolicyDeletedwholesale-purge policy:<policy_id>
Authn / IdP / API-token / Peer-PSK lifecycle events(non-graph; explicit no-op branch in the switch)

Aggregate deletes (Domain / Project / Resource / Group) emit a single wholesale-purge DeleteFilter keyed only on (ResourceType, ResourceID); SpiceDB's RelationshipFilter treats a blank OptionalRelation as "match every relation under this resource id", which is exactly the semantics the deleted aggregate needs. The GroupMemberRemoved filter is deliberately narrow(group, gid, member, <subjectType>, <subjectID>) — so revoking one principal does NOT delete sibling memberships .

The parity invariant the consumer relies on is:

For every events.Type* constant, MapEvent returns at least one (Relationship | DeleteFilter) OR an explicit no-op branch. An unrecognised event type returns ErrUnmappedEvent and the consumer logs + acks to avoid a livelock.

The reflective gate TestEventTupleMap_CoversAllAggregateEvents in internal/authz/sync/mapping_test.go walks the events.Type* constants in internal/identity/events/ and internal/identity/tenancy/events/ and fails the build with event <name> has no tuple mapping the moment a new constant is added without a corresponding switch branch in mapping.go. The doc gate TestRebacDoc_EventTupleMappingMatchesCode will keep this table in lockstep with the switch.

DomainCreated owner-tuple seed

tenancy.DomainCreated is the one aggregate-create event for which MapEvent returns no Relationship. The tenancy.DomainCreated payload deliberately omits the actor user ID — the field set is (EventID, OccurredAt, DomainID, Slug, MeshCIDR) — so the authz/sync consumer cannot derive domain:<id>#owner@user:<creator> from the payload alone. The DECISION block at internal/authz/sync/mapping.go records the trade-off and points at the two options operators have for landing the owner tuple in production.

Operational consequence — a Domain creator does NOT automatically gain owner / manage on their own Domain. A naïve sign-in → POST /v1/domainsGET /v1/domains/{id} flow returns 403 insufficient_relation from the very actor that just created the Domain because the SpiceDB tuple set is empty. Operators triaging "creator gets 403 on their own Domain" should look here first, NOT at the Authorizer.Check plumbing — the gap is that no tuple was ever written, not that the check evaluator dropped one.

Three seed paths exist; any one is sufficient, listed in order of operator preference for the post-login bootstrap step.

  1. tenancy.DomainAdminGranted outbox event — the canonical post-login path. identity-e2e-demoOPERATION=grant-domain-admin (given DOMAIN_SLUG, EXTERNAL_SUBJECT, RELATION) emits the outbox row; the consumer translates it into user:<uid> -[<relation>]-> domain:<id>. The plexsphere-bootstrap Job's domain_admins[] pass writes these directly via Authorizer.Write so a fresh dev stack lets plexctl loginplexctl domain list succeed unaided. TOUCH semantics make repeat invocations idempotent (docs/how-to/getting-started/log-in.md is the operator recipe).
  2. Bootstrap admin Group — every fresh Domain ships a Domain-scoped admin Group (see groups.md). Adding the creator via POST /v1/admin/groups/{id}/members (or the IdP groups-claim mapping) flows through the consumer's GroupMemberAdded arm; the resolver expands the userset on every Authz.Check. Use when a long-lived Group binding is preferred over a per-user grant.
  3. Explicit cmd/plexsphere-bootstrap seed — the bootstrap binary reconciles a manifest of (domain_slug, admin_user_id) pairs on every Run via Authorizer.Write. It runs at cluster start (before any User row exists), so it suits operator-known pairs, not the post-sign-in flow path (1) optimises for.

A future task will widen the tenancy.DomainCreated payload to carry the actor user ID so the SpiceDB tuple lands automatically on Domain creation, without an out-of-band grant call. Until then, the three seed paths above are the contract operators rely on.

Label Assignment and ReBAC

The Label Registry (internal/labels) is a sibling bounded context that sits beside the ReBAC layer rather than inside it. Two rules frame how the two contexts interact:

  1. Labels never grant permissions (structural lock, schema-level).
  2. Every label assignment is gated by a dual ReBAC check at the application-service boundary (runtime lock, authz-mediated).

This section spells out both locks and the tests that make each one fail the build on drift.

Structural lock — labeldefinition admits no relation subjects

The canonical schema at schema/authz.zed declares labeldefinition with a parent relation pointing at the tenancy hierarchy (Domain or Project — see TestSchema_LabelDefinitionScopeParentsAreTenancyOnly) but ZERO relations on any other definition in the schema accept a labeldefinition (or labeldefinition#… userset) as a subject type. The structural invariant is enforced by TestSchemaLabelsNeverGrantPermissions in internal/authz/schema_invariants_test.go — the historical name for the "no label-derived relations" lock the Label Registry plan referenced. The test parses the schema via a regex over every relation <name>: <types> line and fails the build if labeldefinition ever appears on the right-hand side of a relation, which is the exact edit that would turn a label into an authorisation primitive.

The consequence is operational: a user tagged team=payments does not thereby gain read/act/manage on resources tagged team=payments. Label-based targeting happens at the selector engine (internal/labels/selector) which evaluates against the object's effective label set, not inside SpiceDB, and the Authorizer never consults a label when deriving a permission.

Runtime lock — dual check on every label mutation

Every mutating label operation (PutAssignment, DeleteAssignment, and every CreateDefinition / UpdateDefinition / DeleteDefinition call at the assignment-capable scope) performs two independent authz.Check calls before the write lands:

CheckObjectPermissionWhy
1labeldefinition:<id>assignSubject must be authorised by the Definition's ReBAC ownership to bind that specific label at all. A Definition author can decide that platform/env is assignable by any maintainer, while platform/compliance is only assignable by auditors.
2<object_kind>:<object_id>maintainer (or higher)Subject must also be authorised to modify the object the assignment attaches to. Owning the Definition does not confer the right to tag a resource you do not otherwise maintain.

Both checks must return Permitted before the assignment is written; either denial produces a single PermissionDenied{reason=insufficient_relation} response and a single audit entry. The dual check is implemented in internal/labels/services/assignment_service.go and covered by the transport integration tests in internal/transport/http/v1/labels_integration_test.go.

The shape is deliberately belt-and-braces: the Definition-side assign relation is how the catalog gates who may use a label, and the object-side maintainer relation is how the target gates who may modify it. Either check alone would leak: gating only by Definition would let a holder of platform/env:assign tag any object in the platform; gating only by object would let any maintainer invent or mis-apply labels whose governance the Definition author wanted to keep narrow. Both together keep each bounded context responsible for its own invariants.

For the bounded-context reference on the Label Registry itself — the ubiquitous language, scope hierarchy, value-schema catalogue, selector grammar, and SelectorPort contract — see ../labels/index.md.

Group membership sync

The group-sync loop closes the gap between the IdP-asserted group claim and the ReBAC tuple graph: when a User signs in and the groups.Syncer computes a delta of membership add / remove operations, those operations must reach SpiceDB as group:<id>#member tuple writes (and tombstones) so the next Authorizer.Check observes the new group-derived permissions. The transport for that propagation is the transactional outbox the dual-write consumer at internal/authz/sync/consumer.go already drains for every other ReBAC tuple mutation .

DECISION: group-membership tuples reach SpiceDB through the transactional outbox consumer, NOT through a periodic reconciler. Alternative considered: a background reconciler that scans every usersgroup_memberships row on a fixed timer and diffs it against SpiceDB, issuing WriteRelationships / DeleteRelationships calls to close the gap. REJECTED for three reasons: (a) the outbox is already the dual-write transport for every other ReBAC tuple mutation in internal/authz/sync/consumer.go — reusing it keeps a single transactional contract, a single failure mode, and a single place to reason about ordering; introducing a second propagation path doubles the surface area an operator must understand and instrument; (b) periodic reconciliation introduces clock skew between the IdP claim landing and SpiceDB observing the new tuple, a separate failure mode (the reconciler missing a tick or falling behind), and a window in which group-derived permissions remain stale — exactly the read-your-writes property the per-request authz.Session (see "Zedtoken consistency flow" above) is designed to preserve, and which a timer-driven reconciler structurally cannot; (c) on-mutation outbox events give the lowest-latency, idempotent update path — the consumer already handles retries, ordering, and at-least-once delivery, and a re-run of the same outbox row is a no-op against SpiceDB's tuple set, so the periodic reconciler's only nominal advantage (self-healing on missed events) is already covered by the consumer's at-least-once contract. The reconciler is therefore a strictly weaker design than the chosen outbox-driven path.

The outbox row shape, retry contract, and idempotency invariants are detailed in the consumer package; the membership-delta producer side is covered by the groups.Syncer contract in the sibling Groups bounded-context reference.

Invariant-to-test matrix

Every invariant the ReBAC context enforces is backed by at least one automated test. Every plan requirement appears at least once below. When a row lists multiple enforcement layers, the later layers are belt-and-braces — the earlier one is authoritative.

Invariant (REQ-id)Enforced atTest
Authorizer wraps the authzed-go gRPC surface with Check, Write, Delete, LookupResources, LookupSubjects and rejects empty triples at the boundaryauthz.NewAuthorizer, validateTriple, parseObject, parseSubjectinternal/authz/authorizer_test.go, internal/authz/authorizer_write_test.go, internal/authz/authorizer_lookup_test.go
Zedtoken capture + Fresher resolution order (FullConsistency > AtLeastAsFresh > MinimizeLatency)authz.Session, authz.Fresherinternal/authz/zedtoken_test.go + tests/integration/authz_zedtoken_test.go
ApplySchema is idempotent: read marker → hash → compare → write only on driftauthz.SchemaApplier.ApplySchema + extractAppliedDigest + attachDigestMarkerinternal/authz/schema_test.go + tests/integration/authz_schema_apply_test.go
Audit entry shape is frozen and Reason ordinals are stable across releasesaudit.Entry, audit.Reason, authz.AuditEntry (mirror)internal/audit/entry_test.go + internal/audit/sink_test.go
ReBAC middleware renders 403 problem+json, emits exactly one audit entry per check, skips the bypass-path allowlistinternal/authz/middleware/rebac.gointernal/authz/middleware/rebac_test.go + internal/transport/http/v1/router_test.go (middleware order)
CEL caveats within_time_window, from_cidr, requires_assurance evaluate permitted + denied paths against real SpiceDBschema/authz.zed caveat bodies + authz.BuildCaveatContextinternal/authz/caveats_test.go + tests/integration/authz_caveats_test.go
Caveat VALUES never enter audit.Entry or ErrCaveatViolation; only field NAMES reach the audit rowaudit.Entry.CaveatContext []string type + authz.RedactCaveatContext + Authorizer.Check denial pathinternal/authz/caveats_test.go + internal/authz/middleware/rebac_test.go
ClientConfig rejects mixed preshared-key + mTLS (ErrSpiceDBAuthMixed); either posture works end-to-endauthz.ClientConfig.Validate + bootstrap envvar parserinternal/authz/client_test.go + internal/platform/bootstrap/bootstrap_test.go
OpenAPI PermissionDenied schema exposes status, reason enum, relation_path, correlation_id; referenced from every mutating operationapi/openapi/plexsphere-v1.yamltests/integration/openapi_permission_denied_test.go
authzed-go imports are confined to internal/authz by depguard rule no-authzed-go-outside-authz.golangci.yml depguard + internal/platform/lint/depguard_rules_test.gointernal/platform/lint/depguard_rules_test.go
make authz-lint wraps zed validate schema/authz.zed and runs on the CI lint jobMakefile + .github/workflows/ci.yamlinternal/platform/make/make_targets_test.go + tests/workspace/ci_workflow_test.go
Schema invariants: labels grant no permissions; Group parent is exactly Domain; Domain-admin inheritance reaches Resourceschema/authz.zed structureinternal/authz/schema_invariants_test.go + tests/e2e/identity/rebac-hierarchy/chainsaw-test.yaml
Local kind deploy ships SpiceDB as its own Deployment with preshared-key auth, HTTP disabled, Postgres datastoredeploy/local/base/spicedb/ kustomize treedeploy/local/README.md + docs/tutorials/set-up-local-plexsphere.md
Principal mapper translates user / serviceaccount / group principals into SpiceDB subject triples via identity.groups.Resolverinternal/authz/middleware/mapper.gointernal/authz/middleware/mapper_test.go
LookupResources / LookupSubjects cursor pagination with DefaultPageSize = 200, 10 000-page infinite-loop guardauthz.Authorizer.LookupResources, LookupSubjects, drainLookupResources, drainLookupSubjects, cursorsEqualinternal/authz/authorizer_lookup_test.go + tests/integration/authz_benchmark_test.go
Postgres-backed SpiceDB fixture for integration tests via WithPostgresDatastore(dsn)internal/platform/testutil/containers/spicedb.gotests/integration/authz_zedtoken_test.go, authz_caveats_test.go, authz_schema_apply_test.go, authz_benchmark_test.go
Production SpiceDB client wired via bootstrap.LoadSpiceDBConfig + authz.NewClient; BuildProductionSpiceDBClient close-on-shutdown registered with boot.Registrycmd/plexsphere/spicedb_factory_prod.go + cmd/plexsphere/app.gocmd/plexsphere/spicedb_factory_prod_test.go + tests/integration/authz_production_wiring_test.go
SpiceDB readiness probe: *authz.Client.Ping (ReadSchema round-trip) registered on boot.Registry; /readyz flips red within 30 s of an outageinternal/platform/bootstrap/spicedb_readiness.gointernal/platform/bootstrap/spicedb_readiness_test.go + tests/integration/authz_production_wiring_test.go
Schema-applier on boot: SchemaApplier.ApplySchema runs synchronously between client-dial and listener-bind; digest cache short-circuits a re-roll without schema changesinternal/platform/bootstrap/schema_applier.go + cmd/plexsphere/app.gointernal/platform/bootstrap/schema_applier_test.go + tests/integration/authz_production_wiring_test.go
Real *authz.Authorizer threaded through every *_factory_prod.go under cmd/plexsphere/; the static shims are deletedcmd/plexsphere/{domains,projects,audit,identities,invitations,labels}_factory_prod.gocmd/plexsphere/authz_wiring_prod_test.go + tests/integration/{domains,projects,identities,invitations,audit}_authz_real_spicedb_test.go
Workspace AST gate: no identifier matching (?i)static.*Authorizer may be reintroduced under cmd/plexsphere/tests/workspace/cmd_plexsphere_no_static_authorizer_test.gotests/workspace/cmd_plexsphere_no_static_authorizer_test.go::TestNoStaticAuthorizerShimsRemain
Production binary fails fast (exit 78) when SPICEDB_ENDPOINT is empty in non-testbuild builds; ErrSpiceDBAuthMixed rejected at bootcmd/plexsphere/spicedb_required_prod.go + cmd/plexsphere/app.gocmd/plexsphere/spicedb_required_prod_test.go + cmd/plexsphere/spicedb_factory_prod_test.go
Tenancy CRUD handler tests run against a real testcontainer SpiceDB (granted + explicit-denial); fakeAuthorizer is forbidden in tests/integration/tests/integration/{domains,projects,identities,invitations,audit}_authz_real_spicedb_test.gotests/workspace/no_fake_authorizer_in_integration_tests_test.go::TestNoFakeAuthorizerInIntegrationTests
MapEvent covers every aggregate event in events.Type*; an unmapped constant fails the reflective gateinternal/authz/sync/mapping.gointernal/authz/sync/mapping_test.go::TestEventTupleMap_CoversAllAggregateEvents
Outbox consumer: SELECT … FOR UPDATE SKIP LOCKED, per-row dispatch through MapEvent, idempotent re-apply, applied_at ack on success; nil-collaborator constructor errorsinternal/authz/sync/consumer.go + internal/authz/sync/scanner_pg.gointernal/authz/sync/consumer_test.go + tests/integration/outbox_consumer_authz_test.go
Dual-write retry envelope: 250 ms → 30 s with ±25 % jitter, capped at maxRetriesPerRow=10; PG-ok+SpiceDB-outage and SpiceDB-ok+consumer-crash exercised end-to-endinternal/authz/sync/consumer.go (nextRetryBackoff, jitter, dispatchRow)internal/authz/sync/consumer_test.go + tests/integration/dual_write_failure_modes_test.go
Delete-path mapping: Domain/Project/Resource/Group deletes emit wholesale-purge DeleteFilter; tuples are gone within the SLAinternal/authz/sync/mapping.go (delete branches)internal/authz/sync/mapping_test.go + tests/e2e/identity/rebac-tuple-write/chainsaw-test.yaml
GroupMemberRemoved filter is narrow (group, gid, member, <subjectType>, <subjectID>) — sibling memberships untouched on revocationinternal/authz/sync/mapping.go::principalSubjectTypeAndIDinternal/authz/sync/mapping_test.go + tests/integration/group_membership_sync_test.go
OIDC callback emits GroupMembershipChanged / GroupMembershipRevoked outbox events on claim diff in the same TX as Groups.Sync; revocation propagates within one sign-in cycleinternal/transport/http/v1/auth/callback.go::Groups.Sync integrationinternal/transport/http/v1/auth/callback_groups_sync_test.go + tests/integration/group_membership_sync_test.go + tests/e2e/identity/rebac-group-claim-sync/chainsaw-test.yaml
Authn DefaultBypass() and authz DefaultBypass() agree on the exact-vs-prefix matcher and on the path set; adversarial /v1/auth_bypass_attempt rejected by both layersinternal/identity/authn/middleware/middleware.go + internal/authz/middleware/rebac.gointernal/identity/authn/middleware/bypass_test.go + internal/authz/middleware/rebac_test.go + tests/workspace/middleware_bypass_parity_test.go::TestMiddlewareBypassParity
classifyDenial uses the SpiceDB DEBUG_TRACE payload (not a response-shape heuristic) to distinguish out_of_scope / insufficient_relation / caveat_violationinternal/authz/authorizer.go::classifyDenialinternal/authz/authorizer_test.go::TestClassifyDenial_DebugTraceBacked
Doc gate: ## Production wiring, ## Dual-write contract, ## Event-to-tuple mapping H2 sections present; group-sync DECISION: block present; event-to-tuple table in lockstep with mapping.godocs/contexts/identity/rebac.md (this file) + docs/reference/platform/authz.mdtests/docs/rebac_doc_test.go + tests/docs/authz_doc_test.go
Rendered-surface convention: every error / panic / test-failure message under cmd/plexsphere/spicedb_factory_prod.go and internal/authz/sync/ is free of traceability identifiers; the originating requirement is recorded in the function or test doc-comment, not the surfaced stringcmd/plexsphere/spicedb_factory_prod.go + internal/authz/sync/tests/workspace/no_identifiers_in_rendered_surfaces_test.go
Admin surface runs a per-Domain ReBAC gate against the parent Domain (domain#manage for mutators, domain#read for reads); a refusal renders PermissionDenied + one Outcome=permission_denied audit row with CaveatContext.missing_relation pinned to the gate that failed. IdPBinding aggregates do NOT introduce a new SpiceDB definition — the parent Domain is the canonical gate target.internal/transport/http/v1/admin/{groups,group_members,idp}.go + internal/transport/http/v1/admin/errors.go::writePermissionDeniedinternal/transport/http/v1/admin/permission_denied_test.go + tests/integration/admin_cross_domain_test.go
Admin factory refuses production wiring when AuthzCheck is nil (ErrAdminAuthzCheckRequired wraps the canonical ErrAuthzCheckRequired); cmd/plexsphere/main.go populates the field from productionAuthzWiring.AuthzCheck() so the same SpiceDB-backed Authorizer drives every per-surface factorycmd/plexsphere/admin_factory_prod.go::BuildProductionAdminFactory + cmd/plexsphere/main.gocmd/plexsphere/admin_factory_prod_test.go::TestBuildProductionAdminFactory_RejectsNilAuthzCheck

Cross-references