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

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#managecloud.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, or region_defaults — an empty body surfaces as 400 empty_patch.
  • 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).

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.

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)

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_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).
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_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.
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/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.