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 & 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 (or project#operator), not a principal. An assignment binds a consuming Project — as a whole — to the credential, and the sub-context never writes a per-user uses tuple. Read the consequence carefully, because it is the opposite of what the shape suggests: a user never holds cloudcredential#use. uses names the project OBJECT, not a subject set over its members, so a Check carrying a user subject cannot traverse it. use is asked with the consuming project:<uuid> as the subject — that is exactly how resources.CreateResource phrases the question "may this Project deploy with the credential?". What the Project's principals DO derive is observe, through the separate uses->read arrow below.

  • use INTERSECTS the assignment with the parent Cloud's own use. 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 what parent->use resolves. Both halves, or nothing. Before the intersection, uses alone granted use, 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 use on every credential under the Cloud. parent->use narrows the credential assignment; it never widens it, because owner and assigner — the only operands a principal can match — stay outside the parentheses, and a Cloud operator holds no cloudcredential#uses tuple 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.

    observe reads a parent arrow too, and picks a different one on purpose: parent->manage rather than parent->observe. The people who ADMINISTER a Cloud keep seeing every credential under it, which they must, since owned_by is optional at issuance and a credential may carry no owner tuple at all. Reading parent->observe instead would have re-imported the Cloud's own uses->read and handed every consuming Project the full credential roster.

  • Visibility follows the credential's OWN assignment. observe reads uses->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 the relationCredentialObserve DECISION in internal/transport/http/v1/cloudcredentials/wiring.go.

  • 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 | 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
}
RelationSubject typesFolded into
owneruser, group#membermanage, operate, observe
cloud_adminuser, serviceaccount, group#membermanage, observe
operatoruser, serviceaccount, group#memberoperate, observe
auditoruser, group#memberobserve only
vieweruser, serviceaccount, group#memberobserve only
usesproject, project#operatoruse, 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#membergroup.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
}
RelationSubject typesFolded into
parentplatformmanage via parent->manage; observe via parent->manage and parent->read
bundle_adminuser, serviceaccount, group#membermanage, observe
auditoruser, group#memberobserve only
vieweruser, serviceaccount, group#memberobserve 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#membergroup.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  → 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.

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:

OperationReBAC gateObject checkedPermissionNotes
Requestrequester must be able to deploy into the consuming Projectproject:<project_id>deploySingle check.
Approveapprover must be able to assign the credentialcloudcredential:<credential_id>assignPlus the self-approval denial — see below. Invoked from the approvals queue.
Rejectrejecter must be able to assign the credentialcloudcredential:<credential_id>assignSame gate as Approve; a reject is also an assigner decision. Invoked from the approvals queue.
Revokerevoker must be able to assign the credential, or to deploy into the consuming Projectcloudcredential:<credential_id>, then project:<project_id>assign, then deployTwo-party — see below.
Listreader must read 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 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:

  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 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 eventWriteDelete
credentialassignment.CredentialAssignmentRequested(a pending request never granted access)
credentialassignment.CredentialAssignmentGrantedproject:<project_id> -[uses]-> cloudcredential:<cloud_credential_id> (the owner push — same idempotent write, emitted alone with no Approved/Materialised pair)
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.

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 eventWriteDelete
cloudassignment.CloudAssignmentGranted / Approved / Materialisedcloud:<cloud_id> <-[uses]- project:<project_id>
cloudassignment.CloudAssignmentRevokednarrow (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 eventWriteDelete
cloud.ProviderBundleCreatedprovider_bundle:<bundle_id> <-[parent]- platform:plexsphere, plus provider_bundle:<bundle_id> <-[bundle_admin]- <created_by>
cloud.ProviderBundleUpdated(attribute-only change, no graph effect)
cloud.ProviderBundleDeletedwholesale (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 eventWriteDelete
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->read

makes 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 eventWriteDelete
managementfleet.ManagementClusterRegisteredmanagementcluster:<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->manage

derives 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.

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

SentinelHTTP statusRaised when
ErrPermissionDenied403Any 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.
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 & parent->use) + owner + assignerschema/authz.zed cloudcredential definitioninternal/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 useschema/authz.zed cloudcredential#use uses & parent->usetests/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 assignmentthe composition-root adapter's Request / Grant in cmd/plexsphere/credentialassignments_factory_prod.gocmd/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 definitioninternal/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 createsschema/authz.zed cloud and provider_bundle definitionsinternal/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 / assignschema/authz.zed cloud#observe uses->readtests/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->manageschema/authz.zed cloudcredential#observe + the credential read handlerstests/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/Expiredinternal/authz/sync/mapping.go cloud and cloud-credential armsinternal/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 manageschema/authz.zed provider_bundle definitioninternal/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-opinternal/authz/sync/mapping_provisioning.go provider-bundle armsinternal/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-opsinternal/authz/sync/mapping.go management-fleet armsinternal/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 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 requester the row recordsservices/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 rowservices/approve.go Approveinternal/provisioning/credentialassignment/services/assignment_service_test.go
Every decision transition stamps the deciding subject and instant, and refuses an empty decidercredentialassignment.go Approve / Reject / Revokeinternal/provisioning/credentialassignment/credentialassignment_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_provisioning.go — the provisioning context's event-to-tuple mappers, including the five Credential Assignment mappers.
  • ../../../schema/authz.zed — the canonical ReBAC schema carrying the cloudcredential#uses relation.