Appearance
Cloud assignment — making a Cloud usable in a Project
This document is the bounded-context reference for the Cloud Assignment sub-context of the Plexsphere Provisioning & Lifecycle context. The sub-context ships under internal/provisioning/cloudassignment/ and models the lifecycle of making a single Cloud usable in a single Project. A Cloud must be assigned to a Project before it can be used there, and assignment works in both directions — the operator-push direction and the project-request direction.
It mirrors the sibling Credential Assignment & ReBAC context, which binds a Cloud Credential to a Project, and reuses the same transactional-outbox, idempotency-token, and names-only-audit machinery. This page documents only the slice the Cloud Assignment sub-context adds on top of the shared ReBAC layer; for the canonical ReBAC walk-through — the SpiceDB schema, the zedtoken consistency flow, and the audit Entry shape — see the identity bounded-context reference ../identity/rebac.md.
Ubiquitous language
- requested — a Project has asked for a Cloud and awaits an operator decision.
- granted — a Platform Operator pushed an assignment directly; the resulting assignment is immediately approved and materialised in one authoritative action.
- approved — an operator accepted a pending request; the assignment may now be materialised.
- rejected — an operator declined the request; terminal.
- revoked — a previously approved (or granted) assignment was withdrawn; terminal.
- materialised — the ReBAC
cloud#usestuple has been written for an approved or granted assignment. Approval alone does not make a Cloud usable; the materialised marker records that the backing tuple actually landed, so the domain can tell an approved-but-not-yet-wired assignment from a fully effective one.
Aggregate and lifecycle
The CloudAssignment aggregate records that one Cloud has been assigned to one Project, who asked for it, the lifecycle state, how it was decided, and the materialised marker. The state is a closed four-member enum with the transition table:
text
requested → approved (operator approves a request, or the grant lands here directly)
requested → rejected (operator declines a request; terminal)
approved → revoked (operator withdraws an effective assignment; terminal)approved and rejected are reached from requested only; revoked is reached from approved only. The two terminal states are kept distinct so a downstream consumer auditing "which assignments were ever approved" can tell a never-approved rejected row from a once-approved revoked one.
The aggregate carries local, anti-corruption id value types (ID, ProjectID, CloudID), each a distinct named [16]byte, so the package never imports a sibling context's id type and the compiler keeps a project id and a cloud id apart.
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. On the operator grant path it names the granting operator, which both initiates and authorises the assignment. 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.
Why there is no assignability gate. The Credential Assignment template carries an
ErrCredentialNotAssignablesentinel because a Cloud Credential has a lifecycle — a revoked or expired credential is not assignable. Thecloud.Cloudaggregate has no lifecycle state (it exposes only rename / change-endpoint / change-region edit methods), so a cloud-assignability gate would be dead code that never rejects. The Cloud Assignment aggregate therefore declares only two sentinels — an invariant violation and an illegal transition — pinned by a closed-set test.
The two assignment directions
Operator push — Grant
A Platform Operator holding the Cloud's assign permission assigns a Cloud to a Project directly. Grant is a single authoritative action: it advances a fresh aggregate requested → approved → materialised in one step, writes the cloud#uses tuple synchronously, and emits exactly one CloudAssignmentGranted outbox event. There is no self-approval check — the operator both initiates and authorises the assignment, so the second-party rule does not apply by design.
Project request — Request → Approve / Reject
A Project maintainer or Domain admin holding the Project's deploy permission requests usage of a Cloud by Cloud ID. This persists a requested assignment and emits CloudAssignmentRequested.
The request then waits in the platform-wide decision queue, which the Approval Workflow context serves at GET /v1/approvals. That queue is the only surface a decision arrives through: it resolves the row's kind, authorises the caller against the Cloud the row spends, and calls the Approve / Reject methods below. This context stays authoritative for what those methods do — the transition table, the tuple write, and the second-party rule are enforced here regardless of which surface invoked them. See approvals.
A Platform Operator holding the Cloud's assign permission then either:
- approves — the aggregate advances
approved → materialised, thecloud#usestuple is written, andCloudAssignmentMaterialisedis emitted; or - rejects — the aggregate becomes
rejected, no tuple is written, andCloudAssignmentRejectedis emitted.
A request must be decided by a second party: the approving principal may never equal the principal that filed the request. 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 re-spelled subject with a different kind prefix does not dodge it. A self-approval is refused with ErrSelfApproval before any tuple write.
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 CloudAssignmentRequested outbox event. That is an operator incident rather than a caller fault, and letting the approval through would silently bypass the second-party rule for exactly the rows that cannot satisfy it. Reject and Revoke consult no requester, so such a row stays decidable.
Revoke
Revocation of an approved (or granted) assignment is open to either party. The service checks the Cloud's assign permission first and, on that denial, the consuming Project's deploy permission; either grants, and only a caller that both deny is refused. The Cloud's assigner can therefore pull a Cloud back, and a Project deployer can hand it back, without one waiting on the other's grant. The audit row records which of the two relations granted, so the trail names the party that revoked.
The outcome does not depend on who acted: the aggregate becomes revoked and the backing cloud#uses tuple is narrow-deleted — exactly the (cloud, uses, project) triple — so a sibling Project assigned the same Cloud keeps its access.
Domain events
Each meaningful state change is an explicit event written through the transactional outbox in the same transaction as the assignment-row mutation. Each event denormalises the project_id and cloud_id alongside the assignment_id so the outbox→ReBAC mapping can build the tuple from the payload alone.
| Event | Emitted by | Principal field | ReBAC effect |
|---|---|---|---|
CloudAssignmentRequested | Request | requested_by | none (no access yet) |
CloudAssignmentGranted | Grant | granted_by | write cloud#uses |
CloudAssignmentApproved | (reserved) | approved_by | write cloud#uses |
CloudAssignmentMaterialised | Approve | none (system-driven) | write cloud#uses |
CloudAssignmentRejected | Reject | rejected_by | none |
CloudAssignmentRevoked | Revoke | revoked_by | narrow-delete cloud#uses |
CloudAssignmentMaterialised carries no principal because materialisation is system-driven; the approver's identity is recorded on the audit row instead.
ReBAC gates and the cloud#uses dual-write
The cloud SpiceDB definition gains two additive relations and two permissions:
relation assignerandrelation uses: project | project#operator;permission assign = owner + cloud_admin + assigner— the gate the operatorGrant,Approve,RejectandRevokepaths authorise against;assignfolds incloud_adminso the natural Cloud actor (the creator, seeded ascloud_admin) can grant a Cloud without a separateassignergrant.Revokeaccepts the Project'sdeploypermission as a second, independent route (see Revoke);permission use = uses + owner + cloud_admin + assigner— the permission a consuming Project gains once theusestuple exists. It is asked with theproject:<uuid>as the SUBJECT, never a user:usesnames the Project object, not a subject set over its members;permission observe = owner + cloud_admin + operator + auditor + viewer + uses->read— the read side. Theuses->readarrow is what makes an approved assignment reveal the Cloud to the consuming Project's people.
The requester gate keys on the Project's deploy permission (admin + maintainer + parent->manage), so a Domain admin who created the Project can request an assignment without a bespoke relation grant.
Materialisation is a dual-write: the application service writes the cloud:<id>#uses@project:<id> tuple synchronously (so a follow-up check on the same request context is read-your-writes), and the committed outbox→ReBAC mapping arm for CloudAssignmentGranted / CloudAssignmentApproved / CloudAssignmentMaterialised emits the same idempotent write as the backstop. CloudAssignmentRevoked narrow-deletes exactly the project's edge; CloudAssignmentRequested and CloudAssignmentRejected are explicit no-ops.
What the assignment confers, and what it does not
The assignment edge feeds exactly two permissions, and they answer two different questions:
use, asked with the consumingproject:<uuid>as the subject. This is the machine question — "may this Project deploy against the Cloud?" — and it is what the credential-assignment request path checks before letting a Project ask for a credential on this Cloud.observe, asked with a user subject and resolved throughuses->read. This is the human question — "may this person see the Cloud their Project may use?" — and it resolves for anyone who can read the consuming Project, its own admins, maintainers, operators and viewers plus the owning Domain's admins throughproject#read'sparent->read.
The edge reaches no mutation permission: never manage, operate or assign, so a Project can never escalate into administering a Cloud it was merely granted. internal/authz/schema_invariants_test.go's TestSchemaUsesFeedsNoMutationPermission locks that, and tests/integration/cloud_assignment_visibility_real_spicedb_test.go pins the whole shape against a live SpiceDB.
The Credential Assignment sub-context now carries the same pair of directions: POST /v1/cloud-credentials/{id}/credential-assignments is the holder's push, gated on cloudcredential#assign, landing approved and materialised exactly as this one does. It exists for a sharper reason than symmetry — the self-approval guard means a credential's owner cannot request and then approve their own request, so without a push they had no single-principal route at all.
Nor does a Cloud Assignment reveal the Cloud's credentials. Those follow their own Credential Assignment through cloudcredential#observe — see credential-pool.md and the relationCredentialObserve DECISION in internal/transport/http/v1/cloudcredentials/wiring.go. A Cloud's own administrators keep the full roster, because cloudcredential#observe reads parent->manage.
What an approved assignment installs
An approved assignment does more than grant access. The Cloud declares a set of Crossplane provider packages that serve it, and assignment is what causes every one of them to be installed on the management cluster hosting the Project: the Management Fleet's sweep converges the packages named by every Cloud a cluster's Projects hold an approved assignment for. A package is a property of the cluster, and the assignment is what causes it to exist there — a cluster already running the package for another Project is not disturbed.
The materialised marker is deliberately NOT part of that trigger. Materialisation records that the ReBAC cloud#uses tuple landed, which is an authorization-graph concern; the substrate requirement follows from the operator's decision to approve. Gating the install on tuple convergence would leave a Project waiting for a controller because of an unrelated SpiceDB lag.
Every read of an approved assignment therefore carries a provider_installs array reporting whether those packages are up: one entry per package the Cloud declares, each naming the source it reports on and carrying a phase of Pending, Installing, Serving, Failed or Conflict, the reason behind the last two, and when it was observed. The entries are ordered by source, the same canonical order the Cloud's own provider_packages render in. Every entry reporting Serving is the answer to "can this Project provision against this Cloud yet". The array is absent on requested, rejected and revoked assignments, which install nothing; an absent array means nothing is installed for this assignment. A readiness the platform cannot read fails the request with 500 rather than reporting an optimistic phase.
Revoking an approved assignment drops the platform's install records but never the providers themselves: a controller may still be finishing work for resources created through it. See the Management Fleet reference for the phases, the conflict rules, and the never-uninstall guarantee.
What an approved assignment offers
The approved set has a second reader. The project-scoped Blueprint offer (GET /v1/projects/{id}/blueprints) answers which Blueprints a Project can provision, and it derives that from the same read: the Clouds the Project holds approved, each one's provider mapped onto the Blueprint provider kind naming the same substrate. A Blueprint is offered as provisionable when some published version accepts one of those kinds.
The two readers apply the same rule for the same reason. The installer puts a provider package on the cluster because an operator approved the assignment, and the offer reports the substrate as reachable on the same grounds; gating either on the materialised marker would let the two disagree, and an operator would see a Blueprint reported unavailable while its provider was already installed.
An offered Blueprint is not a promise that provisioning will succeed. The verdict is assignment-level: it says the Project may provision against a Cloud on that substrate, not that a credential has been assigned for it. See the Blueprints API reference for the response shape and the provisionable rule.
Data model
The plexsphere.cloud_assignment table holds one row per assignment (project_id FK ON DELETE CASCADE, cloud_id FK ON DELETE RESTRICT, a four-literal state CHECK matching the aggregate enum, the materialised boolean, timestamps, the nullable requested_by uuid, and the decided_by_subject / decided_at / decision_reason decision trio). The two text columns are NOT NULL DEFAULT '' because the aggregate already spells "not decided yet" as the empty string, while decided_at is nullable because a timestamptz has no zero value an application can tell apart from a real instant. A partial unique index on (project_id, cloud_id) WHERE state IN ('requested', 'approved') enforces "at most one live assignment per (project, cloud)" while still permitting a re-request after a terminal outcome. The sibling plexsphere.cloud_assignment_outbox_token table guarantees at-most-once emission of every lifecycle event; its event_type CHECK accepts the six snake_case tokens including cloud_assignment_granted.
Invariant-to-test matrix
| Invariant | Enforced at | Test |
|---|---|---|
| Closed four-state lifecycle and legal transitions | aggregate | cloudassignment_test.go |
| Operator grant lands approved + materialised in one step | aggregate + service | cloudassignment_test.go, assignment_service_test.go |
| Self-approval is denied before any tuple write, comparing the requester the row records | service | assignment_service_test.go, cloudassignment_self_approval_denied_test.go |
A row with no recorded requester fails Approve without a sentinel, while Reject and Revoke still decide it | service | assignment_service_test.go |
| Every decision transition stamps the deciding subject and instant, and refuses an empty decider | aggregate | cloudassignment_test.go |
| At most one live assignment per (project, cloud) | partial unique index | cloudassignment_repo_test.go, chainsaw-test.yaml |
| A stale concurrent transition cannot overwrite the winner | UPDATE … AND state = expected predicate | cloudassignment_repo_test.go |
A List page is bounded regardless of the caller's limit | repo page-size clamp | assignment_pg_test.go |
Materialisation writes cloud#uses; revoke narrow-deletes it | service + sync mapping | cloudassignment_authz_real_spicedb_test.go, mapping_test.go |
| At-most-once event emission | outbox-token primary key + service rollback on a swallowed token | assignment_pg_test.go, assignment_service_test.go |
HTTP surface
The request / grant / list / revoke workflow is exposed over HTTP under the cloud tag — see ../../reference/api/cloud-assignments.md for the operation map, ReBAC gates, and Problem.code taxonomy. The approve and reject decisions are taken on the approvals queue instead, documented in ../../reference/api/approvals.md. A usable Cloud (an approved assignment materialising the cloud#uses binding) is also the precondition the credential-assignment cloud_id request form checks before auto-selecting a credential.
The plexctl cloud assignment subtree drives the same four operations from the CLI and reaches these application services through the generated API client; plexctl approval carries the decisions. See ../../reference/cli/plexctl/cloud.md and ../../reference/cli/plexctl/approval.md.