Appearance
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 frominternal/identity/and enforced as a separate seam by theno-cross-context-importsdepguard rule in../../../.golangci.yml. When this doc says "the Authorizer" it means a type ininternal/authz/, notinternal/identity/; do not look forinternal/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)
| Definition | Role | Relations / permissions | Subject-id shape |
|---|---|---|---|
user | Human principal resolved by the authn layer. | parent: domain; read = parent->read | tenancy.ID of the identity record. |
serviceaccount | Machine principal resolved by the authn layer (API tokens, SPIFFE SVIDs, OIDC client-credentials). | parent: domain; read = parent->read | tenancy.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)
| Definition | Parent | Relations |
|---|---|---|
group | domain (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)
| Definition | Parent | Privileged relation | Inherits from parent? |
|---|---|---|---|
secret | domain | assigner | No — only explicit grants. |
cloud | domain | operator | No — explicit grants only. |
cloudcredential | cloud | assigner | No — no derivation from cloud.operator. |
blueprint | domain | publisher | No — 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)
| Caveat | Request-time inputs | Tuple-time parameters | Purpose |
|---|---|---|---|
within_time_window | now (timestamp) | until (timestamp) | Expiring grants; step-down by clock. |
from_cidr | client_ip (ipaddress) | allowed_cidrs (list<string>) | Network-bounded grants (bastion IP, VPN egress). |
requires_assurance | acr (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".
| Object | Permission | Derivation |
|---|---|---|
user | read | parent->read |
serviceaccount | read | parent->read |
domain | manage | owner + admin |
domain | read | owner + admin + auditor + member |
project | manage | admin + parent->manage |
project | deploy | admin + maintainer + parent->manage |
project | act | admin + maintainer + operator + parent->manage |
project | observe | admin + maintainer + operator + viewer + parent->read |
resource | manage | owner + maintainer + parent->manage |
resource | act | owner + maintainer + operator + parent->act |
resource | observe | owner + maintainer + operator + viewer + parent->observe |
secret | manage | owner (no parent derivation) |
secret | assign | owner + assigner (no parent derivation) |
secret | read | owner + assigner + reader (no parent derivation) |
cloud | manage | owner (no parent derivation) |
cloud | operate | owner + operator (no parent derivation) |
cloud | observe | owner + operator + auditor (no parent derivation) |
cloudcredential | manage | owner (no parent derivation) |
cloudcredential | assign | owner + assigner (no parent derivation) |
cloudcredential | read | owner + assigner + reader (no parent derivation) |
blueprint | manage | owner (no parent derivation) |
blueprint | publish | owner + publisher (no parent derivation) |
blueprint | read | owner + publisher + reader (no parent derivation) |
labeldefinition | manage | owner (no parent derivation) |
labeldefinition | assign | owner + assigner (no parent derivation) |
labeldefinition | read | owner + assigner + reader (no parent derivation) |
The two derivation chains worth reading aloud:
- Domain admin → resource manage. A tuple
user:alice -[admin]-> domain:acmegives aliceresource#manageon every Resource whose Project's parent isdomain:acme, viaresource.manage = … + parent->manage→project.manage = … + parent->manage→domain.manage = admin. This is the chainsaw e2e scenario attests/e2e/identity/rebac-hierarchy/chainsaw-test.yaml. - Privileged types stop inheritance. A
user:alice -[admin]-> domain:acmetuple does NOT grantsecret#assignon Secrets underacme. Thesecret.assignderivation isowner + assignerwith noparent->…term — by design, so a Domain admin cannot silently exfiltrate credentials. Operators that want Domain-wide secret assignment attach an explicitdomain.member→secret.assignertuple 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:
opts.FullConsistency(set viaauthz.WithFullConsistency()) →FullyConsistent. Zedtoken is ignored. Use for UI list endpoints that MUST NOT show a stale view.session.Token() != ""→AtLeastAsFresh(session.Token()). This is the read-your-writes path.- 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.
| Caveat | Field | Written by | Source | Notes |
|---|---|---|---|---|
within_time_window | now | Authorizer | time.Now().UTC() (or CaveatRequestContext.Now when populated) | RFC3339Nano string; SpiceDB's CEL timestamp type. |
within_time_window | until | Tuple | Caller-supplied expiry on the Write. | Tuple-side expiry — the grant naturally lapses. |
from_cidr | client_ip | Authorizer | CaveatRequestContext.ClientIP (extracted from X-Forwarded-For by the transport policy). | Empty → caveat fails closed. |
from_cidr | allowed_cidrs | Tuple | Caller-supplied list of prefixes. | The caveat returns true when client_ip sits in any prefix. |
requires_assurance | acr | Authorizer | OIDC acr claim from the session. | Empty → caveat fails closed. |
requires_assurance | amr | Authorizer | OIDC amr claim from the session. | Nil → caveat fails closed. |
requires_assurance | acr_freshness_seconds | Authorizer | Seconds since the last acr re-auth event. | Negative → caveat fails closed. |
requires_assurance | required_acr | Tuple | Caller-supplied minimum acr. | Tuple-side policy bar. |
requires_assurance | min_amr | Tuple | Caller-supplied required subset of amr. | Tuple-side policy bar. |
requires_assurance | max_age | Tuple | Caller-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_PERMISSION → ErrCaveatViolation → 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):
| Reason | Meaning |
|---|---|
granted | Check observed a tuple graph permitting the subject for the requested relation. |
out_of_scope | No relevant subject binding existed in the graph (unknown subject, unrelated Domain). |
insufficient_relation | A binding existed but carried a weaker relation than the one required. |
caveat_violation | The 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.
| Aspect | Preshared key (dev) | mTLS (production) |
|---|---|---|
| Authentication | Shared bearer token (SPICEDB_PRESHARED_KEY) sent on every gRPC call. | Client certificate + private key + trusted CA bundle. |
| Transport | TLS by default; PresharedKeyInsecure=true downgrades for kind-local fixtures. | TLS 1.2+ with mutual authentication (tls.LoadX509KeyPair). |
| Envvars | SPICEDB_ENDPOINT, SPICEDB_PRESHARED_KEY. | SPICEDB_ENDPOINT, SPICEDB_MTLS_CERT_PATH, SPICEDB_MTLS_KEY_PATH, SPICEDB_MTLS_CA_PATH. |
| Threat model | Every 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 via | deploy/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. |
| Rotation | Rolling 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 servermin_timesetting so the client is never disconnected for being "too chatty". - Correlation-id interceptor — reads
authz.CorrelationIDFromContext(ctx)and writes it to thex-correlation-idgRPC metadata header on every outgoing call. The same id reaches the audit row viaCallOptions.CorrelationIDso 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:
- SpiceDB client —
BuildProductionSpiceDBClientresolvesbootstrap.LoadSpiceDBConfig, validates the preshared-key / mTLS exclusivity (ErrSpiceDBAuthMixed), and dials theSPICEDB_ENDPOINTtarget. - Schema-applier —
bootstrap.RegisterSchemaApplierProbecalls*authz.SchemaApplier.ApplySchemawith the embeddedauthz.SchemaText(); the digest cache short-circuits when the live schema already matches, so a re-roll without a schema change does not callWriteSchema. - Bootstrap-tokens reconciler — registered on the same
boot.Registryso the admin surface is only declared ready after token expiry sweeps are scheduled. - Readiness probe —
bootstrap.RegisterSpiceDBReadinessProbewraps*authz.Client.Ping(aReadSchemaround-trip); a SpiceDB outage flips/readyzred within the probe's 30 s window . - Outbox consumer —
outboxConsumerSeamsconstructs*authzsync.ConsumerandRuns it in a dedicated goroutine AFTER the listener bind so a transient SpiceDB stall during boot does not block readiness;Stopis registered on the shutdown hook chain with a 10 s drain budget so the in-flight batch lands or surrenders cleanly onSIGTERM.
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 thedomain:<id>#owner@user:<creator>tuple for newly-created Domains: thetenancy.DomainCreatedpayload 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 throughPOST /v1/domainsand then callsGET /v1/domains/{id}returns403 insufficient_relationUNLESS a separate seed path has written the owner tuple (admin Group membership orcmd/plexsphere-bootstrapreconciliation). 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:
| Constant | Value | Meaning |
|---|---|---|
pollIntervalMin | 250 ms | Lower bound on the idle poll interval; a partial batch resets to this. |
pollIntervalMax | 5 s | Upper bound on the geometric idle backoff (visibility-latency target on an idle system). |
retryBackoffMin | 250 ms | First-attempt retry sleep on a per-row dispatch failure. |
retryBackoffMax | 30 s | Retry-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. |
maxRetriesPerRow | 10 | Per-row attempt budget before the row surrenders to the next Run cycle (applied_at stays NULL). |
defaultBatchSize | 64 | Rows 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.
| Failure | Detection | Recovery |
|---|---|---|
| PG commit succeeded, SpiceDB Write failed | consumer retry log (authz/sync: dispatch failed, retrying) keyed on outbox_id + attempt_count | exponential 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 ack | duplicate 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 outage | producer-side error inside the application transaction | request 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 line | row 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 event | Write | Delete |
|---|---|---|
tenancy.DomainCreated | — (owner tuple deferred; see DomainCreated owner-tuple seed below and mapping.go DECISION block) | — |
tenancy.DomainAdminGranted | user:<user_id> -[<relation>]-> domain:<domain_id> (relation ∈ {admin, auditor, member}; the post-login seed path documented in DomainCreated owner-tuple seed below) | — |
tenancy.DomainApproverGranted | user:<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.DomainEmergencyApproverGranted | user:<user_id> -[emergency_approver]-> domain:<domain_id> (emergency_approver counterpart of DomainApproverGranted; relation fixed by the event type) | — |
tenancy.DomainApproverRevoked | — | narrow (domain:<id>, approver, user, <user_id>) — sibling approver grants on the same Domain untouched |
tenancy.DomainEmergencyApproverRevoked | — | narrow (domain:<id>, emergency_approver, user, <user_id>) — sibling emergency_approver grants untouched |
tenancy.DomainUpdated | — | — |
tenancy.DomainDeleted | — | wholesale-purge domain:<id> |
tenancy.ProjectCreated | domain:<domain_id> -[parent]-> project:<project_id> | — |
tenancy.ProjectUpdated | — | — |
tenancy.ProjectDeleted | — | wholesale-purge project:<project_id> |
tenancy.ResourceCreated | project:<project_id> -[parent]-> resource:<resource_id> | — |
tenancy.ResourceMoved | project:<to_project_id> -[parent]-> resource:<resource_id> | narrow (resource:<id>, parent, project, <from_project_id>) |
tenancy.ResourceDeleted | — | wholesale-purge resource:<resource_id> |
tenancy.NodeRegistered | — (no node definition in schema/authz.zed) | — |
tenancy.NodeReachabilityChanged | — | — |
identity.GroupCreated | domain:<domain_id> -[parent]-> group:<group_id> | — |
identity.GroupRenamed | — | — |
identity.GroupDeleted | — | wholesale-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.UserProvisioned | domain:<domain_id> -[parent]-> user:<user_id> (the per-row identity-visibility edge — see Subjects) | — |
identity.ServiceIdentityProvisioned | domain:<domain_id> -[parent]-> serviceaccount:<service_identity_id> (service-cohort counterpart of UserProvisioned) | — |
tenancy.PolicyRevisionCreated | user:<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.PolicyDeleted | — | wholesale-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,MapEventreturns at least one(Relationship | DeleteFilter)OR an explicit no-op branch. An unrecognised event type returnsErrUnmappedEventand 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/domains → GET /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.
tenancy.DomainAdminGrantedoutbox event — the canonical post-login path.identity-e2e-demoOPERATION=grant-domain-admin(givenDOMAIN_SLUG, EXTERNAL_SUBJECT, RELATION) emits the outbox row; the consumer translates it intouser:<uid> -[<relation>]-> domain:<id>. Theplexsphere-bootstrapJob'sdomain_admins[]pass writes these directly viaAuthorizer.Writeso a fresh dev stack letsplexctl login→plexctl domain listsucceed unaided. TOUCH semantics make repeat invocations idempotent (docs/how-to/getting-started/log-in.mdis the operator recipe).- Bootstrap admin Group — every fresh Domain ships a Domain-scoped admin Group (see
groups.md). Adding the creator viaPOST /v1/admin/groups/{id}/members(or the IdP groups-claim mapping) flows through the consumer'sGroupMemberAddedarm; the resolver expands the userset on everyAuthz.Check. Use when a long-lived Group binding is preferred over a per-user grant. - Explicit
cmd/plexsphere-bootstrapseed — the bootstrap binary reconciles a manifest of(domain_slug, admin_user_id)pairs on every Run viaAuthorizer.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:
- Labels never grant permissions (structural lock, schema-level).
- 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:
| Check | Object | Permission | Why |
|---|---|---|---|
| 1 | labeldefinition:<id> | assign | Subject 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
users↔group_membershipsrow on a fixed timer and diffs it against SpiceDB, issuingWriteRelationships/DeleteRelationshipscalls to close the gap. REJECTED for three reasons: (a) the outbox is already the dual-write transport for every other ReBAC tuple mutation ininternal/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-requestauthz.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 at | Test |
|---|---|---|
Authorizer wraps the authzed-go gRPC surface with Check, Write, Delete, LookupResources, LookupSubjects and rejects empty triples at the boundary | authz.NewAuthorizer, validateTriple, parseObject, parseSubject | internal/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.Fresher | internal/authz/zedtoken_test.go + tests/integration/authz_zedtoken_test.go |
ApplySchema is idempotent: read marker → hash → compare → write only on drift | authz.SchemaApplier.ApplySchema + extractAppliedDigest + attachDigestMarker | internal/authz/schema_test.go + tests/integration/authz_schema_apply_test.go |
Audit entry shape is frozen and Reason ordinals are stable across releases | audit.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 allowlist | internal/authz/middleware/rebac.go | internal/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 SpiceDB | schema/authz.zed caveat bodies + authz.BuildCaveatContext | internal/authz/caveats_test.go + tests/integration/authz_caveats_test.go |
Caveat VALUES never enter audit.Entry or ErrCaveatViolation; only field NAMES reach the audit row | audit.Entry.CaveatContext []string type + authz.RedactCaveatContext + Authorizer.Check denial path | internal/authz/caveats_test.go + internal/authz/middleware/rebac_test.go |
ClientConfig rejects mixed preshared-key + mTLS (ErrSpiceDBAuthMixed); either posture works end-to-end | authz.ClientConfig.Validate + bootstrap envvar parser | internal/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 operation | api/openapi/plexsphere-v1.yaml | tests/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.go | internal/platform/lint/depguard_rules_test.go |
make authz-lint wraps zed validate schema/authz.zed and runs on the CI lint job | Makefile + .github/workflows/ci.yaml | internal/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 Resource | schema/authz.zed structure | internal/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 datastore | deploy/local/base/spicedb/ kustomize tree | deploy/local/README.md + docs/tutorials/set-up-local-plexsphere.md |
Principal mapper translates user / serviceaccount / group principals into SpiceDB subject triples via identity.groups.Resolver | internal/authz/middleware/mapper.go | internal/authz/middleware/mapper_test.go |
LookupResources / LookupSubjects cursor pagination with DefaultPageSize = 200, 10 000-page infinite-loop guard | authz.Authorizer.LookupResources, LookupSubjects, drainLookupResources, drainLookupSubjects, cursorsEqual | internal/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.go | tests/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.Registry | cmd/plexsphere/spicedb_factory_prod.go + cmd/plexsphere/app.go | cmd/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 outage | internal/platform/bootstrap/spicedb_readiness.go | internal/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 changes | internal/platform/bootstrap/schema_applier.go + cmd/plexsphere/app.go | internal/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 deleted | cmd/plexsphere/{domains,projects,audit,identities,invitations,labels}_factory_prod.go | cmd/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.go | tests/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 boot | cmd/plexsphere/spicedb_required_prod.go + cmd/plexsphere/app.go | cmd/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.go | tests/workspace/no_fake_authorizer_in_integration_tests_test.go::TestNoFakeAuthorizerInIntegrationTests |
MapEvent covers every aggregate event in events.Type*; an unmapped constant fails the reflective gate | internal/authz/sync/mapping.go | internal/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 errors | internal/authz/sync/consumer.go + internal/authz/sync/scanner_pg.go | internal/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-end | internal/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 SLA | internal/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 revocation | internal/authz/sync/mapping.go::principalSubjectTypeAndID | internal/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 cycle | internal/transport/http/v1/auth/callback.go::Groups.Sync integration | internal/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 layers | internal/identity/authn/middleware/middleware.go + internal/authz/middleware/rebac.go | internal/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_violation | internal/authz/authorizer.go::classifyDenial | internal/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.go | docs/contexts/identity/rebac.md (this file) + docs/reference/platform/authz.md | tests/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 string | cmd/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::writePermissionDenied | internal/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 factory | cmd/plexsphere/admin_factory_prod.go::BuildProductionAdminFactory + cmd/plexsphere/main.go | cmd/plexsphere/admin_factory_prod_test.go::TestBuildProductionAdminFactory_RejectsNilAuthzCheck |
Cross-references
./tenancy.md— sibling bounded-context reference for the Domain, Project, Resource, Node aggregates the ReBAC hierarchy derivations sit on top of../groups.md— sibling bounded-context reference for the Group, GroupMembership, GroupParent aggregates the ReBAC layer consumes viagroups.Resolver. See the ReBAC consumer contract at./groups.md#rebac-consumer-contract-s009../idp.md— sibling bounded-context reference for the IdP binding, User, UserSession, ServiceIdentity, APIToken aggregates the authn layer (upstream of this doc) exposes as Principal values to the PrincipalMapper.../../reference/platform/authz.md— API reference forinternal/authz: Authorizer methods, ConsistencyOption, Session, error taxonomy.../../how-to/authorization/apply-the-rebac-schema.md— operator runbook for schema changes.../../architecture/storage-topology.md— storage topology including the SpiceDB ↔ Postgres node.../../reference/platform/db.md— pgx pool and goose migrations; the SpiceDB datastore shares the Postgres instance via thespicedblogical schema.../../../schema/authz.zed— canonical schema applied on every boot byApplySchema.../../../internal/authz/doc.go— bounded-context package doc for the authz module.../../../internal/authz/sync/— outbox-driven dual-write consumer (Consumer.Run,MapEvent,Scanner, the SLA constants) that mirrors aggregate writes into the SpiceDB tuple graph.../../../deploy/local/README.md— local-kind SpiceDB Deployment reference, preshared-key posture and the per-workload mTLS rollout note.../../../tests/e2e/identity/rebac-hierarchy/chainsaw-test.yaml— end-to-end Domain-admin → Resource derivation suite that exercises the hierarchy table above.