Appearance
OpenBao Credential Broker
This is the per-port API reference for the OpenBao Credential Broker. It maps each value object, port, and outbox event to its field-level shape. The reference is a map, not a duplicate contract — the authoritative source is the Go code under internal/provisioning/credentials/ and the SQL schema under 0019_credential_broker.sql.
For the bounded-context narrative (the aggregate state machine, the deterministic KV-v2 path, the at-most-once outbox token, the sweeper cadence, the threat model) see ../../contexts/provisioning/credentials.md.
The broker exposes no /v1 HTTP surface of its own. Callers reach it through the in-process Custodian facade; external consumers (Kubernetes workloads) read the materialised secret via External Secrets Operator + Crossplane plumbed at the application level. A workspace-level drift gate scans the generated OpenAPI document for any /v1/credentials* path and asserts the count is zero.
Custodian — facade methods
The Custodian interface (internal/provisioning/credentials/ports.go) is the only port transport handlers and CLI callers reach directly. Implementations orchestrate the Repository, Materialiser, audit, and outbox in one Postgres transaction.
| Method | Input | Output | Mandatory failure modes |
|---|---|---|---|
Issue(ctx, IssueInput) | IssueInput | IssueResult | ErrDomainUnresolved, ErrMaterialiserUnavailable, ErrIssueAtomicityViolated, ErrCredentialAlreadyExists, ErrPathAlreadyMaterialised |
Rotate(ctx, RotateInput) | RotateInput | RotateResult | ErrCredentialNotFound, ErrCredentialRevoked, ErrBrokerCASConflict, ErrKVStoreCASConflict, ErrDomainUnresolved, ErrMaterialiserUnavailable |
Revoke(ctx, RevokeInput) | RevokeInput | error | ErrCredentialNotFound, ErrCredentialRevoked, ErrDomainUnresolved |
Lookup(ctx, CredentialID) | CredentialID | CredentialRow | ErrCredentialNotFound |
Value objects
CredentialID
Binary form of the credential UUIDv7 used as the broker-row primary key. Defined as type CredentialID [16]byte.
| Field / method | Type | Notes |
|---|---|---|
| underlying | [16]byte | UUIDv7 raw bytes. The zero value is treated as "not yet assigned" and is rejected by every aggregate invariant that requires a concrete reference. |
String() string | method | Returns the canonical 8-4-4-4-12 hyphenated UUID textual form. The output matches the format used elsewhere in the platform so logs join cleanly with sibling contexts. |
IsZero() bool | method | Reports whether the CredentialID has not been assigned. |
ParseCredentialID(s string) | function | Parses a canonical hyphenated UUID string into a CredentialID. Returned errors are package-local so the caller does not need to import an external UUID library to branch on the failure mode. |
Material
Opaque secret value the caller hands the broker on Issue and Rotate. Caller-owned bytes — the broker MUST defensively copy both Payload and KeyValues before any persistence call so subsequent caller mutation cannot reach the stored row.
| Field | Go type | Persistence | Notes |
|---|---|---|---|
Payload | []byte | data.payload in the KV-v2 row. | The opaque bytes the caller wants stored. |
TTL | time.Duration | expires_at = now() + TTL on the broker row. | The broker's expiry budget; drives the Sweeper's eligibility predicate. |
KeyValues | map[string]string | data.* in the KV-v2 row. | The flat data map written alongside Payload. |
IssueInput
Value object Custodian.Issue accepts.
| Field | Go type | Required | Notes |
|---|---|---|---|
ProjectID | [16]byte | yes | The residency pivot. Drives kv_path derivation and the Domain resolution. Zero value is rejected before any persistence call. |
Material | Material | yes | The bytes the broker writes to KV-v2 plus the TTL from which expires_at is derived. |
IssueResult
Value object Custodian.Issue returns on success.
| Field | Go type | Source |
|---|---|---|
CredentialID | CredentialID | UUIDv7 minted by the application at issuance. |
KVMount | string | Materialiser.DerivePath (verbatim from Config.KVMount). |
KVPath | string | Materialiser.DerivePath (projects/<projectID>/credentials/<credentialID>). |
Version | int32 | Broker-row version. Always 1 for a fresh issuance. |
KVVersion | int32 | KV-v2 store's own version on the freshly-written secret. Always 1 for a fresh issuance. |
ExpiresAt | time.Time | UTC; now() + Material.TTL. |
RotateInput
Value object Custodian.Rotate accepts.
| Field | Go type | Required | Notes |
|---|---|---|---|
CredentialID | CredentialID | yes | The aggregate to rotate. |
ExpectedVersion | int32 | yes | The broker-row version the caller observed via Lookup. A mismatch surfaces ErrBrokerCASConflict so concurrent rotates fail closed. |
Material | Material | yes | The new bytes; expires_at is recomputed from Material.TTL. |
RotateResult
Value object Custodian.Rotate returns on success.
| Field | Go type | Source |
|---|---|---|
Version | int32 | New broker-row version (ExpectedVersion + 1). |
KVVersion | int32 | New KV-v2 version reported by Materialiser.Put. |
ExpiresAt | time.Time | UTC; now() + Material.TTL. |
The CredentialID is implicit in the input and therefore not echoed on the result.
RevokeInput
Value object Custodian.Revoke accepts.
| Field | Go type | Required | Notes |
|---|---|---|---|
CredentialID | CredentialID | yes | The aggregate to revoke. |
Reason | string | yes | Operator-supplied audit string. Recorded on the CredentialRevoked outbox event payload. |
Revocation is terminal — once revoked_at is non-null no further rotation is possible. A second Revoke surfaces ErrCredentialRevoked.
CredentialRow
Projection Repository.FindByID and Repository.ListExpired return. The shape mirrors the plexsphere.credential row one-for-one so the repository adapter is a thin marshalling layer with no domain computation.
| Field | Go type | Column | Notes |
|---|---|---|---|
CredentialID | CredentialID | id | UUIDv7 primary key. |
ProjectID | [16]byte | project_id | UUIDv7. ON DELETE CASCADE — deleting the Project deletes its credentials. |
DomainID | [16]byte | domain_id | UUIDv7. ON DELETE RESTRICT — a Domain with credentials cannot be deleted. |
KVMount | string | kv_mount | The broker's configured Config.KVMount at the time of issuance. |
KVPath | string | kv_path | The deterministic projection — see path derivation. UNIQUE per (project_id, kv_path). |
KVVersion | int32 | kv_version | The OpenBao KV-v2 store's own version, mirrored on the broker row so operators can correlate without round-tripping to OpenBao. |
Version | int32 | version | Broker-row CAS counter. Incremented by every application-side mutation. |
ExpiresAt | time.Time | expires_at | Wall-clock instant the credential ceases to be valid. |
RevokedAt | *time.Time | revoked_at | Non-nil once the operator has issued a soft-delete. SQL CHECK gates revoked_at / expired_at exclusivity. |
ExpiredAt | *time.Time | expired_at | Non-nil once the Sweeper has observed the credential's expiry. |
CreatedAt | time.Time | created_at | UTC. |
UpdatedAt | time.Time | updated_at | UTC; bumped by every RotateCAS / Revoke / MarkExpired. |
Materialiser — KV-v2 adapter
The Materialiser interface is the narrow port the credentials package consumes for KV-v2 access. The default in-package adapter exposes only DerivePath; the OpenBao-backed adapter ships under the credentials_openbao build tag in materialiser/openbao/.
| Method | Signature | Returns | Notes |
|---|---|---|---|
Put | Put(ctx, mount, path string, data Material, cas int32) (newVersion int32, err error) | New KV-v2 version on success. | Writes data at /<mount>/data/<path> with the supplied CAS expectation. CAS mismatch → ErrKVStoreCASConflict. Network / transport failure → ErrMaterialiserUnavailable. |
Delete | Delete(ctx, mount, path string) error | nil on success. | Soft-deletes the secret at /<mount>/data/<path>. Non-CAS failure → ErrMaterialiserUnavailable. |
DerivePath | DerivePath(projectID, credentialID [16]byte) (mount, path string) | The deterministic (mount, path) pair. | Pure logic. Zero projectID or credentialID → empty pair (see DECISION block on materialiser.go). |
Repository — Postgres port
The Repository interface is the persistence port the Custodian writes through. The Postgres adapter (repo/credentials_pg.go) is a thin wrapper over the sqlc-generated queries from C0_credentials.sql. Constraint-name dispatch maps SQLSTATE 23505 collisions to the canonical sentinels.
| Method | Returns | Mandatory failure modes |
|---|---|---|
Create(ctx, CredentialRow) | error | ErrCredentialAlreadyExists, ErrPathAlreadyMaterialised, ErrInvalidPathInput |
FindByID(ctx, CredentialID) | (CredentialRow, error) | ErrCredentialNotFound |
RotateCAS(ctx, id, expectedVersion, newKVVersion, expiresAt) | (newVersion int32, error) | ErrCredentialNotFound, ErrBrokerCASConflict, ErrCredentialRevoked |
Revoke(ctx, CredentialID) | (alreadyRevoked bool, error) | ErrCredentialNotFound (the already-revoked outcome is a value, not an error). |
ListExpired(ctx, now, limit) | ([]CredentialRow, error) | none — empty page on no results. |
MarkExpired(ctx, id, when) | error | ErrCredentialNotFound, ErrCredentialRevoked |
AppendOutboxEvent(ctx, id, eventType, payload) | error | unique-violation on (credential_id, event_type) is mapped to a no-op for Sweeper / Revoke idempotency (see credential_outbox_token). |
RunInTx(ctx, fn func(tx Repository) error) | error | propagates fn's error verbatim; rolls back on any non-nil return. |
Sweeper — TTL expiry worker
The Sweeper ports and configuration. See Sweeper cadence for the boot vs steady-state contract.
| Surface | Signature | Notes |
|---|---|---|
New(repo, audit, clock, opts...) | (*Sweeper, error) | Refuses construction on any nil collaborator. |
(*Sweeper).Run(ctx) | (scanned, expired int, err error) | Idempotent and self-terminating. Walks ListExpired in pages of defaultPageSize=256 until an empty page; per row applies MarkExpired + AppendOutboxEvent. First clean return flips the /readyz readiness flag. |
(*Sweeper).ProbeFunc(ctx) | error | Returns errProbePending until the first Run completes; nil thereafter. Does NOT re-trigger Run. |
WithPageSize(n int32) | Option | Overrides the per-tick ListExpired page width. Non-positive values are ignored (degrade to default). |
WithLogger(logger *slog.Logger) | Option | Overrides the structured logger; nil ignored. |
WithRegisterer(reg prometheus.Registerer) | Option | Wires Prometheus counters; nil keeps zero-value mode. |
ProbeName | const string = "credentials-sweeper" | Operator-facing probe identifier. Part of the wire contract — runbooks grep for this string verbatim. |
Configuration
The Config value type carried by the credentials package and the production composition root.
| Field | Go type | Source | Effect |
|---|---|---|---|
SweepInterval | time.Duration | PLEXSPHERE_CREDENTIALS_SWEEP_INTERVAL | Steady-state period between Sweeper.Run invocations. Zero falls back to defaultCredentialsSweepInterval = 30s. |
KVMount | string | PLEXSPHERE_CREDENTIALS_KV_MOUNT | The OpenBao KV-v2 mount prefix every derived credential path is anchored under. Empty when DSN is set is a build-time error (ErrCredentialsKVMountRequired). |
The production composition root holds additional knobs on productionCredentialsConfig (DSN, KVAddress, KVAuth, AllowInsecureMaterialiser) — see cmd/plexsphere/credentials_factory_prod.go for the full inventory and the build-time gate.
Error sentinels
Closed enumeration. Callers branch on these via errors.Is — wrapping is fine, identity must remain intact. The set is authoritative; adding a new sentinel is a deliberate edit to internal/provisioning/credentials/errors.go and a corresponding update to this table.
| Sentinel | Source layer | Trigger |
|---|---|---|
ErrCredentialNotFound | Repository / Custodian | FindByID / Lookup for an absent CredentialID. |
ErrCredentialAlreadyExists | Repository (PRIMARY KEY collision) | A second Issue for the same credential id with mismatched fields. |
ErrCredentialRevoked | Custodian / Repository | Rotate or Revoke or MarkExpired on a row whose revoked_at is non-null. |
ErrBrokerCASConflict | Repository.RotateCAS | Broker-row version advanced past caller's ExpectedVersion. |
ErrKVStoreCASConflict | Materialiser.Put | KV-v2 store's kv_version advanced past broker's expected value. Distinct from ErrBrokerCASConflict — different remediation. |
ErrDomainUnresolved | Custodian | credentials.DomainResolver.Resolve returns an error or a zero domainID during a broker operation. Fail-closed. |
ErrAuditUnavailable | Custodian | AuditSink.Record fails after the broker has committed. The broker decision is durable but the audit chain has gapped. Counter-only — operators alert on AuditSinkFailuresTotal(). |
ErrMaterialiserUnavailable | Materialiser.Put / Delete | Non-CAS KV-v2 failure (network, transport timeout, OpenBao unsealed-but-blocked). |
ErrIssueAtomicityViolated | Custodian | Compensating Materialiser.Delete fired after a Postgres rollback — KV-v2 row created but Postgres failed to commit. |
ErrInvalidPathInput | Repository | kv_path violates the path-format invariant before SQL UNIQUE runs. |
ErrPathAlreadyMaterialised | Repository (constraint-name dispatch on credential_kv_path_unique UNIQUE on (kv_mount, kv_path)) | Chosen-credential-id collision against an existing Project's deterministic path. Distinct from ErrCredentialAlreadyExists — they return for different SQL constraints and have different remediations. |
Outbox event schemas
Closed enumeration of four event types. The event_type column value is the discriminator stored verbatim in plexsphere.outbox_events. The payload is the JSON marshal of the matching Go struct under internal/provisioning/credentials/events/.
The package-local drift gate TestEventTypesAreClosedSet enforces the four-event allow-list — adding a fifth value is a breaking schema change, not a switch-statement extension.
CredentialIssued — event_type = "credentials.CredentialIssued"
| JSON field | Go type | Notes |
|---|---|---|
event_id | [16]byte | UUIDv7 minted at emission. |
occurred_at | time.Time | UTC. |
credential_id | [16]byte | UUIDv7. |
project_id | [16]byte | UUIDv7. The residency pivot. |
kv_mount | string | Verbatim from Config.KVMount. |
kv_path | string | The deterministic projection (see path derivation). |
version | int32 | Broker-row version. Always 1 for issuance. |
kv_version | int32 | KV-v2 version. Always 1 for issuance. |
expires_at | time.Time | UTC. |
CredentialRotated — event_type = "credentials.CredentialRotated"
| JSON field | Go type | Notes |
|---|---|---|
event_id | [16]byte | UUIDv7 minted at emission. |
occurred_at | time.Time | UTC. |
credential_id | [16]byte | UUIDv7. |
version | int32 | New broker-row version. |
kv_version | int32 | New KV-v2 version. |
expires_at | time.Time | UTC; recomputed from Material.TTL. |
kv_mount and kv_path are deliberately not carried — they are stable across rotations (the path derivation is invariant on the CredentialID) and would just bloat the outbox row. Consumers that need them re-derive from (project_id, credential_id) or look them up on the broker row.
CredentialRevoked — event_type = "credentials.CredentialRevoked"
| JSON field | Go type | Notes |
|---|---|---|
event_id | [16]byte | UUIDv7 minted at emission. |
occurred_at | time.Time | UTC. |
credential_id | [16]byte | UUIDv7. |
reason | string | Operator-supplied audit string from RevokeInput.Reason. |
CredentialExpired — event_type = "credentials.CredentialExpired"
| JSON field | Go type | Notes |
|---|---|---|
event_id | [16]byte | UUIDv7 minted at emission. |
occurred_at | time.Time | UTC. The expiry timestamp by definition; the credential's expires_at is <= occurred_at. |
credential_id | [16]byte | UUIDv7. |
The event carries only the credential identity — downstream consumers re-derive everything else from the broker row or from the prior CredentialIssued payload.
Metrics
The Sweeper exports two zero-value-tolerant counter vectors via metrics.go.
| Metric | Type | Increments when |
|---|---|---|
credentials_sweeper_invocations_total | counter | Sweeper.Run is entered (per call). |
credentials_sweeper_expirations_total | counter | A row is successfully MarkExpired and the matching CredentialExpired outbox row is appended. |
A nil prometheus.Registerer keeps the counters in zero-value mode — Run still increments the in-memory pointers but no scrape surface is registered. The integration test credentials_broker_sweeper_ticker_test.go asserts credentials_sweeper_invocations_total advances as the ticker fires.
Cross-references
../../contexts/provisioning/credentials.md— the bounded-context narrative: aggregates, state machine, Sweeper cadence, KV-v2 path derivation, threat model.../../../internal/provisioning/credentials/ports.go— the authoritative Go declarations for every type listed in the Value objects section.../../../internal/provisioning/credentials/errors.go— the authoritative Go declarations and DECISION blocks for every sentinel listed in the Error sentinels section.../../../internal/provisioning/credentials/events/events.go— the authoritative event payload struct definitions.../../../internal/platform/db/migrations/0019_credential_broker.sql— the persistence schema forplexsphere.credentialandplexsphere.credential_outbox_token.../../../internal/platform/db/queries/C0_credentials.sql— the sqlc-annotated queries the Postgres adapter wraps.../../../cmd/plexsphere/credentials_factory_prod.go— the production composition root, the env-var inventory, and the build-time validation sentinels.../../../tests/e2e/security/credential-rotation/chainsaw-test.yaml— Chainsaw e2e suite covering Issue + Rotate + ESO sync.