Skip to content

internal/platform/secretstore

internal/platform/secretstore is the sole sanctioned wrapper around OpenBao (Vault-API-compatible) for plexsphere. Direct imports of github.com/openbao/openbao/api from any bounded context are rejected by the no-direct-persistence-from-contexts depguard rule in .golangci.yml.

This document is the authoritative reference for:

See also:

Three Auth modes

The secretstore.Auth interface is sealed: its methods are unexported by design so only this package can provide a concrete implementation. A binary cannot be linked against a third-party login method that would bypass the workload-identity requirement.

ModeTypeProduction?Required fieldsDefault mount
AppRoleAuthAppRoleYesRoleID, SecretIDapprole (override via MountPath)
KubernetesAuthKubernetesYesRolekubernetes (override via MountPath)
Dev TokenAuthDevTokenNo — build-tag onlyToken

AuthAppRole

go
cfg := secretstore.Config{
    Address: "https://openbao.internal:8200",
    Auth: secretstore.AuthAppRole{
        RoleID:   os.Getenv("OPENBAO_APPROLE_ROLE_ID"),
        SecretID: os.Getenv("OPENBAO_APPROLE_SECRET_ID"),
    },
}

RoleID and SecretID are both required; missing either returns an error tagged (REQ-004, PX-0003). SecretID is treated as sensitive and is never echoed in errors or logs.

AuthKubernetes

go
cfg := secretstore.Config{
    Address: "https://openbao.internal:8200",
    Auth: secretstore.AuthKubernetes{
        Role: "plexsphere-core",
        // Defaults to the projected SA token at
        // /var/run/secrets/kubernetes.io/serviceaccount/token
    },
}

The default projected service-account token path matches the Kubernetes convention; override only for test rigs.

Dev-token build-tag invariant

AuthDevToken exists only behind the //go:build secretstore_dev build tag. This is an invariant, not a preference:

  • internal/platform/secretstore/auth_devtoken.go is gated on //go:build secretstore_dev. When compiled in, init() emits a warning-level slog entry so operators cannot miss that the build contains the static-token code path.
  • internal/platform/secretstore/auth_devtoken_stub.go is gated on //go:build !secretstore_dev and deliberately does not define AuthDevToken. Referring to AuthDevToken outside the dev build is therefore a compile-time error, enforced by construction.

A production binary compiled without the secretstore_dev tag cannot contain the static-token code path, even if a future refactor accidentally referenced it.

Client Config

FieldTypeDefaultPurpose
Addressstring— (REQUIRED)OpenBao cluster URL. Missing → error tagged (REQ-007, PX-0003).
AuthAuth— (REQUIRED)Authentication strategy. Missing → error tagged (REQ-004, PX-0003).
Namespacestring""Optional OpenBao namespace; sets the X-Vault-Namespace header.
TLSInsecureSkipVerifyboolfalseLocal-dev-only TLS bypass. Production deployments MUST leave this false.

Constructing a client

NewClient(ctx, cfg, logger) (*Client, error) validates the config, performs the initial login, and starts the renewal goroutine. Callers MUST invoke client.Close() at shutdown to stop the renewal loop cleanly.

go
client, err := secretstore.NewClient(ctx, cfg, logger)
if err != nil { /* ... */ }
defer client.Close()

Error strings never embed SecretID, AppRole credentials, tokens, or any other secret material.

KV v2 CAS semantics

(*Client).KVGet(ctx, mount, path) and (*Client).KVPut(ctx, mount, path, data, cas) are the sanctioned accessors for the KV v2 engine.

CallReturnSemantics
KVGet*KVSecret, errorReads the latest version. A missing secret surfaces the underlying API error so callers can distinguish 404 from transport failures.
KVPut(..., cas: nil)*KVSecret, errorUnconditional write.
KVPut(..., cas: &n)*KVSecret, errorCheck-and-set gated on version n. On mismatch, returns a descriptive error tagged cas mismatch ... (REQ-004, PX-0003).

CAS-mismatch handling

A CAS mismatch is reported without internal retry. Retrying a CAS write without rereading would lose data — callers must KVGet to refresh the expected version before attempting KVPut again. The mismatch detector matches either "cas mismatch" or "check-and-set parameter did not match" in the underlying error (case-insensitive).

Package-level KVSecret type

The KVSecret value returned by KVGet / KVPut is a package-level struct intentionally narrower than the underlying *api.KVSecret:

FieldTypeSource
Datamap[string]anyThe key/value payload of the secret version.
VersionintKV v2 version number.
Metadatamap[string]anyRaw KV v2 custom metadata; may be nil.

Bounded contexts depend on KVSecret, never on *api.KVSecret directly, so the OpenBao SDK stays contained inside this package .

Renewal goroutine contract

NewClient starts a background goroutine (runRenewalLoop) that:

  1. Refreshes the vault token at approximately 2/3 of its lease duration (renewInterval(leaseDuration)).
  2. If the lease is renewable, calls RenewSelf and resets the timer against the fresh LeaseDuration. On a renewable lease that successfully renews, the client continues without a fresh login.
  3. If renewal fails (renewal error, non-renewable lease, empty response), performs a fresh login via the configured Auth and resets the timer against the new lease.

The loop terminates when either the caller-supplied context cancels or Close is invoked. Close signals the loop via an internal closeCh and waits on renewDone for shutdown to complete .

Failure semantics

  • A re-login failure is logged at error level and the loop retries on the next tick. The client continues to serve KV calls using the currently-held token until it expires — a sealed or unreachable OpenBao is surfaced on /readyz via the probe below, not by crashing the client.
  • Error strings logged by the renewal loop never embed credential material.

Readiness probe

secretstore.ProbeFunc(client) returns a health.ProbeFunc that calls Sys().Health() on the wrapped vault client and flags the dependency unhealthy when the cluster is sealed or not initialized, both of which are fatal to KV operations.

SymbolValue
secretstore.ProbeName"secretstore"
Error suffix(REQ-006, PX-0003)
Failure classesnil client, ctx.Err(), HTTP error, empty response, sealed, not initialized
go
registry.Register(secretstore.ProbeName, secretstore.ProbeFunc(client))

Error strings never expose the OpenBao address, namespace, or any token material; /readyz bodies are served to unauthenticated probes.

Raft snapshot

(*Client).RaftSnapshot and (*Client).RaftSnapshotRestore wrap the integrated-raft snapshot surface of an OpenBao cluster. A snapshot is a gzipped tar that captures a point-in-time copy of the entire keyspace plus the raft state, suitable for disaster-recovery backups. Restore replays such a snapshot back into a target cluster.

MethodReturnSemantics
RaftSnapshot(ctx, w io.Writer)errorStreams a snapshot of the cluster into w. On a dev/inmem backend it fails with ErrRaftUnsupported; any other failure is wrapped with %w so the cause stays reachable via errors.As.
RaftSnapshotRestore(ctx, r io.Reader, force bool)errorRestores a previously captured snapshot from r. With force == true the restore bypasses the cluster-state safety checks (the force endpoint). Errors are wrapped exactly as in RaftSnapshot.

ErrRaftUnsupported classification

Both methods return the package sentinel ErrRaftUnsupported (secretstore: raft storage not in use) when the backing OpenBao node is not running the integrated raft storage backend. Callers use errors.Is(err, secretstore.ErrRaftUnsupported) to tell "this backend can never snapshot" apart from a transient transport or network failure that may succeed on retry.

The classifier keys on the stable server phrase "raft storage is not in use", not on an HTTP status code: the dev-mode status code varies across OpenBao versions while the message text is stable. When the sentinel is returned, the underlying *api.ResponseError is still reachable via errors.As because the error is wrapped with both the sentinel and the cause.

Dev-mode limitation

The dev-stack / StartOpenBao dev fixture runs in-memory storage and therefore cannot produce or consume raft snapshots — every snapshot or restore call against it returns ErrRaftUnsupported. Only a node booted with the integrated raft storage backend supports snapshots; use the StartOpenBaoRaft integration fixture (or a production raft deployment) when exercising the snapshot path.

Seal-key custody implications

A snapshot-force restore overwrites the target node's data, but the restored cluster continues to use the source cluster's keyring. The practical consequence for operators:

  • The restored node must be unsealed with the original (snapshot-time) unseal/seal key, not the target node's current key.
  • After a cross-cluster force restore, plan the unseal step around the source cluster's seal-key custody. A restore that succeeds at the API level still leaves the node sealed until it is unsealed with the snapshot-time key material.

Snapshot to a file

go
f, err := os.Create("/var/backups/openbao-snapshot.tar.gz")
if err != nil { /* ... */ }
defer f.Close()

if err := client.RaftSnapshot(ctx, f); err != nil {
    if errors.Is(err, secretstore.ErrRaftUnsupported) {
        // The backend is dev/inmem storage and can never snapshot;
        // not a transient failure, so do not retry.
        return fmt.Errorf("raft snapshots require the integrated raft backend: %w", err)
    }
    // Transport/network or other server error — may be retryable.
    return err
}

Cross-references