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 folds in owner and cloud_admin, operate adds the operator relation, and observe adds operator, auditor, and viewer on top of the two manage principals (so a Cloud's creator, seeded as cloud_admin, can both administer and read it back); the privileged relations do NOT inherit from the parent Domain hierarchy. observe additionally reads uses->read, so an approved Cloud Assignment makes the Cloud readable to everyone who can read the consuming Project — that is the path a Project's people take to GET a Cloud, and it needs no per-user grant on the Cloud itself. The arrow reaches observe only: assignment never confers manage, operate, or assign. 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, plus platform#manage or provider_bundle#observe on a referenced bundle | 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,region_defaults,provider_packages,provider_config_api_version,provider_bundle_id,provider_bundle_version, orprovider_package_overrides— an empty body surfaces as400 empty_patch. Aprovider_packagespatch replaces the whole set as one unit: the packages the body names become the Cloud's packages and every package it omits is dropped. An absent or empty array, asourcenamed twice, an entry missing one of its two members, or a malformedprovider_config_api_versionis rejected with400 invalid_cloud.provider_config_api_versionpatches on its own, without restating the package set.provider_package_overridesreplaces the whole override set the same way. Omit the field and the current set is untouched; state[]and the set is cleared, which puts the Cloud back on the pinned bundle version as it stands. An override-only patch runs oncloud#managealone: it deviates from the reference the Cloud already holds and moves nothing about that reference, so it is not one of the bundle-touching shapes the second ReBAC check gates. It does read the Cloud back, for the precondition rather than for a gate: the write rewrites the whole row, reference and pin included, so it asserts the reference that read reported and answers409 provider_bundle_reference_movedwhen a competing writer re-pointed or promoted the Cloud in between. The expectation comes from the server's own read of this request, so it covers the window from that read to the write; a reference that moved between the caller'sGETand this patch is not reported, and the override set — likeprovider_packages— is replaced wholesale, so two patches racing on the set are last-writer-wins.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,gcp,hetzner,openstack),CloudProviderPackage(one package:source,version, and the read-onlyorigina member of the effective set carries — an authored per-Cloud override is the same object without it).
The Cloud response carries the resolved id, display_name, slug, provider, endpoint, region_defaults, external_id, the provider_packages array naming the Crossplane provider packages the Cloud declares (each entry an OCI source and a pinned version), the top-level provider_config_api_version the Provisioning Broker stamps as the rendered ProviderConfig's apiVersion, and the created_at / updated_at lifecycle timestamps. provider_packages holds between one and sixteen entries, names each source at most once, and is rendered in canonical source-ascending order whatever order the request stated it in. The apiVersion is one value per Cloud rather than one per package: the broker renders a single ProviderConfig per Cloud, and every declared package serves that one group. A Cloud that takes its provider configuration from a provider bundle also carries provider_bundle_id, provider_bundle_slug, and provider_bundle_version; see Provider configuration modes below. 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.
Provider configuration modes
A Cloud holds its provider configuration in exactly one of two ways. In inline mode it declares provider_packages and provider_config_api_version itself. In bundle mode it names a provider_bundle_id and resolves both values from that provider bundle, the shared declaration authored once on /v1/provider-bundles.
CreateCloud and PatchCloud accept either the bundle reference or the inline pair, never both. A body naming provider_bundle_id alongside either inline field is rejected with 400 invalid_cloud_provider_mode naming every field that took part in the conflict; a create that sets neither, or only one half of the inline pair, is rejected with 400 invalid_cloud. On a patch, setting provider_bundle_id on an inline Cloud is the switch into bundle mode and setting both inline fields on a referencing Cloud is the switch back. A patch that takes a referencing Cloud only halfway out, naming one inline field and not the other, is rejected with 400 invalid_cloud_provider_mode naming the field it left out: such a Cloud has neither a package set nor an apiVersion of its own to fall back on.
The named bundle is admitted before the write. It must exist, and an unknown id is rejected with 400 unknown_provider_bundle; it must serve the same provider as the Cloud, and a bundle of another provider is rejected with 400 provider_bundle_provider_mismatch. The check runs at admission because nothing re-checks a stored reference afterwards. A malformed provider_bundle_id surfaces as 400 invalid_cloud_provider_mode rather than 400 invalid_body: the field carries no format: uuid in the schema, so the value reaches the service's admission check and the rejection can name the field that was wrong.
PatchCloud also authorises the caller against the bundle itself, not only against the Cloud. A patch that touches the bundle reference runs a second ReBAC check and answers 403 with reason insufficient_relation when it fails. cloud#manage is a grant on the addressed Cloud and says nothing about the platform-scoped bundle catalogue: without the second gate, a principal holding cloud_admin on a single Cloud would read any bundle's slug and full package set back through this call's 200 body — state GET /v1/provider-bundles/{id} refuses them — and could hold that bundle's deletion behind 409 provider_bundle_referenced for as long as the reference stands.
Both bundle-touching shapes run it. A body naming a well-formed provider_bundle_id is checked against that bundle. A promotion carries no id, so the handler reads the Cloud back and checks the bundle it already references — and carries that reference, bundle and pinned version together, into the write, which refuses with 409 provider_bundle_reference_moved if the Cloud has since moved either half. Without that precondition the check would be advisory: a competing writer landing between the two reads would put the promotion inside a bundle the gate never saw.
Gating only the first shape would leave the promotion as the same read primitive one step removed: each 200 renders the frozen declaration of one historical version — the content GET /v1/provider-bundles/{id}/versions refuses without provider_bundle#observe — so incrementing the number walks the whole history, and 400 provider_bundle_version_not_found against 200 reports where it ends. It would also leave a package-set rollback as the one bundle operation reachable with no permission on the bundle at all, while re-pointing the Cloud at a different bundle needs observe.
That second check has two legs. platform#manage — the very permission CreateCloud requires to make the same reference — is asked first and satisfies the gate on its own; provider_bundle#observe on the named bundle is the fallback for callers without it. The order matters because observe folds platform#manage in only through the bundle's parent edge, and that edge is written by the authz-sync consumer draining the ProviderBundleCreated outbox row rather than by the 201 itself. On the single-leg form, a bundle created moments earlier resolved observe for nobody — not its own creator, not a platform admin — so the natural POST /v1/provider-bundles → PATCH /v1/clouds/{id} sequence answered 403 for as long as the outbox took to drain. The platform leg removes that window without widening who may make the reference: it grants exactly the callers CreateCloud already lets make it. CreateCloud itself needs no counterpart gate for the same reason — platform#manage is the permission it is already gated on.
provider_packages and provider_config_api_version on every read surface carry the effective values: for a Cloud in bundle mode, the declaration of the bundle version it pins with its own package overrides merged over it; the Cloud's own values otherwise. They are resolved per read. A Cloud in bundle mode also carries the provenance of those values — provider_bundle_id, provider_bundle_slug, and provider_bundle_version. All three are absent for an inline Cloud, and the slug travels next to the id so a client renders the reference without a second round-trip.
The pinned version
A bundle reference names a version as well as a bundle. A content patch on the bundle publishes a new immutable version and moves no Cloud, so each referencing Cloud keeps resolving the declaration it pins until a Cloud write moves that pin. Two Clouds on the same bundle can therefore report different provider_packages, and the roster at GET /v1/provider-bundles/{id}/clouds reports each row's provider_bundle_version so the spread is visible.
provider_bundle_version is optional on both writes:
- Omitted alongside a
provider_bundle_idthat puts the Cloud into bundle mode, the write pins that bundle'slatest_version— the declaration an operator reads when they pick the bundle. - Stated alongside the id, it pins that version instead.
- Stated alone on a Cloud already in bundle mode, it is the promotion: the one write that moves this Cloud onto another declaration of the bundle it already references. It moves no other Cloud.
A version the referenced bundle never published is rejected with 400 provider_bundle_version_not_found, which is deliberately distinct from unknown_provider_bundle: the bundle is right and the version is not, so the operator picks another version rather than another bundle. The versions it did publish are readable at GET /v1/provider-bundles/{id}/versions. Naming the field while the Cloud neither is nor becomes a bundle reference is rejected with 400 invalid_cloud_provider_mode, because there is no bundle for the version to point into. Numbering starts at 1, so a value below it is rejected with the same code on both writes rather than read as an omitted field.
Per-Cloud package overrides
A Cloud in bundle mode may run some of the pinned declaration's packages at other versions without leaving the bundle. provider_package_overrides is that deviation: {source, version} pairs authored on the Cloud, each source named at most once, up to sixteen entries. The field belongs to bundle mode, and stating it while the Cloud declares (or comes to declare) its packages inline is rejected with 400 invalid_cloud_provider_mode. A Cloud that owns its package set runs another version by editing that set.
The overlay is applied per read. An override whose source the pinned version carries replaces that entry's version; an override naming a source the pinned version does not carry joins the effective set. Nothing is ever removed, so the effective set is never smaller than the pinned declaration's. provider_packages reports the result and stamps each entry with an origin:
origin | Meaning |
|---|---|
bundle | The entry is the pinned declaration's, taken as it stands. |
override | An override replaced the version of an entry the pinned declaration carries. |
addition | An override named a source the pinned declaration does not carry, so it joined the set. |
origin is present exactly when provider_bundle_id is. An inline Cloud resolved through one declaration, so its packages carry no origin and its response omits provider_package_overrides as well. A Cloud in bundle mode always carries the key, as the empty array when it authored no override.
provider_package_overrides on a write is the authored set, not the merged one, and it replaces the whole set: the entries the body names become the Cloud's overrides and every entry it omits is dropped. On PatchCloud, omitting the field leaves the current set untouched and [] clears it. The set rides along with an attach or a promotion in the same patch, and what the body states is what the Cloud carries afterwards whichever of the two the patch also did.
A promotion on its own leaves the override set alone: it moves the pin and writes nothing about the deviations. A shadow can therefore outlive the entry it shadowed, because the newer declaration may not carry that source at all, and the effective set then reports the override as an addition. That classification is the only signal the shadow has stopped shadowing anything, so an operator who promoted past a dropped package reads it from the response rather than from the promotion refusing.
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) |
gcp | project (non-empty string) | default_region (non-empty string) |
hetzner | api_endpoint (non-empty string) | default_location (non-empty string) |
openstack | auth_url (non-empty string) | default_region (non-empty string) |
A provider value outside the closed enum surfaces as 400 unknown_provider.
Each provider requires the field that discriminates one account or deployment from another, even where a public default exists — hetzner requires api_endpoint and azure requires cloud_environment — so a Cloud pointing at a compatible private substrate stays expressible and a mistyped key cannot silently resolve to the vendor's public endpoint. openstack does not require the Keystone scope (project_name, domain_name): an application credential carries its own.
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, an absent or empty provider_packages array, a source named twice, an entry missing its source or version, a provider_package_overrides entry naming a source twice, or a malformed provider_config_api_version). |
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_provider_mode | 400 | Create / Patch | The body named provider_bundle_id together with provider_packages or provider_config_api_version, named provider_bundle_version or provider_package_overrides while the Cloud declares (or comes to declare) its packages inline, left a referencing Cloud halfway out of bundle mode, stated a provider_bundle_id that is not a canonical UUID string, or stated a provider_bundle_version below 1. |
unknown_provider_bundle | 400 | Create / Patch | provider_bundle_id names no persisted provider bundle. |
provider_bundle_provider_mismatch | 400 | Create / Patch | The referenced provider bundle serves a different provider than the Cloud. |
provider_bundle_version_not_found | 400 | Create / Patch | provider_bundle_version names a version the referenced provider bundle never published. The write is refused before it commits; the composite foreign key on (provider_bundle_id, provider_bundle_version) is the backstop for a version dropped mid-write. |
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. |
provider_bundle_reference_moved | 409 | Patch | A body naming no bundle but depending on the one the Cloud references — a promotion, or a provider_package_overrides patch — was decided against the reference the Cloud held when the request was read, and a competing writer moved that reference before the write, either to another bundle or to another pinned version of the same one. Nothing was written; re-read the Cloud and send the patch again. |
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/provider-bundles.md— the provider-bundle surface a Cloud in bundle mode references, with the bundle's own ReBAC gates andProblem.codetaxonomy.../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.