ADR-032 — URL-based per-resource secret references + multi-provider routing
- Status: Accepted (design-only; binding for the SPRINT_20260622_008…012 implementation stream)
- Date: 2026-06-22
- Owners: Lead architect / Cryptography + Platform + Integrations + ReleaseOrchestrator + CLI + Web
- Supersedes / Amends: Supersedes ADR-031 §D2 (“a single backend is bound globally at DI via
Crypto:SecretProvider:Backend”). Everything else in ADR-031 (built-in floor D1, master-key-at-setup D3, regional-crypto routing D4, test-only OpenBao D5 as amended 2026-07-04, ADR-007/008/012 relationships D6) stands unchanged. - Relates to: ADR-007, ADR-008, ADR-012, ADR-031
Context
ADR-031 delivered ISecretProvider (builtin / vault / openbao adapters), the master-key-at-setup ceremony, and durable builtin persistence. A final audit of the implemented stream found one critical gap that this ADR closes:
Per-resource backend selection is captured in the UI but never routed. The setup wizard, the registry-credential selector, and the deployment selector all let an operator choose which backend a secret lives in (builtin / vault / openbao), but
ISecretProvideris bound to exactly one provider for the whole process at DI time (ADR-031 §D2 +Crypto:SecretProvider:Backend). A reference resolved at runtime always hits that one global provider regardless of the per-resource choice. So an install cannot mix vault + openbao + builtin simultaneously, and the per-resource UI choice is silently inert.
The operator decision is: a secret is referenced by a URL that names the provider (kind + optional instance id) and the path within it. The URL is the canonical, non-secret hint shown/stored/logged everywhere a secret is configured. The UI gives a Select… affordance (pick provider, then path) plus type-ahead autocomplete that searches the provider for real paths.
Decision
D1 — A secret reference is a URL that names provider + path
The canonical reference grammar (full spec: docs/implplan/specs/secret-reference-url-scheme.md):
<scheme>://[<provider-id>/]<path>[#<version>]
<scheme>∈builtin | vault | openbao(plusauthref://retained as a back-compat alias forvault://, and the inline dev schemesfile:// | base64: | plain:).<provider-id>(optional) selects a specific configured instance when an install has more than one provider of that kind. Omitted ⇒ the default instance for that scheme.<path>— builtin ⇒ the tenant owner-key; vault/openbao ⇒<mount>/<path>[/<field>].#<version>(optional) pins a KV version (vault/openbao) or a builtin credential row generation.
The URL is non-secret — a pointer, never the value — so it is safe to store, display, log, and put in audit records. This is the “hints everywhere” property.
D2 — Multi-provider config replaces the single global backend
ADR-031 §D2’s single Crypto:SecretProvider:Backend is superseded by a provider registry plus a default seal target:
"Crypto": {
"SecretProviders": [
{ "id": "builtin", "kind": "builtin" },
{ "id": "vault-prod","kind": "vault", "address": "https://vault.example:8200", "auth": { /* … */ } },
{ "id": "bao", "kind": "openbao", "address": "http://openbao:8200", "auth": { /* … */ } }
],
"SecretProvider": {
"DefaultSealTarget": "builtin" // where NEW secrets seal when the URL omits a target
}
}
The single-backend mode becomes the degenerate one-provider case: a config with exactly one entry behaves identically to today, and the legacy Crypto:SecretProvider:Backend=<kind> is honoured as a one-entry shorthand (back-compat shim) that synthesises a single default-id provider of that kind.
D3 — RoutingSecretProvider dispatches by URL
A new RoutingSecretProvider : ISecretProvider holds all configured providers (keyed by (kind, id)) and dispatches every operation by parsing the reference URL:
ResolveAsync(reference, …)/RevokeAsync→ route by the reference’sScheme+ProviderIdto the matching inner provider.SealAsync(request, …)→ route by the request’s seal target (an explicit<scheme>://<provider-id>/target, elseDefaultSealTarget).ListAsync(providerId, prefix, …)→ route to the named provider.DescribeAsync()→ describe the default seal target provider (back-compat with the single-descriptor/statusendpoint); a new per-provider describe is exposed via the/secret-providersendpoint (D5).
RoutingSecretProvider is registered as the active ISecretProvider; the concrete builtin/vault/openbao providers become inner instances it owns (constructed one-per-registry-entry). This is the concrete supersession of ADR-031 §D2: there is no longer a single inner provider bound globally.
Fail-closed: an unresolvable reference (unknown scheme, unknown provider-id, absent provider for a required seal) is rejected loudly (throw) — never a silent downgrade to another provider. A recognised-but-absent value still returns null (the ADR-031 contract is preserved).
Deterministic provider-id resolution (no silent mis-routing). The first URL segment is a provider-id IFF it matches a configured Crypto:SecretProviders[].id. Whether an unmatched first segment is folded into the path or rejected depends on how many instances of the scheme’s kind are configured:
- Exactly one instance of the kind ⇒ the default is unambiguous. An omitted provider-id, or a first segment that is not a configured id, resolves to that single instance (the segment is the bare mount/path). The bare-mount form (
vault://secret/app/db) keeps working. - Two or more instances of the kind ⇒ the default is ambiguous, so an explicit, matching provider-id is required. Both an omitted provider-id and a first segment that matches no configured id (e.g. a typo or an unconfigured
vault://prod-typo/kv/x) fail closed (throw) — the router never silently picks an arbitrary instance and never folds the unmatched segment into the path to mis-resolve it against the wrong backend.
The SealAsync path applies the same rule to a schemed seal target. Bind on SecretReference stays config-free (it knows only the id set); the instance-count-aware fail-closed decision lives in RoutingSecretProvider, which reads the parsed candidate first segment before binding. Full grammar + the single-vs-multiple table: secret-reference-url-scheme.md §1–§2.
D4 — Browse/search capability (ListAsync) powers autocomplete
A new ISecretProvider.ListAsync(providerId, pathPrefix, pageToken) returns path names only — never values:
- builtin ⇒
LIST crypto.secret_storerows bytenant_id+owner_keyprefix (reuses the existing tenant-scopedPostgresSecretCredentialStore). - vault / openbao ⇒ KV v2
LIST <mount>/metadata/<prefix>over the existing Vault HTTP transport (a newListAsynconIVaultKvReader; no new NuGet).
It is gated crypto:read + tenant, rate-limited, and paged. It powers the UI type-ahead. It MUST NOT read or return secret material.
D5 — Reference-existing vs seal-new-here
The reference input supports both modes:
- Reference-existing — the operator searches the chosen provider (D4) and picks a path that already holds a secret; the stored config is just the URL.
- Seal-new-here — the operator types a NEW path under a chosen provider and supplies the secret value once; the backend
SealAsyncwrites it to that provider+path and stores the resulting URL.
A new platform endpoint group /api/v1/admin/crypto/secret-providers enumerates configured providers + their describe state, and /api/v1/admin/crypto/secret-providers/{id}/paths?prefix=&pageToken= proxies ListAsync for autocomplete.
D6 — Relationship to existing ADRs
- ADR-031 — §D2 superseded (above); all other decisions stand. The builtin floor, master-key ceremony, test-only OpenBao profile (D5, as amended 2026-07-04 — dev/CI convenience, not a shipped/bundled broker), and regional routing are reused verbatim —
RoutingSecretProvidercomposes the same inner adapters. - ADR-007 / ADR-008 / ADR-012 — unaffected. Routing happens above the KEK/AEAD/tenant layer; each inner provider keeps its ADR-007/008 behaviour and the tenant scope is carried on every routed call.
Rejected alternatives
- Keep one global backend + a per-resource “preferred backend” hint. Rejected: the hint cannot be honoured without routing, which is exactly the audited gap.
- Encode the provider as a free-form string outside a URL. Rejected: a URL is the one canonical, parseable, displayable, loggable form; ad-hoc strings drift and cannot be type-ahead-searched.
- Resolve provider per call from a side table instead of from the URL. Rejected: the URL must be self-describing so it is portable across services and safe to show in the UI without a join.
- Return secret values from the browse API. Rejected outright: browse is
crypto:readpath-discovery only; values stay behindResolveAsync+ its stricter authz.
Consequences
- One install can mix builtin + vault + openbao (and multiple instances of a kind) simultaneously; the per-resource UI choice is finally real.
- Every secret-config surface shows a canonical, searchable URL hint.
- Risk: a routed reference to a removed provider fails closed at resolve time; mitigated by
/secret-providersdescribe surfacing unreachable instances and by validation at config bind. - Migration: existing
authref://and bare single-backend configs keep working (back-compat shim D2 + alias D1); no stored reference needs rewriting.
