Skip to content

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.zedcloud#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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/projects/{id}/cloud-assignmentsRequestCloudAssignmentproject#deploy on the consuming Project (BEFORE the body decode)cloud_assignment.requestCloudAssignmentRequested (emitted by the application service)8 KiB
GET/v1/projects/{id}/cloud-assignmentsListCloudAssignmentstop-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-assignmentsGrantCloudAssignmentcloud#assign on the Cloud (BEFORE the body decode)cloud_assignment.grantCloudAssignmentGranted (emitted by the application service)8 KiB
POST/v1/cloud-assignments/{id}/revokeRevokeCloudAssignmentcloud#assign on the owning Cloud, else project#deploy on the consuming Project (AFTER an unavoidable pre-authz row read)cloud_assignment.revokeCloudAssignmentRevoked (emitted by the application service)8 KiB
  • body_cap = 8 KiB (MaxCloudAssignmentRequestBodyBytes in internal/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 as 413 request_body_too_large.
  • RequestCloudAssignment and GrantCloudAssignment run their ReBAC gate before decoding the body, so an unauthorised caller never exercises the JSON decoder. RevokeCloudAssignment reads 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 (a 404 versus a 403 for 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: assign on the Cloud is tried first, deploy on the Project only if it is denied. A single denial row is written when both fail, recording the Cloud as the object and deploy as 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 same cloud:<id>#uses@project:<id> tuple, leaving a sibling Project's edge intact.
  • The operator grant creates the assignment already approved + materialised and emits a single CloudAssignmentGranted event; 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 persisted requested_by principal, and the queue refuses an approver who is the original requester with 403 self_approval_denied.
  • A second live assignment for the same (Project, Cloud) pair while an earlier one is requested or approved is refused with 409 duplicate_live_cloud_assignment. A decision that is not legal from the current state (e.g. approving a rejected row, revoking a requested row) is refused with 409 illegal_transition, whether it arrives here or through the queue.
  • ListCloudAssignments.limit is validated at the handler against [1, 200] with default 50; a value outside the window is refused with 400 invalid_limit rather than clamped. ListCloudAssignments.cursor is opaque, HMAC-signed, and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as 400 invalid_cursor and a cross-caller replay as 403 cursor_binding_mismatch. The page renders in creation order. A single top-level project#read gate authorises the whole page — every row belongs to the one path Project.

Path & query parameters

OperationParameterTypeRequiredNotes
RequestCloudAssignment / ListCloudAssignmentsid (path)string (uuid)yesConsuming Project UUIDv7. Non-zero. Malformed → 400 invalid_project_id.
GrantCloudAssignmentid (path)string (uuid)yesCloud UUIDv7. Non-zero. Malformed → 400 invalid_cloud_id.
RevokeCloudAssignmentid (path)string (uuid)yesCloud Assignment UUIDv7. Non-zero. Malformed → 400 invalid_cloud_assignment_id.
ListCloudAssignmentscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListCloudAssignmentslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.

Schemas

SchemaRole
CloudAssignmentRequestBodyRequest body — a single cloud_id.
CloudAssignmentGrantRequestGrant body — a single project_id.
CloudAssignmentDecisionRequestRevoke body — a single reason (1–1024 chars).
CloudAssignmentResponseMetadata projection shared by every operation: id, project_id, cloud_id, state, materialised, created_at, updated_at, and the optional provider_installs.
CloudAssignmentProviderInstallReadiness 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.
CloudAssignmentListOne creation-ordered page: items plus a nullable next_cursor.
CloudAssignmentStateThe 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.

CodeStatusWhereMeaning
invalid_project_id400Request / List / GrantProject {id} or body project_id was not a non-zero UUID.
invalid_cloud_id400Request / GrantCloud {id} or body cloud_id was not a non-zero UUID.
invalid_cloud_assignment_id400RevokeAssignment {id} was not a non-zero UUID.
invalid_body400Request / Grant / RevokeBody could not be read or did not parse as the operation's request schema.
invalid_decision_reason400Revokereason was empty or whitespace-only.
invalid_limit400ListOut of [1, 200].
invalid_cursor400ListHMAC verification or structural decode failed.
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403ListCursor was minted for a different caller.
cloud_assignment_not_found404RevokeNo Cloud Assignment with the given {id}.
duplicate_live_cloud_assignment409Request / GrantA live (Project, Cloud) assignment already exists.
illegal_transition409RevokeRevocation is not legal from the assignment's current state.
request_body_too_large413Request / Grant / RevokeBody exceeded the 8 KiB ceiling.
authz_unavailable503every operationThe authorization backend is temporarily unavailable.
cloud_assignments_not_provisioned501every operationThe composition root has not wired the Cloud Assignment dependency bundle yet.
internal500every operationServer-side failure path.

A 403 permission-denied response carries the extended PermissionDenied fields documented in authz.md.

Cross-references