Appearance
Cloud Assignments HTTP API
This is the reference for the Cloud Assignments HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, outbox event, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract.
A Cloud Assignment makes a Cloud usable in a Project. There are two directions. A Project request opens an assignment in the requested state; an operator who holds the Cloud's assign permission then moves it to approved (which materialises the cloud#uses binding) or rejected. An operator grant is a single authoritative action that creates the assignment directly in approved+materialised, bypassing the second-party rule by design. Either way, an operator later moves an approved assignment to revoked, which narrow-deletes the cloud#uses binding. The carrying OpenAPI tag is cloud — Cloud Assignments sit alongside the Cloud Inventory and Cloud Credentials surfaces.
The approve and reject decisions are not taken here. They are taken on the dual-control queue at POST /v1/approvals/{id}/approve and /reject, which accepts a Cloud Assignment id and dispatches it to the same application service this surface calls. See approvals.md for the decide contract.
The request path gates on the consuming Project's deploy permission (admin + maintainer + parent->manage, the same composite the Credential Assignment request and resources.CreateResource gate on); the list gates on the Project's read; the grant gates on the Cloud's assign permission. Revocation accepts either party: assign on the Cloud, or, when that is denied, deploy on the consuming Project. The ReBAC chains resolve against the project and cloud definitions in schema/authz.zed — cloud#assign folds in owner, cloud_admin, and the explicit assigner relation, and a materialised assignment writes cloud:<id>#uses@project:<id> so the Project gains the cloud#use permission. For the cursor-paginated list idiom this surface inherits, see credential-assignments.md — both share the same HMAC-signed caller-bound cursor mechanism.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/projects/{id}/cloud-assignments | RequestCloudAssignment | project#deploy on the consuming Project (BEFORE the body decode) | cloud_assignment.request | CloudAssignmentRequested (emitted by the application service) | 8 KiB |
| GET | /v1/projects/{id}/cloud-assignments | ListCloudAssignments | top-level project#read on the consuming Project (BEFORE the persistence read) | cloud_assignment.list (granted, with cohort size) | (none) | n/a |
| POST | /v1/clouds/{id}/cloud-assignments | GrantCloudAssignment | cloud#assign on the Cloud (BEFORE the body decode) | cloud_assignment.grant | CloudAssignmentGranted (emitted by the application service) | 8 KiB |
| POST | /v1/cloud-assignments/{id}/revoke | RevokeCloudAssignment | cloud#assign on the owning Cloud, else project#deploy on the consuming Project (AFTER an unavoidable pre-authz row read) | cloud_assignment.revoke | CloudAssignmentRevoked (emitted by the application service) | 8 KiB |
body_cap = 8 KiB(MaxCloudAssignmentRequestBodyBytesininternal/transport/http/v1/cloudassignments/wiring.go) is enforced before the JSON decoder runs on the request, grant, and revoke bodies; an over-cap body surfaces as413 request_body_too_large.RequestCloudAssignmentandGrantCloudAssignmentrun their ReBAC gate before decoding the body, so an unauthorised caller never exercises the JSON decoder.RevokeCloudAssignmentreads the assignment row first (the path id encodes neither the owning Cloud nor the consuming Project), then runs its two-party gate; the accepted narrow existence side-channel (a404versus a403for a guessed UUID) is recorded via the audit-first permission-denied path.- The two revoke checks run in order and neither is audited on its own:
assignon the Cloud is tried first,deployon the Project only if it is denied. A single denial row is written when both fail, recording the Cloud as the object anddeployas the missing relation. On success the application service records which relation granted, so the audit trail names the party that revoked. Either party's revoke narrow-deletes the samecloud:<id>#uses@project:<id>tuple, leaving a sibling Project's edge intact. - The operator grant creates the assignment already
approved+materialisedand emits a singleCloudAssignmentGrantedevent; it runs no self-approval check because the operator both initiates and authorises the assignment. The request → approve path does run the self-approval guard: the service compares the caller against the persistedrequested_byprincipal, and the queue refuses an approver who is the original requester with403 self_approval_denied. - A second live assignment for the same
(Project, Cloud)pair while an earlier one isrequestedorapprovedis refused with409 duplicate_live_cloud_assignment. A decision that is not legal from the current state (e.g. approving arejectedrow, revoking arequestedrow) is refused with409 illegal_transition, whether it arrives here or through the queue. ListCloudAssignments.limitis validated at the handler against[1, 200]with default50; a value outside the window is refused with400 invalid_limitrather than clamped.ListCloudAssignments.cursoris opaque, HMAC-signed, and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as400 invalid_cursorand a cross-caller replay as403 cursor_binding_mismatch. The page renders in creation order. A single top-levelproject#readgate authorises the whole page — every row belongs to the one path Project.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| RequestCloudAssignment / ListCloudAssignments | id (path) | string (uuid) | yes | Consuming Project UUIDv7. Non-zero. Malformed → 400 invalid_project_id. |
| GrantCloudAssignment | id (path) | string (uuid) | yes | Cloud UUIDv7. Non-zero. Malformed → 400 invalid_cloud_id. |
| RevokeCloudAssignment | id (path) | string (uuid) | yes | Cloud Assignment UUIDv7. Non-zero. Malformed → 400 invalid_cloud_assignment_id. |
| ListCloudAssignments | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListCloudAssignments | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
| Schema | Role |
|---|---|
CloudAssignmentRequestBody | Request body — a single cloud_id. |
CloudAssignmentGrantRequest | Grant body — a single project_id. |
CloudAssignmentDecisionRequest | Revoke body — a single reason (1–1024 chars). |
CloudAssignmentResponse | Metadata projection shared by every operation: id, project_id, cloud_id, state, materialised, created_at, updated_at, and the optional provider_installs. |
CloudAssignmentProviderInstall | Readiness of ONE Crossplane provider package the assigned Cloud declares, on the management cluster hosting the Project: the source it reports on, phase (Pending, Installing, Serving, Failed, Conflict), the optional message behind Failed and Conflict, and the optional observed_at. The source matches one of the Cloud's provider_packages entries. |
CloudAssignmentList | One creation-ordered page: items plus a nullable next_cursor. |
CloudAssignmentState | The lifecycle state string — requested, approved, rejected, or revoked. |
provider_installs is an array with one entry per package the assigned Cloud declares, ordered by source the same way the Cloud's own provider_packages are. It is present ONLY while the assignment is approved: a requested, rejected, or revoked assignment installs nothing, so the property is absent rather than reporting phases that do not apply, and an absent array means nothing is installed for this assignment. The Project can provision against the Cloud once every entry reports Serving. A readiness the platform cannot resolve fails the request with 500 instead of degrading to an optimistic phase. See the Management Fleet context reference for what drives the phases.
materialised is true only while the assignment is approved — it tracks whether the cloud#uses binding is live. The state is a documented string rather than an OpenAPI enum; the OpenAPI spec carries the rationale (avoiding a generated-code enum-naming collision) in a YAML comment on the CloudAssignmentState schema.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason and request correlation_id.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_project_id | 400 | Request / List / Grant | Project {id} or body project_id was not a non-zero UUID. |
invalid_cloud_id | 400 | Request / Grant | Cloud {id} or body cloud_id was not a non-zero UUID. |
invalid_cloud_assignment_id | 400 | Revoke | Assignment {id} was not a non-zero UUID. |
invalid_body | 400 | Request / Grant / Revoke | Body could not be read or did not parse as the operation's request schema. |
invalid_decision_reason | 400 | Revoke | reason was empty or whitespace-only. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
cursor_binding_mismatch | 403 | List | Cursor was minted for a different caller. |
cloud_assignment_not_found | 404 | Revoke | No Cloud Assignment with the given {id}. |
duplicate_live_cloud_assignment | 409 | Request / Grant | A live (Project, Cloud) assignment already exists. |
illegal_transition | 409 | Revoke | Revocation is not legal from the assignment's current state. |
request_body_too_large | 413 | Request / Grant / Revoke | Body exceeded the 8 KiB ceiling. |
authz_unavailable | 503 | every operation | The authorization backend is temporarily unavailable. |
cloud_assignments_not_provisioned | 501 | every operation | The composition root has not wired the Cloud Assignment dependency bundle yet. |
internal | 500 | every operation | Server-side failure path. |
A 403 permission-denied response carries the extended PermissionDenied fields documented in authz.md.
Cross-references
approvals.md— the queue that carries the approve and reject decisions for this family, its per-kind gates, and itsApprovalrow shape.credential-assignments.md— the sibling approver-gated surface, including thecloud_idform that consumes a usable Cloud through a Cloud Assignment.cloud-credentials.md— the Cloud Credentials issue / read / revoke surface and the Credential to Cloud association.clouds.md— the Cloud Inventory CRUD surface.../../contexts/provisioning/cloud-assignment.md— the bounded-context narrative behind this surface.