Appearance
Clouds HTTP API
This is the reference for the /v1/clouds 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. The ReBAC chain the gates below resolve against is the cloud definition in the definition cloud block in schema/authz.zed — manage is owner-only, operate adds the operator relation, and observe adds the auditor relation; the privileged relations do NOT inherit from the parent Domain hierarchy. For the cursor-paginated list idiom and the canonical Problem.code style this surface inherits, see ../api/domains.md — Domains and Clouds share the same shape.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/clouds | CreateCloud | platform platform#manage | cloud.create | CloudCreated | 8 KiB |
| GET | /v1/clouds | ListClouds | per-row cloud#observe filter | cloud.list (granted, with post-filter item_count) | (none) | n/a |
| GET | /v1/clouds/{id} | GetCloud | cloud#observe (BEFORE persistence read) | cloud.read | (none) | n/a |
| PATCH | /v1/clouds/{id} | PatchCloud | cloud#manage | cloud.update (NAMES-only fields_changed) | CloudUpdated | 8 KiB |
| DELETE | /v1/clouds/{id} | DeleteCloud | cloud#manage | cloud.delete | CloudDeleted | n/a |
body_cap = 8 KiB(MaxCloudRequestBodyBytesininternal/transport/http/v1/clouds/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as413 request_body_too_large.ListClouds.limitis clamped at the handler to[1, 200]with default50.ListClouds.cursoris opaque, HMAC-signed by the server through theCursorCodecport; a tampered cursor surfaces as400 invalid_cursor.ListCloudslayers a per-rowcloud#observeReBAC check on top of the slug-ordered persistence window — the response page is the subset the caller is authorised to see. Thenext_cursoris set whenever the persistence layer returned a full page regardless of how many rows the per-row filter dropped, so a thin authorised cohort still pages forward.cloud#observeis the lowest-privilege read-equivalent declared on theclouddefinition (owner + operator + auditor) — the schema does not declare acloud#readpermission and routing the gate at a non-existent permission would surface asErrRelationNotFoundfrom SpiceDB.GetCloudruns thecloud#observeReBAC check before the persistence read, so an unauthorised caller receives403without the existence side-channel a "load-then-check" flow would leak.PatchCloudrejects bodies that carry aslugkey (even with the same value) at decode time with400 slug_immutable— the slug is the URL handle exported into cached dashboard links and outbox projections, and mutating it would silently rot every cached reference.PatchCloudrejects bodies that carry aproviderkey at decode time with400 provider_immutable— provider is the validator-routing key for the per-provider validator family, and changing it would invalidate every previously-storedendpointblob.PatchCloudrequires the body to set at least one ofdisplay_name,endpoint, orregion_defaults— an empty body surfaces as400 empty_patch.DeleteCloudruns the empty-aggregate guard inside the same transaction as the row delete; at least one persisted CloudCredential forces409 cloud_not_emptywith the structuredCloudChildCountspayload (cloud_credentialscount) so the operator knows which sub-aggregate to drain first. On a fresh deployment where thecloud_credentialstable has not yet been migrated in, theto_regclassguard in the underlying SQL query returns 0 and the delete succeeds with204 No Content.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetCloud / PatchCloud / DeleteCloud | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed → 400 invalid_cloud_id. The CloudID parameter component is shared across all three operations. |
| ListClouds | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor. |
| ListClouds | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Request:
CloudCreateRequest,CloudPatchRequest. - Response:
CloudResponse(single),CloudList(paged). - Embedded:
CloudChildCounts(delete-conflict extension),CloudProvider(closed enum:aws,azure).
The Cloud response carries the resolved id, display_name, slug, provider, endpoint, region_defaults, external_id, and the created_at / updated_at lifecycle timestamps. The endpoint and region_defaults fields are JSONB blobs whose internal shape is owned by the per-provider validator registry — the OpenAPI schema only declares the wire envelope (type: object, additionalProperties: true). The shape is shared by every read surface (CreateCloud, GetCloud, ListClouds, PatchCloud) so clients only need one binding.
Per-provider validation
The endpoint and region_defaults payloads are dispatched on provider to the validator registry in internal/provisioning/cloud/validator/. Each validator returns a slice of FieldError rows; the handler classifies the failure on the leftmost entry — if its Field starts with region_defaults, the rejection becomes 400 invalid_cloud_region_defaults; otherwise it becomes 400 invalid_cloud_endpoint.
| Provider | endpoint requires | region_defaults requires |
|---|---|---|
aws | region, partition (non-empty strings) | default_region (non-empty string) |
azure | cloud_environment (non-empty string) | subscription_id, tenant_id (non-empty strings) |
A provider value outside the closed enum surfaces as 400 unknown_provider.
Slug + (provider, external_id) uniqueness
Two distinct UNIQUE constraints back the Cloud aggregate's identity contract:
clouds_slug_uniqueonslug— a duplicate slug surfaces as409 cloud_slug_conflict.clouds_provider_external_id_uniqueon(provider, external_id)— a duplicate upstream account binding surfaces as409 cloud_external_id_conflict.
The repository classifies the two constraint violations separately so callers can distinguish "this URL handle is already taken" from "this upstream provider account is already registered" without parsing the detail string.
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, relation_path, and request correlation_id.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_cloud_id | 400 | path-id family | Malformed UUID or zero UUID. |
invalid_cloud | 400 | Create / Patch | Aggregate invariant rejected the body (empty display_name, malformed slug, malformed endpoint/region_defaults JSON). |
slug_immutable | 400 | Patch | Body carried a top-level slug key. |
provider_immutable | 400 | Patch | Body carried a top-level provider key. |
empty_patch | 400 | Patch | Body had no patchable fields set. |
unknown_provider | 400 | Create / Patch | provider value outside the closed CloudProvider enum. |
invalid_cloud_endpoint | 400 | Create / Patch | Per-provider validator rejected the endpoint payload (leftmost FieldError targeted the endpoint column). |
invalid_cloud_region_defaults | 400 | Create / Patch | Per-provider validator rejected the region_defaults payload (leftmost FieldError targeted the region_defaults column). |
invalid_body | 400 | Create / Patch | Body could not be read or did not parse as the typed request shape. |
invalid_cursor | 400 | List | HMAC verification failed. |
invalid_limit | 400 | List | Out of [1, 200]. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
cloud_not_found | 404 | path-id family | No Cloud with the given {id}. |
cloud_slug_conflict | 409 | Create | clouds_slug_unique violation. |
cloud_external_id_conflict | 409 | Create | clouds_provider_external_id_unique violation. |
cloud_not_empty | 409 | Delete | Cloud still owns child aggregates; payload carries CloudChildCounts.cloud_credentials. |
request_body_too_large | 413 | Create / Patch | Body exceeded the 8 KiB Cloud Inventory ceiling. |
internal | 500 | every operation | Server-side failure path. |
A 403 response carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ../api/authz.md.
Cross-references
../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; the*Cloud*operations and theCloudCreateRequest/CloudPatchRequest/CloudResponse/CloudList/CloudChildCounts/CloudProviderschemas.../../../internal/transport/http/v1/clouds/— the transport-tier implementation: the five handlers, the closedProblem.codetaxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter onListClouds.../../../schema/authz.zed— ReBAC schema; thedefinition cloudblock declares themanage/operate/observepermissions this surface gates on.../api/domains.md— sibling tenancy CRUD surface for Domains, with the same cursor/limit pagination idiom and the same empty-aggregate-on-delete contract.../api/authz.md— thePermissionDeniedshape returned on 403.