Skip to content

Cloud Credentials HTTP API

This is the reference for the operator-facing Cloud Credentials issue + read + revoke 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.

Rotation has no HTTP surface and remains in-process only. This surface adds four operations: an operator issue trigger, two reads, and one operator revoke trigger. The issue and revoke handlers delegate to the Custodian, which stays the only mutation entry point into the context. The gates below resolve against two definitions in schema/authz.zed. Mutations address the parent cloud, whose manage folds in owner and cloud_admin. Reads of a specific credential address the cloudcredential itself: observe = owner + assigner + parent->manage + uses->read. The parent->manage term keeps a Cloud's administrators seeing every credential under it — necessary because an owner is optional at issuance — while uses->read means an approved Credential Assignment is what reveals a credential to the consuming Project's people.

The split is deliberate. cloud#observe also derives from its consuming Projects, so had the credential reads stayed on the parent Cloud, a Cloud Assignment alone would have exposed the metadata of every credential under that Cloud — folding the credential-assignment governance into the Cloud one. Listing a Cloud's credentials therefore takes both: cloud#observe to enumerate the Cloud at all, then a per-row cloudcredential#observe. There is no cloud#read or cloudcredential#read; the read paths gate on observe, the lowest-privilege read-equivalent. For the cursor-paginated list idiom this surface inherits, see ../api/clouds.md — Clouds and Cloud Credentials share the same HMAC-signed caller-bound cursor mechanism.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/clouds/{id}/cloud-credentialsIssueCloudCredentialcloud#manage on the parent Cloud (BEFORE the body decode)cloud_credential.issueCloudCredentialIssued (emitted by the Custodian)8 KiB
GET/v1/clouds/{id}/cloud-credentialsListCloudCredentialstop-level cloud#observe on the parent Cloud (BEFORE the persistence read) + per-row cloudcredential#observe filtercloud_credential.list (granted, with post-filter item_count)(none)n/a
GET/v1/cloud-credentials/{id}GetCloudCredentialcloudcredential#observe on the addressed credential (AFTER a row read kept first so a missing credential is 404, not 403)cloud_credential.read(none)n/a
POST/v1/cloud-credentials/{id}/credential-assignmentsGrantCredentialAssignmentcloudcredential#assign on the addressed credential (BEFORE the body decode)credential_assignment.grantCredentialAssignmentGranted8 KiB
POST/v1/cloud-credentials/{id}/revokeRevokeCloudCredentialcloud#manage on the owning Cloudcloud_credential.revokeCloudCredentialRevoked (emitted by the Custodian)8 KiB
POST/v1/cloud-credentials/{id}/cloudsAttachCloudCredentialCloudcloud#manage on the credential's home Cloud (AFTER an unavoidable pre-authz row read) and on the target usage Cloud (AFTER the body decode)cloud_credential.attach_cloud(none)8 KiB
DELETE/v1/cloud-credentials/{id}/clouds/{cloud_id}DetachCloudCredentialCloudcloud#manage on either the credential's home Cloud or the target usage Cloud (AFTER an unavoidable pre-authz row read)cloud_credential.detach_cloud(none)n/a
GET/v1/cloud-credentials/{id}/cloudsListCloudCredentialCloudscloudcredential#observe on the addressed credential (AFTER a row read kept first so a missing credential is 404, not 403)cloud_credential.list_clouds(none)n/a
  • body_cap = 8 KiB (MaxCloudCredentialRequestBodyBytes in internal/transport/http/v1/cloudcredentials/wiring.go) is enforced before the JSON decoder runs on the issue and revoke bodies; an over-cap body surfaces as 413 request_body_too_large.
  • IssueCloudCredential runs the cloud#manage gate on the parent Cloud before decoding the request body, so an unauthorised caller is refused without the server ever reading the secret material. It then validates the body, delegates to the Custodian — which writes the material to OpenBao KV-v2, persists the broker row, and appends a CloudCredentialIssued outbox event in one transaction — and returns 201 with a Location header pointing at the new credential's canonical read URL (/v1/cloud-credentials/{id}). The request payload and key_values are accepted inbound only; the 201 body is the same metadata-only projection the read paths return and never echoes the secret material or the KV storage location.
  • ListCloudCredentials.limit is clamped at the handler to [1, 200] with default 50.
  • ListCloudCredentials.cursor is opaque, HMAC-signed by the server through the CursorCodec port and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as 400 invalid_cursor and a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. The keyset is (created_at, cloud_credential_id) so the page renders in the order the credentials were created with a deterministic tie-break.
  • ListCloudCredentials runs a top-level cloud#observe check on the parent Cloud before the persistence read so an unauthorised caller never observes the existence side-channel of the Cloud's credential set, then layers a per-row cloud#observe filter for defence-in-depth. 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.
  • GetCloudCredential must read the persistence row before the ReBAC check because the credential id on the path does not encode its owning Cloud — the gate target is unknowable until the row is read. The accepted narrow existence side-channel (a 404 versus a 403 for a guessed UUID) is documented in the handler's DECISION block; the 403 is emitted via the audit-first permission-denied path so the probe is recorded.
  • RevokeCloudCredential reads the row to resolve the parent Cloud, runs the cloud#manage gate, then delegates to the Custodian. Revocation is idempotent: revoking an already-revoked credential returns 200 with the unchanged metadata rather than a conflict, mirroring the domain Custodian.Revoke / Repository.RevokealreadyRevoked contract.

Credential to Cloud association

A Cloud Credential serves its home Cloud plus any number of additional usage Clouds, recorded in the plexsphere.cloud_credential_cloud_usage join. Attaching a usage Cloud both mutates the credential the home Cloud owns and makes the target Cloud start serving that credential's secret material, so attach gates on manage of both the home Cloud and the target Cloud — a home-only gate would let one tenant inject a credential into another tenant's Cloud. Detach gates on manage of either Cloud so the target Cloud's owner can remove a foreign edge — the usage join's cloud_id foreign key is ON DELETE RESTRICT, so an unremovable edge would otherwise pin the Cloud against deletion. The list gates on the home Cloud's observe.

  • AttachCloudCredentialCloud adds a usage edge so the credential additionally serves the body's cloud_id. The caller is refused with 403 unless it holds cloud#manage on the target usage Cloud as well as the home Cloud. It is idempotent — re-attaching an already-attached Cloud returns 201 without a second edge. A revoked credential cannot pick up further usage Clouds and is refused with 409 cloud_credential_revoked; a cloud_id naming no existing Cloud is refused with 404 cloud_not_found.
  • DetachCloudCredentialCloud removes the usage edge for the {cloud_id} path segment. cloud#manage on either the home Cloud or the target Cloud authorises it, so the target Cloud's owner can remediate an edge another tenant attached. It is idempotent — detaching an absent edge returns 204. The home Cloud is undetachable: it anchors the KV-v2 path, so a detach targeting it is refused with 409 cannot_detach_home_cloud.
  • ListCloudCredentialClouds returns the Clouds the credential serves (home plus attached) in cloud_id order, behind the same opaque, HMAC-signed, caller-bound cursor the credential list uses.

Path & query parameters

OperationParameterTypeRequiredNotes
IssueCloudCredential / ListCloudCredentialsid (path)string (uuid)yesParent Cloud UUIDv7. Non-zero. Malformed → 400 invalid_cloud_id. The shared CloudID parameter component.
ListCloudCredentialscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListCloudCredentialslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
GetCloudCredential / RevokeCloudCredentialid (path)string (uuid)yesCloud Credential UUIDv7. Non-zero. Malformed → 400 invalid_cloud_credential_id. The shared CloudCredentialID parameter component.
AttachCloudCredentialCloud / DetachCloudCredentialCloud / ListCloudCredentialCloudsid (path)string (uuid)yesCloud Credential UUIDv7. Non-zero. Malformed → 400 invalid_cloud_credential_id.
DetachCloudCredentialCloudcloudId (path)string (uuid)yesUsage Cloud UUIDv7 to detach. Non-zero. Malformed → 400 invalid_cloud_id.
ListCloudCredentialCloudscursor / limit (query)string / integernoSame opaque-cursor / [1, 200] clamp contract as the credential list.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

  • Request: CloudCredentialIssueRequest (a display_name, a base64 payload, and an optional key_values map — accepted inbound only); CloudCredentialRevokeRequest (a single non-empty reason string).
  • Response: CloudCredentialResponse (single), CloudCredentialList (paged).
  • Embedded: CloudCredentialStatus (closed enum: active, expired, revoked).

The response carries the resolved id, cloud_id, display_name, version, derived status, expires_at, nullable revoked_at, nullable expired_at, and the created_at / updated_at lifecycle timestamps. The shape is shared by GetCloudCredential, ListCloudCredentials, and RevokeCloudCredential so clients only need one binding.

Derived status

status is computed by the read surface from the lifecycle timestamps; it is not a stored column. The precedence is fixed:

  • revoked when revoked_at is set — this wins over expired even when both timestamps are populated, because the deliberate operator action is the more salient lifecycle fact than passive expiry.
  • otherwise expired when expired_at is set OR expires_at is in the past.
  • otherwise active.

Omitted KV fields

The projection is deliberately metadata-only. The KV mount, KV path, KV version, and every byte of secret material are never exposed on this surface — the storage location is a storage-internal detail an operator has no reason to see, and leaking it would widen the credential's blast radius. The omission is structural: the transport-tier view type carries no kv_* field, so the projection cannot leak the storage location even by accident.

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_cloud_id400Issue / List / Attach / DetachParent or usage Cloud {id} / {cloud_id} / body cloud_id was not a non-zero UUID.
invalid_cloud_credential_id400Get / Revoke / Attach / Detach / ListCloudsCredential {id} was not a non-zero UUID.
invalid_limit400ListOut of [1, 200].
invalid_cursor400ListHMAC verification or structural decode failed.
invalid_body400Issue / RevokeBody could not be read or did not parse as the operation's request schema.
invalid_display_name400Issuedisplay_name was empty or whitespace-only.
invalid_payload400Issuepayload decoded to zero bytes.
invalid_revoke_reason400Revokereason was empty or whitespace-only.
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403ListCursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay).
cloud_credential_not_found404Get / Revoke / Attach / Detach / ListCloudsNo Cloud Credential with the given {id}.
cloud_not_found404AttachThe body cloud_id names no existing Cloud.
cloud_credential_revoked409AttachA revoked credential cannot pick up further usage Clouds.
cannot_detach_home_cloud409DetachThe addressed Cloud is the credential's undetachable home Cloud.
request_body_too_large413Issue / Revoke / AttachBody exceeded the 8 KiB Cloud Credentials ceiling.
cloud_credentials_not_provisioned501every operationThe composition root has not wired the read + revoke dependency bundle yet.
internal500every operationServer-side failure path.

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

Cross-references

  • ../../../api/openapi/plexsphere-v1.yaml — OpenAPI 3.1 spec; the *CloudCredential* operations and the CloudCredentialResponse / CloudCredentialList / CloudCredentialStatus / CloudCredentialIssueRequest / CloudCredentialRevokeRequest schemas.
  • ../../../internal/transport/http/v1/cloudcredentials/ — the transport-tier implementation: the three handlers, the closed Problem.code taxonomy, the metadata-only projection, the body-cap and limit-clamp constants, and the per-row visibility filter on the list path.
  • ../../../schema/authz.zed — ReBAC schema; the cloud definition declares the manage / operate / observe permissions this surface gates on.
  • ../api/clouds.md — sibling Cloud Inventory CRUD surface, with the same HMAC-signed caller-bound cursor pagination idiom and the same observe / manage ReBAC gates.
  • ../api/authz.md — the PermissionDenied shape returned on 403.