Skip to content

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.

MethodInputOutputMandatory failure modes
Issue(ctx, IssueInput)IssueInputIssueResultErrDomainUnresolved, ErrMaterialiserUnavailable, ErrIssueAtomicityViolated, ErrCredentialAlreadyExists, ErrPathAlreadyMaterialised
Rotate(ctx, RotateInput)RotateInputRotateResultErrCredentialNotFound, ErrCredentialRevoked, ErrBrokerCASConflict, ErrKVStoreCASConflict, ErrDomainUnresolved, ErrMaterialiserUnavailable
Revoke(ctx, RevokeInput)RevokeInputerrorErrCredentialNotFound, ErrCredentialRevoked, ErrDomainUnresolved
Lookup(ctx, CredentialID)CredentialIDCredentialRowErrCredentialNotFound

Value objects

CredentialID

Binary form of the credential UUIDv7 used as the broker-row primary key. Defined as type CredentialID [16]byte.

Field / methodTypeNotes
underlying[16]byteUUIDv7 raw bytes. The zero value is treated as "not yet assigned" and is rejected by every aggregate invariant that requires a concrete reference.
String() stringmethodReturns 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() boolmethodReports whether the CredentialID has not been assigned.
ParseCredentialID(s string)functionParses 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.

FieldGo typePersistenceNotes
Payload[]bytedata.payload in the KV-v2 row.The opaque bytes the caller wants stored.
TTLtime.Durationexpires_at = now() + TTL on the broker row.The broker's expiry budget; drives the Sweeper's eligibility predicate.
KeyValuesmap[string]stringdata.* in the KV-v2 row.The flat data map written alongside Payload.

IssueInput

Value object Custodian.Issue accepts.

FieldGo typeRequiredNotes
ProjectID[16]byteyesThe residency pivot. Drives kv_path derivation and the Domain resolution. Zero value is rejected before any persistence call.
MaterialMaterialyesThe bytes the broker writes to KV-v2 plus the TTL from which expires_at is derived.

IssueResult

Value object Custodian.Issue returns on success.

FieldGo typeSource
CredentialIDCredentialIDUUIDv7 minted by the application at issuance.
KVMountstringMaterialiser.DerivePath (verbatim from Config.KVMount).
KVPathstringMaterialiser.DerivePath (projects/<projectID>/credentials/<credentialID>).
Versionint32Broker-row version. Always 1 for a fresh issuance.
KVVersionint32KV-v2 store's own version on the freshly-written secret. Always 1 for a fresh issuance.
ExpiresAttime.TimeUTC; now() + Material.TTL.

RotateInput

Value object Custodian.Rotate accepts.

FieldGo typeRequiredNotes
CredentialIDCredentialIDyesThe aggregate to rotate.
ExpectedVersionint32yesThe broker-row version the caller observed via Lookup. A mismatch surfaces ErrBrokerCASConflict so concurrent rotates fail closed.
MaterialMaterialyesThe new bytes; expires_at is recomputed from Material.TTL.

RotateResult

Value object Custodian.Rotate returns on success.

FieldGo typeSource
Versionint32New broker-row version (ExpectedVersion + 1).
KVVersionint32New KV-v2 version reported by Materialiser.Put.
ExpiresAttime.TimeUTC; now() + Material.TTL.

The CredentialID is implicit in the input and therefore not echoed on the result.

RevokeInput

Value object Custodian.Revoke accepts.

FieldGo typeRequiredNotes
CredentialIDCredentialIDyesThe aggregate to revoke.
ReasonstringyesOperator-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.

FieldGo typeColumnNotes
CredentialIDCredentialIDidUUIDv7 primary key.
ProjectID[16]byteproject_idUUIDv7. ON DELETE CASCADE — deleting the Project deletes its credentials.
DomainID[16]bytedomain_idUUIDv7. ON DELETE RESTRICT — a Domain with credentials cannot be deleted.
KVMountstringkv_mountThe broker's configured Config.KVMount at the time of issuance.
KVPathstringkv_pathThe deterministic projection — see path derivation. UNIQUE per (project_id, kv_path).
KVVersionint32kv_versionThe OpenBao KV-v2 store's own version, mirrored on the broker row so operators can correlate without round-tripping to OpenBao.
Versionint32versionBroker-row CAS counter. Incremented by every application-side mutation.
ExpiresAttime.Timeexpires_atWall-clock instant the credential ceases to be valid.
RevokedAt*time.Timerevoked_atNon-nil once the operator has issued a soft-delete. SQL CHECK gates revoked_at / expired_at exclusivity.
ExpiredAt*time.Timeexpired_atNon-nil once the Sweeper has observed the credential's expiry.
CreatedAttime.Timecreated_atUTC.
UpdatedAttime.Timeupdated_atUTC; 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/.

MethodSignatureReturnsNotes
PutPut(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.
DeleteDelete(ctx, mount, path string) errornil on success.Soft-deletes the secret at /<mount>/data/<path>. Non-CAS failure → ErrMaterialiserUnavailable.
DerivePathDerivePath(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.

MethodReturnsMandatory failure modes
Create(ctx, CredentialRow)errorErrCredentialAlreadyExists, 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)errorErrCredentialNotFound, ErrCredentialRevoked
AppendOutboxEvent(ctx, id, eventType, payload)errorunique-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)errorpropagates 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.

SurfaceSignatureNotes
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)errorReturns errProbePending until the first Run completes; nil thereafter. Does NOT re-trigger Run.
WithPageSize(n int32)OptionOverrides the per-tick ListExpired page width. Non-positive values are ignored (degrade to default).
WithLogger(logger *slog.Logger)OptionOverrides the structured logger; nil ignored.
WithRegisterer(reg prometheus.Registerer)OptionWires Prometheus counters; nil keeps zero-value mode.
ProbeNameconst 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.

FieldGo typeSourceEffect
SweepIntervaltime.DurationPLEXSPHERE_CREDENTIALS_SWEEP_INTERVALSteady-state period between Sweeper.Run invocations. Zero falls back to defaultCredentialsSweepInterval = 30s.
KVMountstringPLEXSPHERE_CREDENTIALS_KV_MOUNTThe 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.

SentinelSource layerTrigger
ErrCredentialNotFoundRepository / CustodianFindByID / Lookup for an absent CredentialID.
ErrCredentialAlreadyExistsRepository (PRIMARY KEY collision)A second Issue for the same credential id with mismatched fields.
ErrCredentialRevokedCustodian / RepositoryRotate or Revoke or MarkExpired on a row whose revoked_at is non-null.
ErrBrokerCASConflictRepository.RotateCASBroker-row version advanced past caller's ExpectedVersion.
ErrKVStoreCASConflictMaterialiser.PutKV-v2 store's kv_version advanced past broker's expected value. Distinct from ErrBrokerCASConflict — different remediation.
ErrDomainUnresolvedCustodiancredentials.DomainResolver.Resolve returns an error or a zero domainID during a broker operation. Fail-closed.
ErrAuditUnavailableCustodianAuditSink.Record fails after the broker has committed. The broker decision is durable but the audit chain has gapped. Counter-only — operators alert on AuditSinkFailuresTotal().
ErrMaterialiserUnavailableMaterialiser.Put / DeleteNon-CAS KV-v2 failure (network, transport timeout, OpenBao unsealed-but-blocked).
ErrIssueAtomicityViolatedCustodianCompensating Materialiser.Delete fired after a Postgres rollback — KV-v2 row created but Postgres failed to commit.
ErrInvalidPathInputRepositorykv_path violates the path-format invariant before SQL UNIQUE runs.
ErrPathAlreadyMaterialisedRepository (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 fieldGo typeNotes
event_id[16]byteUUIDv7 minted at emission.
occurred_attime.TimeUTC.
credential_id[16]byteUUIDv7.
project_id[16]byteUUIDv7. The residency pivot.
kv_mountstringVerbatim from Config.KVMount.
kv_pathstringThe deterministic projection (see path derivation).
versionint32Broker-row version. Always 1 for issuance.
kv_versionint32KV-v2 version. Always 1 for issuance.
expires_attime.TimeUTC.

CredentialRotated — event_type = "credentials.CredentialRotated"

JSON fieldGo typeNotes
event_id[16]byteUUIDv7 minted at emission.
occurred_attime.TimeUTC.
credential_id[16]byteUUIDv7.
versionint32New broker-row version.
kv_versionint32New KV-v2 version.
expires_attime.TimeUTC; 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 fieldGo typeNotes
event_id[16]byteUUIDv7 minted at emission.
occurred_attime.TimeUTC.
credential_id[16]byteUUIDv7.
reasonstringOperator-supplied audit string from RevokeInput.Reason.

CredentialExpired — event_type = "credentials.CredentialExpired"

JSON fieldGo typeNotes
event_id[16]byteUUIDv7 minted at emission.
occurred_attime.TimeUTC. The expiry timestamp by definition; the credential's expires_at is <= occurred_at.
credential_id[16]byteUUIDv7.

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.

MetricTypeIncrements when
credentials_sweeper_invocations_totalcounterSweeper.Run is entered (per call).
credentials_sweeper_expirations_totalcounterA 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