Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET/v1/projects/{id}/credentialsListProjectCredentialstop-level project#observe on the parent Project (BEFORE the persistence read) + per-row project#observe filtercredential.list (granted, with post-filter item_count)(none)n/a
GET/v1/credentials/{id}GetCredentialproject#observe on the owning Project (AFTER an unavoidable pre-authz row read)credential.read(none)n/a
POST/v1/credentials/{id}/revokeRevokeCredentialproject#manage on the owning Projectcredential.revokeCredentialRevoked (emitted by the Custodian)8 KiB
POST/v1/credentials/{id}/rotateRotateCredentialproject#manage on the owning Projectcredential.rotateCredentialRotated (emitted by the Custodian)8 KiB
  • body_cap = 8 KiB (MaxCredentialRequestBodyBytes in internal/transport/http/v1/credentials/wiring.go) is enforced before the JSON decoder runs on the revoke / rotate bodies; an over-cap body surfaces as 413 request_body_too_large.
  • ListProjectCredentials.limit is clamped at the handler to [1, 200] with default 50.
  • ListProjectCredentials.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, credential_id) so the page renders in the order the credentials were created with a deterministic tie-break.
  • ListProjectCredentials runs a top-level project#observe check 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-row project#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.
  • GetCredential must 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 (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. RevokeCredential and RotateCredential take the same pre-authz-read posture for the same reason.
  • RevokeCredential reads the row to resolve the parent Project, runs the project#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.
  • RotateCredential accepts the new secret material inbound in the request body and a caller-observed expected_version. The Custodian performs a compare-and-swap against the live broker-row version: a mismatch surfaces as 409 credential_cas_conflict so two concurrent rotations fail closed, and rotating a revoked credential is refused with 409 credential_revoked. The response is the same metadata-only projection — no field of the inbound material is ever echoed back.

Path & query parameters

OperationParameterTypeRequiredNotes
ListProjectCredentialsid (path)string (uuid)yesParent Project UUIDv7. Non-zero. Malformed → 400 invalid_project_id. The shared ProjectID parameter component.
ListProjectCredentialscursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListProjectCredentialslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
GetCredential / RevokeCredential / RotateCredentialid (path)string (uuid)yesCredential 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-empty reason string); CredentialRotateRequest (expected_version + CredentialRotateMaterial).
  • Response: CredentialResponse (single), CredentialList (paged).
  • Embedded: the lifecycle status deliberately reuses the shared CloudCredentialStatus closed 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:

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

CodeStatusWhereMeaning
invalid_project_id400ListParent Project {id} was not a non-zero UUID.
invalid_credential_id400Get / Revoke / RotateCredential {id} was not a non-zero UUID.
invalid_limit400ListOut of [1, 200].
invalid_cursor400ListHMAC verification or structural decode failed.
invalid_body400Revoke / RotateBody could not be read or did not parse as the expected request schema.
invalid_revoke_reason400Revokereason was empty or whitespace-only.
invalid_rotate_material400Rotatematerial.payload empty / over-cap or material.ttl_seconds out of range.
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403ListCursor was minted for a different caller (per-(caller, pepper) HMAC binding rejected the replay).
credential_not_found404Get / Revoke / RotateNo credential with the given {id}.
credential_cas_conflict409Rotateexpected_version did not match the live broker-row version.
credential_revoked409RotateThe credential has been revoked and cannot be rotated.
request_body_too_large413Revoke / RotateBody exceeded the 8 KiB credentials ceiling.
credentials_not_provisioned501every operationThe composition root has not wired the read + lifecycle 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 *Credential* broker operations and the CredentialResponse / CredentialList / CredentialRevokeRequest / CredentialRotateRequest / CredentialRotateMaterial schemas.
  • ../../../internal/transport/http/v1/credentials/ — the transport-tier implementation: the four 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 project definition declares the manage / observe permissions this surface gates on.
  • ../api/cloud-credentials.md — sibling Cloud Credentials surface, with the same HMAC-signed caller-bound cursor pagination idiom and the shared CloudCredentialStatus lifecycle-status enum.
  • ../api/authz.md — the PermissionDenied shape returned on 403.