Skip to content

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.zedmanage 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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/cloudsCreateCloudplatform platform#managecloud.createCloudCreated8 KiB
GET/v1/cloudsListCloudsper-row cloud#observe filtercloud.list (granted, with post-filter item_count)(none)n/a
GET/v1/clouds/{id}GetCloudcloud#observe (BEFORE persistence read)cloud.read(none)n/a
PATCH/v1/clouds/{id}PatchCloudcloud#manage, plus platform#manage or provider_bundle#observe on a referenced bundlecloud.update (NAMES-only fields_changed)CloudUpdated8 KiB
DELETE/v1/clouds/{id}DeleteCloudcloud#managecloud.deleteCloudDeletedn/a
  • body_cap = 8 KiB (MaxCloudRequestBodyBytes in internal/transport/http/v1/clouds/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as 413 request_body_too_large.
  • ListClouds.limit is clamped at the handler to [1, 200] with default 50.
  • ListClouds.cursor is opaque, HMAC-signed by the server through the CursorCodec port; a tampered cursor surfaces as 400 invalid_cursor.
  • ListClouds layers a per-row cloud#observe ReBAC check on top of the slug-ordered persistence window — the response page is the subset the caller is authorised to see. The next_cursor is 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#observe is the lowest-privilege read-equivalent declared on the cloud definition (owner + operator + auditor) — the schema does not declare a cloud#read permission and routing the gate at a non-existent permission would surface as ErrRelationNotFound from SpiceDB.
  • GetCloud runs the cloud#observe ReBAC check before the persistence read, so an unauthorised caller receives 403 without the existence side-channel a "load-then-check" flow would leak.
  • PatchCloud rejects bodies that carry a slug key (even with the same value) at decode time with 400 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.
  • PatchCloud rejects bodies that carry a provider key at decode time with 400 provider_immutable — provider is the validator-routing key for the per-provider validator family, and changing it would invalidate every previously-stored endpoint blob.
  • PatchCloud requires the body to set at least one of display_name, endpoint, region_defaults, provider_packages, provider_config_api_version, provider_bundle_id, provider_bundle_version, or provider_package_overrides — an empty body surfaces as 400 empty_patch. A provider_packages patch 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, a source named twice, an entry missing one of its two members, or a malformed provider_config_api_version is rejected with 400 invalid_cloud. provider_config_api_version patches on its own, without restating the package set.
  • provider_package_overrides replaces 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 on cloud#manage alone: 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 answers 409 provider_bundle_reference_moved when 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's GET and this patch is not reported, and the override set — like provider_packages — is replaced wholesale, so two patches racing on the set are last-writer-wins.
  • DeleteCloud runs the empty-aggregate guard inside the same transaction as the row delete; at least one persisted CloudCredential forces 409 cloud_not_empty with the structured CloudChildCounts payload (cloud_credentials count) so the operator knows which sub-aggregate to drain first. On a fresh deployment where the cloud_credentials table has not yet been migrated in, the to_regclass guard in the underlying SQL query returns 0 and the delete succeeds with 204 No Content.

Path & query parameters

OperationParameterTypeRequiredNotes
GetCloud / PatchCloud / DeleteCloudid (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_cloud_id. The CloudID parameter component is shared across all three operations.
ListCloudscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor.
ListCloudslimit (query)integerno[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-only origin a 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-bundlesPATCH /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_id that puts the Cloud into bundle mode, the write pins that bundle's latest_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:

originMeaning
bundleThe entry is the pinned declaration's, taken as it stands.
overrideAn override replaced the version of an entry the pinned declaration carries.
additionAn 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.

Providerendpoint requiresregion_defaults requires
awsregion, partition (non-empty strings)default_region (non-empty string)
azurecloud_environment (non-empty string)subscription_id, tenant_id (non-empty strings)
gcpproject (non-empty string)default_region (non-empty string)
hetznerapi_endpoint (non-empty string)default_location (non-empty string)
openstackauth_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_unique on slug — a duplicate slug surfaces as 409 cloud_slug_conflict.
  • clouds_provider_external_id_unique on (provider, external_id) — a duplicate upstream account binding surfaces as 409 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.

CodeStatusWhereMeaning
invalid_cloud_id400path-id familyMalformed UUID or zero UUID.
invalid_cloud400Create / PatchAggregate 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_immutable400PatchBody carried a top-level slug key.
provider_immutable400PatchBody carried a top-level provider key.
empty_patch400PatchBody had no patchable fields set.
unknown_provider400Create / Patchprovider value outside the closed CloudProvider enum.
invalid_cloud_provider_mode400Create / PatchThe 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_bundle400Create / Patchprovider_bundle_id names no persisted provider bundle.
provider_bundle_provider_mismatch400Create / PatchThe referenced provider bundle serves a different provider than the Cloud.
provider_bundle_version_not_found400Create / Patchprovider_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_endpoint400Create / PatchPer-provider validator rejected the endpoint payload (leftmost FieldError targeted the endpoint column).
invalid_cloud_region_defaults400Create / PatchPer-provider validator rejected the region_defaults payload (leftmost FieldError targeted the region_defaults column).
invalid_body400Create / PatchBody could not be read or did not parse as the typed request shape.
invalid_cursor400ListHMAC verification failed.
invalid_limit400ListOut of [1, 200].
unauthenticated401every operationRequest carries no authenticated principal.
cloud_not_found404path-id familyNo Cloud with the given {id}.
cloud_slug_conflict409Createclouds_slug_unique violation.
cloud_external_id_conflict409Createclouds_provider_external_id_unique violation.
cloud_not_empty409DeleteCloud still owns child aggregates; payload carries CloudChildCounts.cloud_credentials.
provider_bundle_reference_moved409PatchA 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_large413Create / PatchBody exceeded the 8 KiB Cloud Inventory ceiling.
internal500every operationServer-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 the CloudCreateRequest / CloudPatchRequest / CloudResponse / CloudList / CloudChildCounts / CloudProvider schemas.
  • ../../../internal/transport/http/v1/clouds/ — the transport-tier implementation: the five handlers, the closed Problem.code taxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter on ListClouds.
  • ../../../schema/authz.zed — ReBAC schema; the definition cloud block declares the manage / operate / observe permissions 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 and Problem.code taxonomy.
  • ../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 — the PermissionDenied shape returned on 403.