Skip to content

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 in internal/authz/; when it says "the service" it means CredentialAssignmentService in ../../../internal/provisioning/credentialassignment/services/. The two are kept apart by the no-cross-context-imports depguard rule: the service declares its Authorizer port locally rather than importing internal/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 (or project#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 derives cloudcredential#use through the use = uses + owner + assigner permission. The sub-context never writes a per-user uses tuple; the Project is the unit of consumption.
  • uses does not derive from parent. The cloudcredential definition carries a parent: cloud relation, but no permission on it reads parent->…. A Cloud operator does not silently gain use on every credential under the Cloud; the use permission is uses + owner + assigner only. 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.
  • uses replaced the unused legacy reader relation. The schema comment at the uses declaration records that uses is the relation a Credential Assignment writes; it took the slot the earlier reader relation 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
}
RelationSubject typesFolded into
owneruser, group#membermanage, operate, observe
cloud_adminuser, group#membermanage only
operatoruser, serviceaccount, group#memberoperate, observe
auditoruser, group#memberobserve only
vieweruser, serviceaccount, group#memberobserve 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  → revoked

rejected 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:

OperationReBAC gateObject checkedPermissionNotes
Requestrequester must maintain the consuming Projectproject:<project_id>admin OR maintainerDual check — admin first, maintainer only if admin is denied, so a granted admin costs one round-trip.
Approveapprover must be able to assign the credentialcloudcredential:<credential_id>assignPlus the self-approval denial — see below.
Rejectrejecter must be able to assign the credentialcloudcredential:<credential_id>assignSame gate as Approve; a reject is also an assigner decision.
Revokerevoker must be able to assign the credentialcloudcredential:<credential_id>assignSame gate; revoking is withdrawing an assigner-granted edge.
Listreader must observe the consuming Projectproject:<project_id>readPure 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:

  1. Synchronous arm — inside the service method. Approve calls Authorizer.Write with the uses relationship before the repository transaction commits; Revoke calls Authorizer.Delete with a narrow filter the same way. The real Authorizer captures the write zedtoken into the per-request session, so a follow-up Check on the same ctx is read-your-writes. This is the write-to-check consistency the approval flow promises the caller: the moment Approve returns, the caller's next Check on the same request observes the grant.
  2. Outbox backstop — internal/authz/sync.MapEvent. The same transaction that persists the state transition appends a CredentialAssignmentMaterialised (for Approve) or CredentialAssignmentRevoked (for Revoke) outbox event. The outbox consumer drains it and MapEvent translates it into the same Authorizer.Write / Authorizer.Delete call. Authorizer.Write has 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 the uses edge 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 eventWriteDelete
credentialassignment.CredentialAssignmentRequested(a pending request never granted access)
credentialassignment.CredentialAssignmentApprovedproject:<project_id> -[uses]-> cloudcredential:<cloud_credential_id>
credentialassignment.CredentialAssignmentMaterialisedproject:<project_id> -[uses]-> cloudcredential:<cloud_credential_id> (same idempotent write as Approved)
credentialassignment.CredentialAssignmentRejected(a declined request never granted access)
credentialassignment.CredentialAssignmentRevokednarrow (cloudcredential:<cloud_credential_id>, uses, project, <project_id>)

Two mapping decisions are worth reading aloud:

  • Approved and Materialised emit the SAME write. Approve is the application-service decision; Materialise is 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 the uses edge resilient to either event being replayed on its own. Emitting the write only on Materialised was 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 CredentialAssignmentRevoked filter pins both ResourceID (the credential) and SubjectID (this project), so a sibling Project assigned the same credential keeps its access. This is the same narrow-delete shape the GroupMemberRemoved arm uses, and the deliberate opposite of the wholesale uses-purge that CloudCredentialRevoked / CloudCredentialExpired perform 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 eventWriteDelete
cloud.CloudCreatedcloud:<cloud_id> <-[cloud_admin]- <creator>
cloud.CloudUpdated(attribute-only change, no graph effect)
cloud.CloudDeletedwholesale (cloud:<cloud_id>) — every tuple under the Cloud
cloudcredentials.CloudCredentialIssuedcloudcredential:<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 / CloudCredentialExpiredevery 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.

OperationAudit RelationAudit ObjectCaveatContext keys
Requestcredentialassignment.requestcloudcredential:<credential_id>project_id, cloud_credential_id, assignment_id
Approvecredentialassignment.approvecloudcredential:<credential_id>project_id, cloud_credential_id, assignment_id
Rejectcredentialassignment.rejectcloudcredential:<credential_id>project_id, cloud_credential_id, assignment_id
Revokecredentialassignment.revokecloudcredential:<credential_id>project_id, cloud_credential_id, assignment_id
Listcredentialassignment.listproject:<project_id>project_id

Two contract properties hold:

  • Values are identifiers, never secret material. The CaveatContext keys 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 the Check owns 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:

SentinelHTTP statusRaised when
ErrPermissionDenied403Any ReBAC gate denies the principal — the requester gate on Request, or the assign/read gate on the decision/list paths.
ErrSelfApproval403Approve is called by the principal that requested the assignment.
ErrDuplicateAssignment409Request 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.

InvariantEnforced atTest
cloudcredential#uses admits project / project#operator only and use derives from uses + owner + assigner with no parent termschema/authz.zed cloudcredential definitioninternal/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 usersetschema/authz.zed cloud definitioninternal/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/Expiredinternal/authz/sync/mapping.go cloud and cloud-credential armsinternal/authz/sync/mapping_test.go
Request is gated on the consuming Project's admin OR maintainer; a non-assignable credential is refused with ErrCredentialNotAssignableservices/assignment_service.go Requestinternal/provisioning/credentialassignment/services/assignment_service_test.go
Approve / Reject / Revoke are gated on the credential's assign permissionservices/approve.go, services/revoke.gointernal/provisioning/credentialassignment/services/assignment_service_test.go
Approve refuses self-approval — the approver may not be the requesterservices/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-writesservices/approve.go usesRelationshipinternal/provisioning/credentialassignment/services/assignment_service_test.go
Revoke removes the uses tuple via a NARROW DeleteFilter pinning both credential and project, leaving sibling projects untouchedservices/revoke.go usesDeleteFilterinternal/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-opsinternal/authz/sync/mapping.go Credential Assignment armsinternal/authz/sync/mapping_test.go
uuidArrayField decodes the [16]byte JSON number-array payload shape into the canonical hyphenated UUIDinternal/authz/sync/mapping.go uuidArrayFieldinternal/authz/sync/mapping_test.go
Each accepted transition emits exactly one names-only audit row with Outcome = "granted"; the credential secret never reaches CaveatContextservices/assignment_service.go emitinternal/provisioning/credentialassignment/services/assignment_service_test.go
The synchronous write and the outbox-relayed event converge on the same uses edge end-to-endservice synchronous arm + MapEvent backstoptests/integration/ credential-assignment authz suite
The request → approve → revoke flow grants and withdraws cloudcredential#use for the consuming Project against a real SpiceDBfull lifecycletests/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 audit Entry shape, 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 the cloudcredential aggregate and its lifecycle events; CloudCredentialRevoked / CloudCredentialExpired purge every uses tuple under a credential, the wholesale counterpart of the narrow per-assignment Revoke documented 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 the cloudcredential#uses relation.