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 + owner + assigner
}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. Every principal that holds a relation on that Project then derivescloudcredential#usethrough theuse = uses + owner + assignerpermission. The sub-context never writes a per-userusestuple; the Project is the unit of consumption. usesdoes not derive fromparent. Thecloudcredentialdefinition carries aparent: cloudrelation, but no permission on it readsparent->…. A Cloud operator does not silently gainuseon every credential under the Cloud; theusepermission isuses + owner + assigneronly. This mirrors the escalation-control contract the identity reference documents for the other privileged object types — see../identity/rebac.md#privileged-object-types-4-definitions.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 | group#member
relation operator: user | serviceaccount | group#member
relation auditor: user | group#member
relation viewer: user | serviceaccount | group#member
permission manage = owner + cloud_admin
permission operate = owner + operator
permission observe = owner + operator + auditor + viewer
}| Relation | Subject types | Folded into |
|---|---|---|
owner | user, group#member | manage, operate, observe |
cloud_admin | user, group#member | manage only |
operator | user, serviceaccount, group#member | operate, observe |
auditor | user, group#member | observe only |
viewer | user, serviceaccount, group#member | observe only |
cloud_admin is the first-class Cloud mutation principal: it is folded into manage additively alongside owner, and never into operate or observe. viewer is the symmetric read-only role, folded into observe only. As with cloudcredential, no permission reads parent->…, so a Domain admin does not silently inherit Cloud mutation rights — the Cloud roles are explicit per-object grants.
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 without a separate grant.
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. A small boot seed therefore writes a single explicit grant for the demo platform-operator (operator, a fixed seed id): platform:plexsphere#admin. That grant folds into the platform manage permission — the gate Cloud creation (and blueprint registration) authorise against — so the operator can author the catalog the provisioning tutorial walks through building. The earlier cloud:<demo>#viewer and blueprint:<cloudless>#reader read grants are retired with the seeds they targeted.
The grant rides the operator's deterministic id rather than the bootstrap-generated project-owner id, which is why the tutorial authors the catalog as the operator and provisions as the owner.
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. 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 maintain the consuming Project | project:<project_id> | admin OR maintainer | Dual check — admin first, maintainer only if admin is denied, so a granted admin costs one round-trip. |
Approve | approver must be able to assign the credential | cloudcredential:<credential_id> | assign | Plus the self-approval denial — see below. |
Reject | rejecter must be able to assign the credential | cloudcredential:<credential_id> | assign | Same gate as Approve; a reject is also an assigner decision. |
Revoke | revoker must be able to assign the credential | cloudcredential:<credential_id> | assign | Same gate; revoking is withdrawing an assigner-granted edge. |
List | reader must observe 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 admin OR maintainer
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. The SpiceDB project definition exposes both admin and maintainer, and either is sufficient to request an assignment. admin is checked first because it is the more common grant for the principals that request assignments; maintainer is checked only when admin is denied. A single composite can_request permission was rejected — the embedded schema has no such permission, and adding one would couple the sub-context to a schema migration outside its scope.
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 comparison is on the raw ReBAC subject strings, so a principal cannot dodge it by re-spelling its own subject. The requester is threaded into ApproveInput.RequestedBy by the transport layer — the aggregate and its Postgres row carry no requested_by column, only the CredentialAssignmentRequested outbox event denormalises the requester, so the caller supplies it. A self-approval attempt is refused with ErrSelfApproval, which the transport layer maps to a 403.
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 canonical mapping is defined in internal/authz/sync/mapping.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.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.
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, ApproveCredentialAssignment, RejectCredentialAssignment, and RevokeCredentialAssignment. 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, threads the correlation id, and supplies ApproveInput.RequestedBy from the CredentialAssignmentRequested event the request trace already holds.
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 requester gate on Request, or the assign/read gate on the decision/list paths. |
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 + owner + assigner with no parent term | schema/authz.zed cloudcredential definition | internal/authz/schema_invariants_test.go |
The cloud definition carries cloud_admin (folded into manage only) and viewer (folded into observe only); no Cloud relation admits a project or cloudcredential userset | schema/authz.zed cloud definition | internal/authz/schema_invariants_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 |
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 | services/approve.go Approve (ErrSelfApproval) | internal/provisioning/credentialassignment/services/assignment_service_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.go— the pure event-to-tuple translation layer, including the five Credential Assignment arms.../../../schema/authz.zed— the canonical ReBAC schema carrying thecloudcredential#usesrelation.