Runbook — Multi-provider secret registry (builtin + openbao + vault)

Audience: Platform / Integrations / ReleaseOrchestrator operators. Applies to: the three services that wire AddRoutingSecretProvider and resolve secret references by URL: StellaOps.Platform.WebService, StellaOps.Integrations.WebService, StellaOps.ReleaseOrchestrator.WebApi. ADR: ADR-032 — URL-based per-resource secret references + multi-provider routing (supersedes ADR-031 §D2). Spec: Secret reference URL scheme. Related: kek-source-vault.md (the master KEK is a separate concern from the secret provider registry).

What this is

ADR-031 bound exactly ONE secret backend per process via Crypto:SecretProvider:Backend. ADR-032 replaces that with a registry of provider instances so a single install can mix builtin + openbao + an external vault simultaneously, and each stored secret reference is a URL that names the provider instance + path. RoutingSecretProvider dispatches every resolve/seal by parsing that URL.

OpenBao positioning (decided 2026-07-04 — ADR-031 §D5 as amended, Option A of the positioning memo): the in-repo OpenBao compose profile is a test-only dev/CI convenience — never shipped or supported in a release. The supported production backends are the built-in floor (the default) and an external, operator-run HashiCorp Vault (the flagship external backend) — or an operator-run OpenBao, since the openbao provider kind reuses the same Vault HTTP client.

The single-backend mode is the degenerate one-entry case: with no Crypto:SecretProviders[] declared, the legacy Crypto:SecretProvider:Backend is honoured as a one-entry back-compat shim — a plain docker compose up (without the override below) behaves exactly as before.

Config shape (ADR-032 §D2)

"Crypto": {
  "SecretProviders": [
    { "id": "builtin", "kind": "builtin" },
    { "id": "bao",     "kind": "openbao", "address": "http://openbao:8200",
      "auth": { "mode": "token", "token": "<op-provided>" } },
    { "id": "vault-prod", "kind": "vault", "address": "https://vault.example:8200",
      "auth": { "mode": "appRole", "roleId": "...", "secretId": "..." } }
  ],
  "SecretProvider": { "DefaultSealTarget": "builtin" }
}

Compose wiring (builtin + test-only openbao — dev/CI)

The registry is expressed in compose via devops/compose/docker-compose.secret-providers.override.yml, which injects the Crypto__SecretProviders__<index>__<field> env vars into all three routing services. The OpenBao instance comes from the test-only Sprint 007 docker-compose.openbao.yml profile (dev/CI convenience — never part of a release; in production, point the openbao/vault entries at an operator-run external broker instead).

# Private .env MUST provide OPENBAO_TOKEN (no repository default; fail-closed):
#   OPENBAO_TOKEN=<operator OpenBao app/root token>
#   OPENBAO_ADDR=http://openbao.stella-ops.local:8200      # default
#   CRYPTO_SECRETPROVIDER_DEFAULTSEALTARGET=builtin        # default

docker compose \
  -f docker-compose.stella-infra.yml \
  -f docker-compose.stella-services.yml \
  -f docker-compose.openbao.yml \
  -f docker-compose.secret-providers.override.yml \
  --profile openbao up -d

Default registry injected: [0] builtin, [1] openbao (id=bao), with DefaultSealTarget=builtin. An external vault-prod entry [2] is included as a commented, opt-in example in the override — uncomment it and supply VAULT_PROD_ADDR + VAULT_PROD_TOKEN (or AppRole) in your .env.

To run single-backend (legacy) mode, simply omit docker-compose.secret-providers.override.yml and leave CRYPTO_SECRETPROVIDER_BACKEND=builtin (or =openbao) — the one-entry shim handles it.

Secret reference URL grammar (quickstart)

Full grammar: secret-reference-url-scheme.md.

<scheme>://[<provider-id>/]<path>[#<version>]
scheme ∈ builtin | vault | openbao | authref (vault alias) | file | base64 | plain

provider-id is optional: omitted ⇒ the default instance of that scheme’s kind; present-but-unknown ⇒ fail-closed (rejected at routing — never a silent fall-through). Examples against the default registry above:

URLRoutes to
builtin://integration:7b3f…d2default builtin, owner-key, current generation
builtin://integration:7b3f…d2#2builtin, generation 2
openbao://bao/kv/notify/smtp/passwordinstance bao, OpenBao KV v2
openbao://kv/notify/smtp/passworddefault openbao instance
vault://vault-prod/secret/app/db/password#5instance vault-prod, KV version 5
vault://secret/app/db/tokendefault vault instance
authref://vault/secret/app/db#tokenlegacy alias (field via #)
file:///run/secrets/master.keyinline file (air-gap material)

The URL is non-secret — a pointer, never the value — so it is safe to store, display, log, and audit.

DefaultSealTarget semantics

When an operator seals a NEW secret without naming an explicit scheme://id/ target, RoutingSecretProvider seals it to the provider id resolved from Crypto:SecretProvider:DefaultSealTarget:

A bare-id DefaultSealTarget that names no configured provider is rejected at config bind (fail-closed) — see SecretProvidersOptions.Validate.

Fail-closed posture (ADR-031 §D3, ADR-032 §D3)

Master key at setup (the seal/open root)

The provider registry (above) decides where a secret lives. The master key (KEK) decides what seals the builtin floor and what wraps externally sourced material — it is a separate concern (ADR-031 §D3, §D6; cross-ref kek-rotation.md, kek-source-vault.md, kek-source-hsm.md).

The setup wizard exposes a Master Key step (SetupStepId.MasterKey) served by GET /api/v1/setup/definitions/steps. The step gates on a probe — a round-trip seal/open against the resolved KEK — so boot fails closed (the legacy “key absent until first seal/open” lazy failure is removed):

# Probe-only seal/open round-trip against the resolved master key (no key change):
POST /api/v1/admin/setup/crypto/master-key/probe     # crypto:kek:rotate + tenant
#   → { "data": { "ok": true, "message": "Master key seal/open round-trip succeeded." } }

# Provision a NEW master key at first setup (symmetric default, or asymmetric RSA/EC):
POST /api/v1/admin/setup/crypto/master-key/generate  # crypto:kek:rotate
POST /api/v1/admin/setup/crypto/master-key/import    # operator-provided material

WARNING — never rotate the live master key casually. Generating, importing, or rotating the active installation KEK re-roots seal/open and will orphan every existing sealed secret (connector creds, deployment bundles) unless a full re-seal walk is run (see kek-rotation.md). On a running, data-bearing install, use the probe to verify the key is healthy; only generate/import on a fresh install or under a planned rotation.

Crypto-control surface (Console)

The Console admin crypto-control page renders the live secret-provider state for operators without shell access:

CLI surface

stella crypto secret-provider (read-only inspection; crypto:read) and stella crypto master-key (provisioning; crypto:kek:rotate). Full reference: CLI crypto command guide.

stella crypto secret-provider list                         # id / kind / reachable
stella crypto secret-provider status                       # backend + KEK fingerprint (non-secret)
stella crypto secret-provider validate                     # master key present + reachable
stella crypto secret-provider paths <id> [--prefix P]      # browse path NAMES only
stella crypto master-key probe                             # seal/open round-trip (setup gate)

secret-provider verbs never print secret bytes (paths returns names only; JSON output has no value field). paths <unknown-id> fails closed with HTTP 404 listing the configured ids (exit 14). master-key generate/import write/forward key material out-of-band (asymmetric private key → owner-only file; import material read from a file, never a CLI arg) and are gated on crypto:kek:rotate.

Verify

# The /secret-providers admin endpoint enumerates the configured registry +
# per-provider describe state (ADR-032 §D5):
GET /api/v1/admin/crypto/secret-providers          # crypto:read + tenant

# docker compose config sanity (registry injected into all three services):
docker compose -f docker-compose.stella-infra.yml \
  -f docker-compose.stella-services.yml \
  -f docker-compose.openbao.yml \
  -f docker-compose.secret-providers.override.yml \
  --profile openbao config | grep Crypto__SecretProviders__

Proven live (2026-06-22)

This registry, the master-key probe gate, the crypto-control surface, and the CLI verbs were proven end-to-end against a live, data-bearing stack (builtin + 2× HashiCorp Vault + OpenBao, all reachable) without touching the live master key. Full evidence (API + UI screenshots + CLI transcripts): docs/qa/feature-checks/runs/2026-06-22-secret-provider-live/.

In that proof, OpenBao was a TEST-ONLY backend (ephemeral container via the opt-in docker-compose.openbao.yml profile), not a shipped default. That is now the decided, permanent positioning (2026-07-04 — ADR-031 §D5 as amended): the product default stays builtin; the supported external backend is an operator-run Vault (or operator-run OpenBao); the in-repo OpenBao profile is dev/CI test tooling only.