Appearance
Provisioning & ReBAC — Credential Assignment, the cloudcredential#uses tuple, and the dual-write sync arms
This document is the authoritative bounded-context reference for how the Credential Assignment sub-context of the Plexsphere Provisioning & Lifecycle context drives the ReBAC authorisation graph. The sub-context ships under internal/provisioning/credentialassignment/ and models the lifecycle of binding a single cloud credential to a single project: an operator requests an assignment, a reviewer approves or rejects it, and an approved assignment may later be revoked. This page covers the cloudcredential#uses relation the sub-context owns in the schema, the four ReBAC permission gates the application service enforces on every transition, the synchronous tuple write/delete that gives the caller read-your-writes consistency, the outbox-backed sync mapping that mirrors each event into the SpiceDB tuple graph, and the audit contract every decision produces. The invariant-to-test matrix at the bottom pins every requirement to at least one automated test.
For the canonical ReBAC layer itself — the SpiceDB schema walk-through, the zedtoken consistency flow, the CEL caveats, the audit Entry shape, and the outbox consumer — see the identity bounded-context reference ../identity/rebac.md. This page does not restate that material; it documents only the slice the Credential Assignment sub-context adds on top of it.
Doc grouping vs. package layout. This page lives under
docs/contexts/provisioning/because Credential Assignment is a provisioning sub-context — it governs who may bind a Cloud Credential to a Project, and a Cloud Credential is provisioning-owned secret material. The ReBAC layer it consumes, however, lives in its own bounded-context package at../../../internal/authz/, and the canonical schema at../../../schema/authz.zed. When this page says "the Authorizer" it means a type ininternal/authz/; when it says "the service" it meansCredentialAssignmentServicein../../../internal/provisioning/credentialassignment/services/. The two are kept apart by theno-cross-context-importsdepguard rule: the service declares itsAuthorizerport locally rather than importinginternal/authz.
The cloudcredential#uses relation
The Credential Assignment sub-context owns exactly one relation in the canonical schema: cloudcredential#uses. The cloudcredential definition in schema/authz.zed is:
text
definition cloudcredential {
relation parent: cloud
relation owner: user | group#member
relation assigner: user | serviceaccount | group#member
relation uses: project | project#operator
permission manage = owner
permission assign = owner + assigner
permission use = (uses & parent->use) + owner + assigner
permission observe = owner + assigner + parent->manage + uses->read
}The uses relation is the seam between the Credential Assignment sub-context and the rest of the ReBAC graph. Three properties of it are worth freezing:
Subject type is
project(orproject#operator), not a principal. An assignment binds a consuming Project — as a whole — to the credential, and the sub-context never writes a per-userusestuple. Read the consequence carefully, because it is the opposite of what the shape suggests: a user never holdscloudcredential#use.usesnames theprojectOBJECT, not a subject set over its members, so aCheckcarrying a user subject cannot traverse it.useis asked with the consumingproject:<uuid>as the subject — that is exactly howresources.CreateResourcephrases the question "may this Project deploy with the credential?". What the Project's principals DO derive isobserve, through the separateuses->readarrow below.useINTERSECTS the assignment with the parent Cloud's ownuse. A Credential Assignment on its own does not make a credential usable: the consuming Project must also hold an approved Cloud Assignment for the Cloud the credential belongs to, which is whatparent->useresolves. Both halves, or nothing. Before the intersection,usesalone granteduse, and a Project could deploy against a Cloud its assigner never approved — binding a credential was enough to reach the Cloud underneath it, so naming a credential directly skipped the Cloud Assignment workflow entirely.The intersection does NOT hand a Cloud operator
useon every credential under the Cloud.parent->usenarrows the credential assignment; it never widens it, becauseownerandassigner— the only operands a principal can match — stay outside the parentheses, and a Cloud operator holds nocloudcredential#usestuple to intersect with. The escalation-control contract the identity reference documents for the other privileged object types therefore still holds — see../identity/rebac.md#privileged-object-types-4-definitions.observereads a parent arrow too, and picks a different one on purpose:parent->managerather thanparent->observe. The people who ADMINISTER a Cloud keep seeing every credential under it, which they must, sinceowned_byis optional at issuance and a credential may carry noownertuple at all. Readingparent->observeinstead would have re-imported the Cloud's ownuses->readand handed every consuming Project the full credential roster.Visibility follows the credential's OWN assignment.
observereadsuses->read, so an approved Credential Assignment is what reveals a credential to the consuming Project's principals — a Cloud Assignment alone does not. The two assignment workflows therefore stay independent: granting a Project a Cloud lets it see the Cloud and request credentials against it; granting a credential is what lets it see that credential. The read surfaces enforce this by addressing the credential rather than its parent Cloud — see therelationCredentialObserveDECISION ininternal/transport/http/v1/cloudcredentials/wiring.go.usesreplaced the unused legacyreaderrelation. The schema comment at theusesdeclaration records thatusesis the relation a Credential Assignment writes; it took the slot the earlierreaderrelation occupied before any assignment workflow existed.
The tuple a materialised assignment writes is therefore exactly:
text
cloudcredential:<credential_id>#uses@project:<project_id>The cloud definition — per-object Cloud roles
The schema change that introduced cloudcredential#uses also gave the cloud definition two first-class roles, so a Cloud can be governed per-object rather than only platform-wide. The cloud definition in schema/authz.zed is:
text
definition cloud {
relation parent: domain
relation owner: user | group#member
relation cloud_admin: user | serviceaccount | group#member
relation operator: user | serviceaccount | group#member
relation auditor: user | group#member
relation viewer: user | serviceaccount | group#member
relation uses: project | project#operator
permission manage = owner + cloud_admin
permission operate = owner + operator
permission observe = owner + cloud_admin + operator + auditor + viewer + uses->read
}| Relation | Subject types | Folded into |
|---|---|---|
owner | user, group#member | manage, operate, observe |
cloud_admin | user, serviceaccount, group#member | manage, observe |
operator | user, serviceaccount, group#member | operate, observe |
auditor | user, group#member | observe only |
viewer | user, serviceaccount, group#member | observe only |
uses | project, project#operator | use, and observe via uses->read |
cloud_admin is the first-class Cloud mutation principal: it is folded into manage additively alongside owner, and into observe as well — a principal that administers a Cloud must be able to read it, mirroring every other definition in the schema whose manage principal is also an observe principal. It is never folded into operate. viewer is the read-only role, folded into observe only. No permission reads parent->…, so a Domain admin does not silently inherit Cloud rights from the owning Domain — the direct Cloud roles are explicit per-object grants.
observe additionally reads uses->read, which is what makes an approved Cloud Assignment the thing that reveals a Cloud. The assignment writes cloud:<id>#uses@project:<id>, so everyone who can read the consuming Project — its admins, maintainers, operators and viewers, plus the owning Domain's admins through project#read's own parent->read — can observe the Cloud that Project may use. Without the arrow the assignment was functional but invisible: the uses subject is the project OBJECT rather than a subject set over its members, so no Check carrying a user subject could traverse it, and a Project deployed against a Cloud its own Domain admin could not GET. The arrow is folded into observe ONLY — manage, operate and assign stay on the direct relations, so consuming a Cloud never becomes administering it.
The cloud_admin edge is seeded automatically: when CloudService creates a Cloud it denormalises the creating principal onto the CloudCreated event, and the sync mapping (below) writes cloud:<cloud_id>#cloud_admin@<creator> so the creator holds per-object manage and observe without a separate grant — it can administer and read back the Cloud it created.
That seeding is why cloud_admin admits serviceaccount alongside user, exactly as provider_bundle#bundle_admin below does. Creating a Cloud is gated on platform#manage, and platform#admin reaches a service account through group#member — group.member admits serviceaccount — so a CI service account in a platform-admin group creates Clouds and the mapper seeds its subject. A relation that refused the subject type would make SpiceDB reject the seed; the sync consumer classifies a rejection on the merits as deterministic and surrenders the row for good, so the committed Cloud would keep no tuples at all. No permission on cloud reads parent->…, so nothing else resolves: every GET / PATCH / DELETE on it would answer 403 to everyone — creator and platform admin alike — while its slug stayed taken.
On the make dev stack the provisioning catalog now starts empty — there is no demo Cloud, demo Credential, or seeded Blueprint to grant read access on. The config-driven platform-operator grant seed therefore writes two explicit grants for the first Platform Operator's platform-login row — the user the Domain-less platform sign-in (plexctl login --platform, or the Console's platform sign-in) provisions under the reserved platform-operators Domain. The first, platform:plexsphere#admin, folds into the platform manage permission — the gate Cloud creation, catalog-global blueprint-catalog register/import, and blueprint registration authorise against — so the operator can author the catalog the provisioning tutorial walks through building. The second, managementfleet:fleet#admin, folds into the Management Fleet's manage permission (manage = admin) — the gate the operator-facing register a management cluster and terminate a Project's assignment surfaces authorise against — so the same operator manages the Fleet from the console instead of meeting a disabled control. The earlier cloud:<demo>#viewer and blueprint:<cloudless>#reader read grants are retired with the seeds they targeted, and the still-earlier shape that attached the platform grants to the operator's acme-corp tenant row is retired with them: platform authority belongs to the platform login, never to a tenant session, so a Domain admin can hold domain#manage without ever gaining the platform scope.
The grants ride the operator's deterministic platform-row id (the dev stack pins it so the demo CloudCredential seed can name the same identity as the credential owner), which is why the tutorials author the catalog as the platform-signed-in operator and provision as the owner.
The provider_bundle definition: platform-parented bundle roles
The Cloud Inventory sub-context's second aggregate, the ProviderBundle, carries its own definition. A bundle names one reusable Crossplane provider-package set, and every Cloud in bundle mode resolves its packages against it. The provider_bundle definition in schema/authz.zed is:
text
definition provider_bundle {
relation parent: platform
relation bundle_admin: user | serviceaccount | group#member
relation auditor: user | group#member
relation viewer: user | serviceaccount | group#member
permission manage = bundle_admin + parent->manage
permission observe = bundle_admin + auditor + viewer + parent->manage + parent->read
}| Relation | Subject types | Folded into |
|---|---|---|
parent | platform | manage via parent->manage; observe via parent->manage and parent->read |
bundle_admin | user, serviceaccount, group#member | manage, observe |
auditor | user, group#member | observe only |
viewer | user, serviceaccount, group#member | observe only |
Two properties set this definition apart from cloud above.
It parents onto the platform singleton, not a Domain. A bundle is platform-wide catalogue state: Clouds in any Domain resolve their packages against the same set, so scoping a bundle to one Domain would misdescribe what it is. The parent edge points at platform:plexsphere, the same singleton the bundle service already stamps on every audit row it writes (services/provider_bundle_service.go).
It derives from that parent, where cloud derives from nothing.platform#manage (which folds in the platform admin relation) carries through to both manage and observe, and platform#read (admin plus auditor) carries through to observe. A strict per-object mirror of the cloud definition, resolving only the explicit bundle relations, was rejected: there is no API for granting a per-bundle role, so the creator's bundle_admin seed is the only tuple a bundle ever gains. A bundle whose creator left the platform would be unmanageable without a raw tuple write, and the platform admins who create Clouds could not list the bundles they must pick one from. bundle_admin, auditor and viewer stay declared for the per-bundle grants a later surface may write. auditor and viewer fold into observe only, so a read-only role never reaches the mutation surface.
Both edges are seeded by the sync mapping below rather than by a separate grant. ProviderBundleService.Create hands the authorised subject to the repository, which denormalises it as created_by on the ProviderBundleCreated row it appends to the outbox, and the mapper writes the parent edge and the creator's bundle_admin grant together.
That seeding is why bundle_admin admits serviceaccount alongside user, the same reason cloud#cloud_admin does. Creating a bundle is gated on platform#manage, and platform#admin reaches a service account through group#member — group.member admits serviceaccount — so a CI service account in a platform-admin group creates bundles, and the mapper seeds its subject. Both tuples are written in one batch, so a relation that refused the subject type would make SpiceDB reject the batch whole and take the parent edge down with the grant: the committed bundle would end up with no tuples at all, invisible to every reader including platform admins, unpatchable, undeletable through the API, and holding its slug forever.
Lifecycle and ReBAC gates
A CredentialAssignment is a value-object aggregate with a closed four-state lifecycle. The legal transitions are:
text
requested → approved
requested → rejected
approved → revokedrejected and revoked are terminal. An approved assignment additionally carries a materialised marker recording that the cloudcredential#uses tuple has actually been written, so the domain can distinguish an approved-but-not-yet-wired assignment from a fully effective one.
Alongside the state, the aggregate records who moved it. A requested_by uuid names the principal that filed the request and is fixed at creation; no transition rewrites it. A decision trio — decided_by_subject, decided_at, decision_reason — is stamped by Approve, Reject and Revoke, and every one of those transitions refuses an empty deciding subject: a persisted decision that cannot name the principal behind it is unauditable. An approval carries no reason, so decision_reason stays empty until a rejection or a revocation supplies one. The aggregate is defined in credentialassignment.go; the ubiquitous language is pinned in doc.go.
Every transition is routed through CredentialAssignmentService (services/assignment_service.go, services/approve.go, services/revoke.go), which gates the transition on a ReBAC Authorizer.Check before the aggregate's pure transition method runs. The gate per operation:
| Operation | ReBAC gate | Object checked | Permission | Notes |
|---|---|---|---|---|
Request | requester must be able to deploy into the consuming Project | project:<project_id> | deploy | Single check. |
Approve | approver must be able to assign the credential | cloudcredential:<credential_id> | assign | Plus the self-approval denial — see below. Invoked from the approvals queue. |
Reject | rejecter must be able to assign the credential | cloudcredential:<credential_id> | assign | Same gate as Approve; a reject is also an assigner decision. Invoked from the approvals queue. |
Revoke | revoker must be able to assign the credential, or to deploy into the consuming Project | cloudcredential:<credential_id>, then project:<project_id> | assign, then deploy | Two-party — see below. |
List | reader must read the consuming Project | project:<project_id> | read | Pure read; no tuple mutation. |
The assign permission resolves to owner + assigner on the cloudcredential definition — neither term derives from a parent — so the principal deciding an assignment must hold an explicit credential-side grant. Owning the Cloud is not enough.
Why the requester gate is the Project's deploy
Request is gated on the Project, not the credential: the principal asking for an assignment is a member of the consuming Project, not necessarily an assigner of the credential. deploy resolves to admin + maintainer + parent->manage, so a Project admin, a Project maintainer, and the Domain admin above them can all open a request, while a Project viewer or operator cannot. Naming the composite rather than checking admin and maintainer as two relations keeps the gate one round-trip and keeps the Domain-admin case working without a bespoke per-Project grant. It is the same permission the Cloud Assignment request and resources.CreateResource gate on, so "may commit this Project to spend" is one answer across the three.
Why Revoke accepts two relations
Revocation is the only transition open to either party. The service checks assign on the Cloud Credential first and, on that denial, deploy on the consuming Project; either grants, and only a caller that both deny is refused. Granting means the credential's assigner can pull a credential back and a Project deployer can hand it back, neither waiting on the other — an assignment is a mutual arrangement, and requiring the credential side for teardown would leave a Project holding spend authority it no longer wants.
The two legs are not audited individually. A single denial row is written when both fail, naming the Cloud Credential as the object and deploy as the missing relation; on success the audit row's relation path carries whichever of the two granted, so the trail names the party that revoked. The tuple delete does not vary with the deciding party: the narrow (cloudcredential, uses, project) filter is built from the aggregate alone.
Where the approve and reject decisions arrive from
Approve and Reject have no HTTP surface of their own. They are reached through the platform-wide decision queue at GET /v1/approvals and POST /v1/approvals/{id}/{approve,reject}, which resolves the row's family, authorises the caller against the Cloud Credential the row spends, and calls the methods above. The transition table, the tuple write, and the self-approval guard stay enforced here — the queue changes who reads the inbox, not who decides what is legal. See approvals.
Self-approval denial
Approve carries one gate the others do not: the approving principal must not be the principal that originally requested the assignment. An assignment must be decided by a second party. The service loads the assignment first and reads requested_by off it, so the requester is never a caller-supplied value the guard would have to trust. The comparison is on the principal uuids the subject strings carry, so a principal cannot dodge it by re-spelling its own subject with a different kind prefix. A self-approval attempt is refused with ErrSelfApproval, which the transport layer maps to a 403.
An assignment whose requested_by is NULL fails Approve with an error carrying no sentinel, which the transport funnel renders as a 500. The column is NULL only on a row written outside the application path, whose requester migration 0071 could not recover from the CredentialAssignmentRequested outbox event. That is an operator incident rather than a caller fault, and letting the approval through would silently bypass the four-eyes rule for exactly the rows that cannot satisfy it. Reject and Revoke consult no requester, so such a row stays decidable: a rejection grants nothing, and a revocation withdraws access rather than conferring it.
Synchronous tuple write and the outbox backstop
Approving and revoking an assignment each mutate the cloudcredential#uses tuple set. The sub-context writes that mutation twice, deliberately, and the two writes converge on the exact same tuple:
- Synchronous arm — inside the service method.
ApprovecallsAuthorizer.Writewith theusesrelationship before the repository transaction commits;RevokecallsAuthorizer.Deletewith a narrow filter the same way. The realAuthorizercaptures the write zedtoken into the per-request session, so a follow-upCheckon the samectxis read-your-writes. This is the write-to-check consistency the approval flow promises the caller: the momentApprovereturns, the caller's nextCheckon the same request observes the grant. - Outbox backstop —
internal/authz/sync.MapEvent. The same transaction that persists the state transition appends aCredentialAssignmentMaterialised(forApprove) orCredentialAssignmentRevoked(forRevoke) outbox event. The outbox consumer drains it andMapEventtranslates it into the sameAuthorizer.Write/Authorizer.Deletecall.Authorizer.Writehas TOUCH semantics, so the duplicate write across the synchronous arm and the relayed event is a no-op at the SpiceDB layer rather than a conflict, and theusesedge re-converges if the event is replayed on its own.
Relying on the outbox arm alone was rejected: it leaves a window where the approval has committed but the caller's immediate Check still denies, coupling correctness to outbox relay latency. Relying on the synchronous arm alone was rejected too: a process crash between the synchronous write and the commit would leave the graph ahead of the durable state with no replay path. Both arms must exist — the service owns the synchronous arm, the committed sync mapping owns the backstop.
The Relationship and DeleteFilter types the service passes are declared locally in the services package as mirrors of authz.Relationship / authz.DeleteFilter, so the package does not import internal/authz; the production adapter bridges the local ports onto the real Authorizer at the composition root.
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. The registry and its MapEvent lookup live in internal/authz/sync/mapping.go, and the provisioning context's mappers — including the five Credential Assignment mappers — are registered in internal/authz/sync/mapping_provisioning.go; this table mirrors the truth there for the five Credential Assignment events and MUST stay in lockstep.
| Assignment event | Write | Delete |
|---|---|---|
credentialassignment.CredentialAssignmentRequested | — (a pending request never granted access) | — |
credentialassignment.CredentialAssignmentGranted | project:<project_id> -[uses]-> cloudcredential:<cloud_credential_id> (the owner push — same idempotent write, emitted alone with no Approved/Materialised pair) | — |
credentialassignment.CredentialAssignmentApproved | project:<project_id> -[uses]-> cloudcredential:<cloud_credential_id> | — |
credentialassignment.CredentialAssignmentMaterialised | project:<project_id> -[uses]-> cloudcredential:<cloud_credential_id> (same idempotent write as Approved) | — |
credentialassignment.CredentialAssignmentRejected | — (a declined request never granted access) | — |
credentialassignment.CredentialAssignmentRevoked | — | narrow (cloudcredential:<cloud_credential_id>, uses, project, <project_id>) |
Two mapping decisions are worth reading aloud:
- Approved and Materialised emit the SAME write.
Approveis the application-service decision;Materialiseis the system step confirming the tuple landed. Emitting the idempotent write on both is deliberate — TOUCH semantics make the duplicate a no-op, and it makes theusesedge resilient to either event being replayed on its own. Emitting the write only onMaterialisedwas rejected: it would leave the graph stale between Approve and Materialise and couple correctness to a strict event ordering the outbox does not guarantee. - Revoked deletes NARROWLY. The
CredentialAssignmentRevokedfilter pins bothResourceID(the credential) andSubjectID(this project), so a sibling Project assigned the same credential keeps its access. This is the same narrow-delete shape theGroupMemberRemovedarm uses, and the deliberate opposite of the wholesaleuses-purge thatCloudCredentialRevoked/CloudCredentialExpiredperform when the credential itself is withdrawn. Revoking one assignment must not touch another.
Cloud and Cloud Credential lifecycle arms
The same schema change wired the cloud and cloudcredential lifecycle events into MapEvent, so the two object types those definitions describe are seeded and retracted automatically rather than by a separate grant:
| Lifecycle event | Write | Delete |
|---|---|---|
cloud.CloudCreated | cloud:<cloud_id> <-[cloud_admin]- <creator> | — |
cloud.CloudUpdated | — (attribute-only change, no graph effect) | — |
cloud.CloudDeleted | — | wholesale (cloud:<cloud_id>) — every tuple under the Cloud |
cloudcredentials.CloudCredentialIssued | cloudcredential:<credential_id> <-[parent]- cloud:<cloud_id>, plus cloudcredential:<credential_id> <-[owner]- <owned_by> when the issuer named an owner | — |
cloudcredentials.CloudCredentialRotated | — (secret-only change, no graph effect) | — |
cloudcredentials.CloudCredentialRevoked / CloudCredentialExpired | — | every uses edge under cloudcredential:<credential_id> |
CloudCredentialIssued carries an OPTIONAL owned_by subject: an issuance with no named owner writes only the parent edge. The credential delete arms purge uses only — the broker row survives revocation and expiry, so the credential's parent and owner edges stay for the lifecycle surfaces that still address the object. This is the wholesale counterpart of the narrow per-assignment Revoke above: CloudCredentialRevoked withdraws every Project's access because the credential itself is withdrawn, whereas CredentialAssignmentRevoked withdraws one Project's.
The parallel Cloud Assignment arms — cloud#uses
The sibling Cloud Assignment context binds a Cloud (not a Cloud Credential) to a Project through the same workflow, materialising onto the parallel cloud#uses relation. Its events map into MapEvent exactly as the Credential Assignment arms do, but onto cloud rather than cloudcredential and with one extra event — the operator-push CloudAssignmentGranted:
| Cloud Assignment event | Write | Delete |
|---|---|---|
cloudassignment.CloudAssignmentGranted / Approved / Materialised | cloud:<cloud_id> <-[uses]- project:<project_id> | — |
cloudassignment.CloudAssignmentRevoked | — | narrow (cloud:<cloud_id>, uses, project:<project_id>) |
cloudassignment.CloudAssignmentRequested / Rejected | — (no access yet / declined) | — |
The operator Grant path produces an immediately approved + materialised assignment in one authoritative action, so CloudAssignmentGranted materialises the uses edge on its own; the request/approve path writes the same idempotent edge on Approved / Materialised. The payloads denormalise cloud_id and project_id as [16]byte arrays decoded by the same uuidArrayField helper described next.
The ProviderBundle lifecycle arms: provider_bundle#parent and #bundle_admin
The Cloud Inventory sub-context's bundle events are wired into MapEvent the same way its Cloud events are, so a bundle is reachable and administrable the moment it exists:
| Lifecycle event | Write | Delete |
|---|---|---|
cloud.ProviderBundleCreated | provider_bundle:<bundle_id> <-[parent]- platform:plexsphere, plus provider_bundle:<bundle_id> <-[bundle_admin]- <created_by> | — |
cloud.ProviderBundleUpdated | — (attribute-only change, no graph effect) | — |
cloud.ProviderBundleDeleted | — | wholesale (provider_bundle:<bundle_id>), every tuple under the bundle |
Neither write is optional, and they carry different weight. The bundle_admin edge is the creator's own grant, the counterpart of the cloud_admin seed on CloudCreated. The parent edge is what makes the bundle visible to anyone else at all: without it, parent->manage and parent->read resolve to nothing and the platform admins who pick a bundle when creating a Cloud would meet a denial on every bundle but the ones they created themselves.
The delete arm purges wholesale, like CloudDeleted and unlike the credential arms above, because the aggregate really is gone. It does not chase the Clouds that referenced the bundle, and it does not have to: a Cloud's reference lives in its own row rather than in the graph, and the aggregate refuses a delete while any Cloud still points at it (see Cloud Inventory).
ProviderBundleCreated and ProviderBundleDeleted carry bundle_id as a hyphenated string, not the [16]byte number array the assignment payloads use, so both arms read it with stringField rather than the uuidArrayField helper described below. cloud.BundleID implements MarshalText, so encoding/json renders it in the canonical 8-4-4-4-12 form, exactly as cloud.ID does for the Cloud arms.
The Blueprint lifecycle arm — blueprint#owner and blueprint#parent
The Blueprint Catalog wires its registration event into MapEvent the same way, so a Blueprint's per-object grants are seeded automatically rather than by a separate write:
| Lifecycle event | Write | Delete |
|---|---|---|
blueprintcatalog.BlueprintRegistered (catalog-global) | blueprint:<blueprint_id> <-[owner]- <created_by> | — |
blueprintcatalog.BlueprintRegistered (Domain-scoped) | blueprint:<blueprint_id> <-[owner]- <created_by>, plus blueprint:<blueprint_id> <-[parent]- domain:<domain_id> | — |
blueprintcatalog.BlueprintVersionPublished / BlueprintRetired | — (no graph effect — a version inherits the Blueprint's grants; a retired Blueprint stays readable to its holders) | — |
BlueprintRegistered carries an OPTIONAL domain_id — the exact shape of the OPTIONAL owned_by on CloudCredentialIssued above. A catalog-global registration omits the key and writes only the owner tuple, so the Blueprint stays privileged. A Domain-scoped registration (a catalog imported into a Domain) ALSO writes the blueprint:<id>#parent@domain:<id> edge, and the schema's
text
permission read = owner + publisher + reader + parent->readmakes the Blueprint discoverable to that Domain's members, because domain#read folds in member. publish and manage derive from owner / publisher only — they do NOT inherit parent->, so a member gains read discovery without any authoring rights. The owner subject is always a user reference (registration is gated on a manage permission whose admitting relations accept only user principals), so the blueprint#owner relation accepts it.
The Management Fleet lifecycle arm — managementcluster#parent
The Management Fleet wires its cluster-registration event into MapEvent so a registered management cluster is linked into the fleet hierarchy automatically rather than by a separate grant:
| Lifecycle event | Write | Delete |
|---|---|---|
managementfleet.ManagementClusterRegistered | managementcluster:<cluster_id> <-[parent]- managementfleet:fleet | — |
managementfleet.ProjectClusterAssigned / ProjectNamespaceReady / ProjectNamespaceTerminated | — (no graph effect — a Project's placement and its namespace lifecycle are not ReBAC objects) | — |
The Management Fleet is a singleton (managementfleet:fleet) that gates the fleet-wide operator surfaces — register a cluster, list the fleet, inspect or terminate a Project's assignment. The per-cluster read paths (get one cluster, list its Project assignments) instead gate on the individual managementcluster:<id> object, whose schema
text
permission observe = owner + operator + auditor + parent->observe
permission manage = owner + parent->managederives a fleet admin/operator/auditor's per-cluster access from the parent edge this arm writes — the same parent-derivation cloudcredential takes from cloud. Without the edge a fleet admin who can list and register clusters still meets a denial on every cluster detail read, because parent->observe resolves to nothing. The edge is minted transactionally with the cluster row: RegisterCluster appends a ManagementClusterRegistered row to the shared outbox in the same transaction as the INSERT, and the sync consumer mirrors it here. The event types ClusterID as [16]byte, so cluster_id is decoded by the same uuidArrayField helper described next. The three lifecycle events are explicit no-ops: a Project's placement onto a cluster and its namespace transitions carry no ReBAC edge.
Payload id encoding — uuidArrayField
The Credential Assignment event structs type their identity fields as raw [16]byte — the sub-context deliberately avoids importing a UUID type to stay an anti-corruption boundary. encoding/json renders a fixed-size byte array as a JSON number array, not the hyphenated string the tenancy events produce. MapEvent therefore reads cloud_credential_id and project_id from the payload via uuidArrayField (the [16]byte counterpart of stringField), which decodes the 16-element number array and re-renders it as the canonical 8-4-4-4-12 hyphenated form so SpiceDB object ids stay consistent across bounded contexts. A payload whose array is the wrong length, holds a non-number element, or holds an out-of-byte-range value fails the mapping rather than writing a malformed tuple.
Audit contract
Every accepted transition emits exactly one names-only audit row through the service's AuditSink port. The row is the services-local AuditEntry, mirroring the canonical internal/audit.Entry plus a CaveatContext map carrying NAMES-only metadata. The composition root bridges the local port onto the canonical audit.Sink via a one-method adapter.
| Operation | Audit Relation | Audit Object | CaveatContext keys |
|---|---|---|---|
Request | credentialassignment.request | cloudcredential:<credential_id> | project_id, cloud_credential_id, assignment_id |
Approve | credentialassignment.approve | cloudcredential:<credential_id> | project_id, cloud_credential_id, assignment_id |
Reject | credentialassignment.reject | cloudcredential:<credential_id> | project_id, cloud_credential_id, assignment_id |
Revoke | credentialassignment.revoke | cloudcredential:<credential_id> | project_id, cloud_credential_id, assignment_id |
List | credentialassignment.list | project:<project_id> | project_id |
Two contract properties hold:
- Values are identifiers, never secret material. The
CaveatContextkeys are stable identifiers and the values are the hyphenated UUID strings of the project, credential, and assignment. The credential's secret bytes never reach the audit row because the service never holds them — assignment governs who may use a credential, not the secret itself. - One row per granted decision. Each accepted transition emits exactly one row with
Outcome = "granted". A denial surfaces as an error to the caller, not as an audit row from this service — the ReBAC middleware that performed theCheckowns the denial audit entry, and double-counting it here would inflate the audit stream.
Audit-sink failures are made loud via slog but are NOT propagated to the caller: a flaky audit backend cannot turn a successful write into a user-visible 5xx. A nil audit sink degrades silently, so the unit tier can run without an audit recorder while production always wires a real sink.
Transport surface
The Credential Assignment HTTP surface is generated into the v1 server interface and dispatched through internal/transport/http/v1/handlers/credentialassignments_dispatch.go: RequestCredentialAssignment, ListCredentialAssignments, and RevokeCredentialAssignment. Approve and Reject have no dispatch entry of their own: the approvals queue's decision handlers call them through the composition root. Each handler delegates to the hand-written transport package when the dependency bundle is wired via SetCredentialAssignments, and otherwise falls through to a shared RFC 9457 Problem (501, credential_assignments_not_provisioned) so an operator hitting the endpoint before wiring lands sees a precise breadcrumb rather than an opaque failure. The transport layer is the seam that resolves the caller's ReBAC subject and threads the correlation id. It supplies no requester: the self-approval guard reads that off the persisted assignment.
The service funnels three sentinels the transport layer branches on via errors.Is:
| Sentinel | HTTP status | Raised when |
|---|---|---|
ErrPermissionDenied | 403 | Any ReBAC gate denies the principal — the deploy gate on Request, the assign gate on a decision, the read gate on List, or both legs of the two-party Revoke gate. |
ErrSelfApproval | 403 | Approve is called by the principal that requested the assignment. |
ErrDuplicateAssignment | 409 | Request hits the partial live-unique index — a second live (project, credential) assignment already exists. |
Invariant-to-test matrix
Every invariant this sub-context enforces against the ReBAC layer is backed by at least one automated test.
| Invariant | Enforced at | Test |
|---|---|---|
cloudcredential#uses admits project / project#operator only and use derives from (uses & parent->use) + owner + assigner | schema/authz.zed cloudcredential definition | internal/authz/schema_invariants_test.go |
A Credential Assignment alone does NOT make a credential usable — the consuming Project must also hold an approved Cloud Assignment for the credential's parent Cloud, and a Project holding only the Credential Assignment is denied use | schema/authz.zed cloudcredential#use uses & parent->use | tests/integration/cloud_assignment_visibility_real_spicedb_test.go + tests/e2e/identity/rebac-hierarchy/chainsaw-test.yaml |
| Opening a Credential Assignment into a Project with no Cloud Assignment for the credential's parent Cloud is refused up front on both write directions, with the problem code that names the missing assignment | the composition-root adapter's Request / Grant in cmd/plexsphere/credentialassignments_factory_prod.go | cmd/plexsphere/credentialassignments_factory_prod_test.go |
The cloud definition carries cloud_admin (folded into manage and observe) and viewer (folded into observe only) | schema/authz.zed cloud definition | internal/authz/schema_invariants_test.go |
Both relations the create sync arms seed with the creating principal — cloud#cloud_admin and provider_bundle#bundle_admin — admit serviceaccount, so a service account holding platform#manage through a group does not orphan the aggregate it creates | schema/authz.zed cloud and provider_bundle definitions | internal/authz/schema_invariants_test.go, tests/integration/provider_bundles_authz_real_spicedb_test.go |
An approved Cloud Assignment makes the Cloud observable to everyone who can read the consuming Project, and confers no manage / operate / assign | schema/authz.zed cloud#observe uses->read | tests/integration/cloud_assignment_visibility_real_spicedb_test.go |
A Cloud Assignment alone does NOT reveal the Cloud's credentials — credential visibility follows the credential's own assignment, while a Cloud's administrators keep the full roster via parent->manage | schema/authz.zed cloudcredential#observe + the credential read handlers | tests/integration/cloud_assignment_visibility_real_spicedb_test.go + internal/transport/http/v1/cloudcredentials/list_test.go |
MapEvent seeds cloud#cloud_admin on CloudCreated, wholesale-purges the Cloud on CloudDeleted, seeds cloudcredential#parent/#owner on CloudCredentialIssued, and purges cloudcredential#uses on CloudCredentialRevoked/Expired | internal/authz/sync/mapping.go cloud and cloud-credential arms | internal/authz/sync/mapping_test.go |
The provider_bundle definition is declared, parents onto platform, and derives manage from parent->manage and observe from parent->manage + parent->read, so a platform admin manages and reads every bundle and a platform auditor only reads; auditor and viewer never reach manage | schema/authz.zed provider_bundle definition | internal/authz/schema_invariants_test.go, tests/integration/provider_bundles_authz_real_spicedb_test.go |
MapEvent seeds provider_bundle#parent into platform:plexsphere and provider_bundle#bundle_admin for the creator on ProviderBundleCreated, wholesale-purges the bundle on ProviderBundleDeleted, and treats ProviderBundleUpdated as an explicit no-op | internal/authz/sync/mapping_provisioning.go provider-bundle arms | internal/authz/sync/mapping_test.go |
MapEvent seeds managementcluster#parent into managementfleet:fleet on ManagementClusterRegistered (lighting up the schema's parent->observe/parent->manage per-cluster derivation) and treats the three fleet lifecycle events as explicit no-ops | internal/authz/sync/mapping.go management-fleet arms | internal/authz/sync/mapping_test.go, tests/integration/managementfleet_outbox_authz_test.go |
Request is gated on the consuming Project's admin OR maintainer; a non-assignable credential is refused with ErrCredentialNotAssignable | services/assignment_service.go Request | internal/provisioning/credentialassignment/services/assignment_service_test.go |
Approve / Reject / Revoke are gated on the credential's assign permission | services/approve.go, services/revoke.go | internal/provisioning/credentialassignment/services/assignment_service_test.go |
Approve refuses self-approval — the approver may not be the requester the row records | services/approve.go Approve (ErrSelfApproval) | internal/provisioning/credentialassignment/services/assignment_service_test.go |
Approve refuses a row with no recorded requester, and carries no sentinel so it renders as a 500; Reject and Revoke still decide such a row | services/approve.go Approve | internal/provisioning/credentialassignment/services/assignment_service_test.go |
| Every decision transition stamps the deciding subject and instant, and refuses an empty decider | credentialassignment.go Approve / Reject / Revoke | internal/provisioning/credentialassignment/credentialassignment_test.go |
Approve writes the cloudcredential#uses tuple synchronously before the transaction commits so a follow-up Check is read-your-writes | services/approve.go usesRelationship | internal/provisioning/credentialassignment/services/assignment_service_test.go |
Revoke removes the uses tuple via a NARROW DeleteFilter pinning both credential and project, leaving sibling projects untouched | services/revoke.go usesDeleteFilter | internal/provisioning/credentialassignment/services/assignment_service_test.go |
MapEvent maps Approved/Materialised to the same idempotent uses write and Revoked to the narrow uses delete; Requested/Rejected are explicit no-ops | internal/authz/sync/mapping.go Credential Assignment arms | internal/authz/sync/mapping_test.go |
uuidArrayField decodes the [16]byte JSON number-array payload shape into the canonical hyphenated UUID | internal/authz/sync/mapping.go uuidArrayField | internal/authz/sync/mapping_test.go |
Each accepted transition emits exactly one names-only audit row with Outcome = "granted"; the credential secret never reaches CaveatContext | services/assignment_service.go emit | internal/provisioning/credentialassignment/services/assignment_service_test.go |
The synchronous write and the outbox-relayed event converge on the same uses edge end-to-end | service synchronous arm + MapEvent backstop | tests/integration/ credential-assignment authz suite |
The request → approve → revoke flow grants and withdraws cloudcredential#use for the consuming Project against a real SpiceDB | full lifecycle | tests/e2e/ credential-assignment ReBAC suite |
Cross-references
../identity/rebac.md— the canonical ReBAC bounded-context reference: SpiceDB schema walk-through, zedtoken consistency flow, CEL caveats, the auditEntryshape, the dual-write outbox, and the event-to-tuple mapping for the tenancy / identity aggregates../credential-pool.md— the Cloud Credentials Custodian sub-context that owns thecloudcredentialaggregate and its lifecycle events;CloudCredentialRevoked/CloudCredentialExpiredpurge everyusestuple under a credential, the wholesale counterpart of the narrow per-assignmentRevokedocumented here../credentials.md— the OpenBao Credential Broker sub-context, the project-scoped sibling of the Cloud Credentials Custodian.../index.md— the bounded-context family index.../../../internal/provisioning/credentialassignment/— the Credential Assignment sub-context package: the aggregate, the five domain events, the repository adapter, and the application service.../../../internal/authz/sync/mapping_provisioning.go— the provisioning context's event-to-tuple mappers, including the five Credential Assignment mappers.../../../schema/authz.zed— the canonical ReBAC schema carrying thecloudcredential#usesrelation.