Appearance
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
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/clouds/{id}/cloud-credentials | IssueCloudCredential | cloud#manage on the parent Cloud (BEFORE the body decode) | cloud_credential.issue | CloudCredentialIssued (emitted by the Custodian) | 8 KiB |
| GET | /v1/clouds/{id}/cloud-credentials | ListCloudCredentials | top-level cloud#observe on the parent Cloud (BEFORE the persistence read) + per-row cloudcredential#observe filter | cloud_credential.list (granted, with post-filter item_count) | (none) | n/a |
| GET | /v1/cloud-credentials/{id} | GetCloudCredential | cloudcredential#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-assignments | GrantCredentialAssignment | cloudcredential#assign on the addressed credential (BEFORE the body decode) | credential_assignment.grant | CredentialAssignmentGranted | 8 KiB |
| POST | /v1/cloud-credentials/{id}/revoke | RevokeCloudCredential | cloud#manage on the owning Cloud | cloud_credential.revoke | CloudCredentialRevoked (emitted by the Custodian) | 8 KiB |
| POST | /v1/cloud-credentials/{id}/clouds | AttachCloudCredentialCloud | cloud#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} | DetachCloudCredentialCloud | cloud#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}/clouds | ListCloudCredentialClouds | cloudcredential#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(MaxCloudCredentialRequestBodyBytesininternal/transport/http/v1/cloudcredentials/wiring.go) is enforced before the JSON decoder runs on the issue and revoke bodies; an over-cap body surfaces as413 request_body_too_large.IssueCloudCredentialruns thecloud#managegate 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 aCloudCredentialIssuedoutbox event in one transaction — and returns201with aLocationheader pointing at the new credential's canonical read URL (/v1/cloud-credentials/{id}). The requestpayloadandkey_valuesare accepted inbound only; the201body is the same metadata-only projection the read paths return and never echoes the secret material or the KV storage location.ListCloudCredentials.limitis clamped at the handler to[1, 200]with default50.ListCloudCredentials.cursoris opaque, HMAC-signed by the server through theCursorCodecport and bound to the per-(caller, pepper) pseudonym; a tampered cursor surfaces as400 invalid_cursorand a cursor minted by one caller and replayed by another surfaces as403 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.ListCloudCredentialsruns a top-levelcloud#observecheck 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-rowcloud#observefilter for defence-in-depth. 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.GetCloudCredentialmust 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 (a404versus a403for a guessed UUID) is documented in the handler's DECISION block; the403is emitted via the audit-first permission-denied path so the probe is recorded.RevokeCloudCredentialreads the row to resolve the parent Cloud, runs thecloud#managegate, then delegates to the Custodian. Revocation is idempotent: revoking an already-revoked credential returns200with the unchanged metadata rather than a conflict, mirroring the domainCustodian.Revoke/Repository.RevokealreadyRevokedcontract.
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.
AttachCloudCredentialCloudadds a usage edge so the credential additionally serves the body'scloud_id. The caller is refused with403unless it holdscloud#manageon the target usage Cloud as well as the home Cloud. It is idempotent — re-attaching an already-attached Cloud returns201without a second edge. A revoked credential cannot pick up further usage Clouds and is refused with409 cloud_credential_revoked; acloud_idnaming no existing Cloud is refused with404 cloud_not_found.DetachCloudCredentialCloudremoves the usage edge for the{cloud_id}path segment.cloud#manageon 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 returns204. The home Cloud is undetachable: it anchors the KV-v2 path, so a detach targeting it is refused with409 cannot_detach_home_cloud.ListCloudCredentialCloudsreturns the Clouds the credential serves (home plus attached) incloud_idorder, behind the same opaque, HMAC-signed, caller-bound cursor the credential list uses.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| IssueCloudCredential / ListCloudCredentials | id (path) | string (uuid) | yes | Parent Cloud UUIDv7. Non-zero. Malformed → 400 invalid_cloud_id. The shared CloudID parameter component. |
| ListCloudCredentials | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListCloudCredentials | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| GetCloudCredential / RevokeCloudCredential | id (path) | string (uuid) | yes | Cloud Credential UUIDv7. Non-zero. Malformed → 400 invalid_cloud_credential_id. The shared CloudCredentialID parameter component. |
| AttachCloudCredentialCloud / DetachCloudCredentialCloud / ListCloudCredentialClouds | id (path) | string (uuid) | yes | Cloud Credential UUIDv7. Non-zero. Malformed → 400 invalid_cloud_credential_id. |
| DetachCloudCredentialCloud | cloudId (path) | string (uuid) | yes | Usage Cloud UUIDv7 to detach. Non-zero. Malformed → 400 invalid_cloud_id. |
| ListCloudCredentialClouds | cursor / limit (query) | string / integer | no | Same 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(adisplay_name, a base64payload, and an optionalkey_valuesmap — accepted inbound only);CloudCredentialRevokeRequest(a single non-emptyreasonstring). - 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:
revokedwhenrevoked_atis set — this wins overexpiredeven when both timestamps are populated, because the deliberate operator action is the more salient lifecycle fact than passive expiry.- otherwise
expiredwhenexpired_atis set ORexpires_atis 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.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_cloud_id | 400 | Issue / List / Attach / Detach | Parent or usage Cloud {id} / {cloud_id} / body cloud_id was not a non-zero UUID. |
invalid_cloud_credential_id | 400 | Get / Revoke / Attach / Detach / ListClouds | Credential {id} was not a non-zero UUID. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_cursor | 400 | List | HMAC verification or structural decode failed. |
invalid_body | 400 | Issue / Revoke | Body could not be read or did not parse as the operation's request schema. |
invalid_display_name | 400 | Issue | display_name was empty or whitespace-only. |
invalid_payload | 400 | Issue | payload decoded to zero bytes. |
invalid_revoke_reason | 400 | Revoke | reason was empty or whitespace-only. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
cursor_binding_mismatch | 403 | List | Cursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay). |
cloud_credential_not_found | 404 | Get / Revoke / Attach / Detach / ListClouds | No Cloud Credential with the given {id}. |
cloud_not_found | 404 | Attach | The body cloud_id names no existing Cloud. |
cloud_credential_revoked | 409 | Attach | A revoked credential cannot pick up further usage Clouds. |
cannot_detach_home_cloud | 409 | Detach | The addressed Cloud is the credential's undetachable home Cloud. |
request_body_too_large | 413 | Issue / Revoke / Attach | Body exceeded the 8 KiB Cloud Credentials ceiling. |
cloud_credentials_not_provisioned | 501 | every operation | The composition root has not wired the read + revoke dependency bundle yet. |
internal | 500 | every operation | Server-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 theCloudCredentialResponse/CloudCredentialList/CloudCredentialStatus/CloudCredentialIssueRequest/CloudCredentialRevokeRequestschemas.../../../internal/transport/http/v1/cloudcredentials/— the transport-tier implementation: the three handlers, the closedProblem.codetaxonomy, 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; theclouddefinition declares themanage/operate/observepermissions this surface gates on.../api/clouds.md— sibling Cloud Inventory CRUD surface, with the same HMAC-signed caller-bound cursor pagination idiom and the sameobserve/manageReBAC gates.../api/authz.md— thePermissionDeniedshape returned on 403.