Appearance
Credential Broker HTTP API
This is the reference for the operator-facing OpenBao Credential Broker read + lifecycle 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 Credential Broker Custodian is otherwise an in-process bounded context — issuance has no HTTP surface and remains Custodian-only. This surface adds exactly four operations: two reads plus the operator revoke and rotate triggers. The revoke and rotate handlers delegate to the Custodian, which stays the only mutation entry point into the context, so the fail-closed Domain-resolution posture is preserved end-to-end. The ReBAC chain the gates below resolve against is the project definition in schema/authz.zed — manage is admin (+ parent Domain manage), and observe adds the maintainer/operator/viewer relations (+ parent Domain read). The read paths gate on observe, the lowest-privilege read-equivalent; the revoke + rotate paths gate on manage. For the cursor-paginated list idiom this surface inherits, see ../api/cloud-credentials.md — both credential surfaces share the same HMAC-signed caller-bound cursor mechanism and the same CloudCredentialStatus lifecycle-status enum.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | /v1/projects/{id}/credentials | ListProjectCredentials | top-level project#observe on the parent Project (BEFORE the persistence read) + per-row project#observe filter | credential.list (granted, with post-filter item_count) | (none) | n/a |
| GET | /v1/credentials/{id} | GetCredential | project#observe on the owning Project (AFTER an unavoidable pre-authz row read) | credential.read | (none) | n/a |
| POST | /v1/credentials/{id}/revoke | RevokeCredential | project#manage on the owning Project | credential.revoke | CredentialRevoked (emitted by the Custodian) | 8 KiB |
| POST | /v1/credentials/{id}/rotate | RotateCredential | project#manage on the owning Project | credential.rotate | CredentialRotated (emitted by the Custodian) | 8 KiB |
body_cap = 8 KiB(MaxCredentialRequestBodyBytesininternal/transport/http/v1/credentials/wiring.go) is enforced before the JSON decoder runs on the revoke / rotate bodies; an over-cap body surfaces as413 request_body_too_large.ListProjectCredentials.limitis clamped at the handler to[1, 200]with default50.ListProjectCredentials.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, credential_id)so the page renders in the order the credentials were created with a deterministic tie-break.ListProjectCredentialsruns a top-levelproject#observecheck on the parent Project before the persistence read so an unauthorised caller never observes the existence side-channel of the Project's credential set, then layers a per-rowproject#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.GetCredentialmust read the persistence row before the ReBAC check because the credential id on the path does not encode its owning Project — 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.RevokeCredentialandRotateCredentialtake the same pre-authz-read posture for the same reason.RevokeCredentialreads the row to resolve the parent Project, runs theproject#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.RotateCredentialaccepts the new secret material inbound in the request body and a caller-observedexpected_version. The Custodian performs a compare-and-swap against the live broker-row version: a mismatch surfaces as409 credential_cas_conflictso two concurrent rotations fail closed, and rotating a revoked credential is refused with409 credential_revoked. The response is the same metadata-only projection — no field of the inbound material is ever echoed back.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| ListProjectCredentials | id (path) | string (uuid) | yes | Parent Project UUIDv7. Non-zero. Malformed → 400 invalid_project_id. The shared ProjectID parameter component. |
| ListProjectCredentials | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListProjectCredentials | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| GetCredential / RevokeCredential / RotateCredential | id (path) | string (uuid) | yes | Credential UUIDv7. Non-zero. Malformed → 400 invalid_credential_id. The shared CredentialID parameter component. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Request:
CredentialRevokeRequest(a single non-emptyreasonstring);CredentialRotateRequest(expected_version+CredentialRotateMaterial). - Response:
CredentialResponse(single),CredentialList(paged). - Embedded: the lifecycle
statusdeliberately reuses the sharedCloudCredentialStatusclosed enum (active,expired,revoked) — the broker's status vocabulary and precedence are byte-identical to the Cloud Credentials surface, so a parallel enum would be needless duplication.
The response carries the resolved id, project_id, version, derived status, expires_at, nullable revoked_at, nullable expired_at, and the created_at / updated_at lifecycle timestamps. The shape is shared by GetCredential, ListProjectCredentials, RevokeCredential, and RotateCredential so clients only need one binding.
CredentialRotateMaterial carries a base64 payload (≤ 4 KiB decoded), a positive ttl_seconds (≤ 365 days), and an optional flat key_values map. All three are accepted inbound only and never returned; an empty payload or non-positive ttl surfaces as 400 invalid_rotate_material.
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 returned 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. Secret material is accepted inbound on RotateCredential but is never projected back.
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_project_id | 400 | List | Parent Project {id} was not a non-zero UUID. |
invalid_credential_id | 400 | Get / Revoke / Rotate | 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 | Revoke / Rotate | Body could not be read or did not parse as the expected request schema. |
invalid_revoke_reason | 400 | Revoke | reason was empty or whitespace-only. |
invalid_rotate_material | 400 | Rotate | material.payload empty / over-cap or material.ttl_seconds out of range. |
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). |
credential_not_found | 404 | Get / Revoke / Rotate | No credential with the given {id}. |
credential_cas_conflict | 409 | Rotate | expected_version did not match the live broker-row version. |
credential_revoked | 409 | Rotate | The credential has been revoked and cannot be rotated. |
request_body_too_large | 413 | Revoke / Rotate | Body exceeded the 8 KiB credentials ceiling. |
credentials_not_provisioned | 501 | every operation | The composition root has not wired the read + lifecycle 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*Credential*broker operations and theCredentialResponse/CredentialList/CredentialRevokeRequest/CredentialRotateRequest/CredentialRotateMaterialschemas.../../../internal/transport/http/v1/credentials/— the transport-tier implementation: the four 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; theprojectdefinition declares themanage/observepermissions this surface gates on.../api/cloud-credentials.md— sibling Cloud Credentials surface, with the same HMAC-signed caller-bound cursor pagination idiom and the sharedCloudCredentialStatuslifecycle-status enum.../api/authz.md— thePermissionDeniedshape returned on 403.