ADR-031 — Unified Secret Provider (built-in floor, Vault/OpenBao, master-key-at-setup)

Context

Sprint 027/028 (ADR-007, ADR-008) delivered a working master-KEK abstraction (IKekSource: env / file / vault / hsm) and regional AEAD routing (ICredentialAlgorithmSelector + ICredentialAeadPrimitive via the plugin router). However, a read-only audit of every secret-handling surface found the following gaps against the locked secret-provider charter:

  1. No unified consumer-facing abstraction. Three credential stores (Platform connector credentials, Integrations inline credentials, ReleaseOrchestrator deployment bundles) each hard-wire IKekSource in their own Program.cs. Registry secret resolution forks further into IAuthRefResolver (Integrations/SbomService) and a static SecretRefResolver (ReleaseOrchestrator) — at least six divergent pathways with inconsistent fail-closed semantics.
  2. No master-key provisioning at setup. Crypto:Kek:Source is bound but ReadSelectedKekSourceKind() is never called; all services hard-register EnvKekSource. The setup wizard has no Master Key step. Boot succeeds even with no key configured; the failure surfaces lazily on first seal/open.
  3. Symmetric only. Every source resolves to byte[]. There is no asymmetric (RSA/EC PEM/PKCS#8) wrap/unwrap path, despite the charter requiring “SUPPORT BOTH symmetric (DEFAULT) AND asymmetric”.
  4. No bundled broker. External HashiCorp Vault is the only non-env backend. The charter requires an OPTIONAL bundled broker.
  5. Weak dev defaults persist in devops/compose/* (e.g. stellaops-dev-root-token-2026, stellaops-dev-bootstrap-key) which let a service boot without operator provisioning — a fail-closed violation.

Decision

D1 — The built-in store is the mandatory default floor

The default secret backend is builtin: secret VALUES live envelope-encrypted in each service’s own PostgreSQL via the existing ICredentialStore, with each per-row DEK sealed by a master KEK resolved from IKekSource. No operator action beyond provisioning a master key (D3) is required to run Stella Ops. An operator-run external Vault (or operator-run OpenBao) is a strictly optional upgrade — the in-repo OpenBao profile is test-only (D5). There is no cloud-managed KMS default, stub, or DI registration (AWS/Azure/GCP KMS are out of scope per the on-prem/air-gap posture).

D2 — A single ISecretProvider abstraction fronts three backends

SUPERSEDED IN PART by ADR-032. The abstraction (ISecretProvider fronting builtin/vault/openbao) stands. The one-backend-bound-globally-at-DI selection described below (Crypto:SecretProvider:Backend) is replaced by URL-routed multi-provider selection: RoutingSecretProvider holds all configured providers (Crypto:SecretProviders[]) and dispatches per reference URL. The single-backend form remains valid as the degenerate one-provider case.

A new abstraction ISecretProvider (see the locked interface spec at docs/implplan/specs/secret-provider-interface.md) fronts exactly three backend kinds:

Selection is via Crypto:SecretProvider:Backend{builtin, vault, openbao} (default builtin). Registry integrations (Integrations, SbomService) AND the deployment process (ReleaseOrchestrator) both consume this single abstraction. The existing IKekSource, ICredentialStore, and ICredentialAlgorithmSelector contracts are NOT replaced — ISecretProvider composes them; the builtin adapter is a thin façade over the Sprint 027/028 machinery, preserving byte-for-byte on-disk format and read-time per-row algorithm dispatch.

D3 — Master key is provisioned at the Compliance Setup step, fail-closed

A new required setup-wizard step (SetupStepId.MasterKey, ordered immediately before Crypto) provisions the master key:

D4 — Both key formats route through ADR-008 regional crypto

D5 — OpenBao is TEST-ONLY (dev/CI convenience); Vault stays the flagship external backend

AMENDED 2026-07-04 (operator decision — Option A of the OpenBao positioning memo, applied by Sprint 20260703_005 D4). The original D5 positioned OpenBao as “the OPTIONAL bundled broker”; the operator ruled it test-only. The builtin default (D1) is unaffected.

The in-repo OpenBao profile (devops/compose/docker-compose.openbao.yml) exists only for local development and automated tests. It is opt-in (compose profile openbao, never started by the default up), is not pinned or shipped in any release bundle, and is not a supported production backend. The supported backends are the builtin floor (D1, the default) and an operator-run external HashiCorp Vault — the flagship external backend — or an operator-run OpenBao the operator stands up and operates themselves. Both external kinds reuse the existing HttpVaultKvReader (OpenBao is API-compatible with Vault KV v2 / login), so no new NuGet dependency is added either way.

Why the test container is OpenBao and not Vault (rationale retained): HashiCorp Vault is BUSL-1.1; even for a test fixture, depending on a second BUSL-1.1 product is avoided. OpenBao (Linux Foundation fork) is MPL-2.0. Because the OpenBao image is not distributed as part of the product — it is pulled at dev/test time only — MPL-2.0 distribution obligations do not attach: OpenBao is classified as dev/test tooling in NOTICE.md and docs/legal/THIRD-PARTY-DEPENDENCIES.md, and no license text is redistributed under third-party-licenses/ (the former courtesy copy was removed with this amendment).

D6 — Relationship to existing ADRs

Rejected alternatives

Consequences