Secrets provider switch (SecretsProvider)
Shipped 2026-08-26 (extended 2026-08-27), replacing the old implicit "blank
KeyVault:Uri means no-op" signal with an explicit config discriminator —
same convention as Caching:ListCache/Auth:RefreshTokenStore/
Sso:PendingLoginStore.
The switch
SecretConfigurationExtensions.AddEosSecrets(this IConfigurationBuilder, IConfiguration) (Infrastructure/Configuration/) is the one seam every
composition root calls — unchanged in shape
(builder.Configuration.AddEosSecrets(builder.Configuration), first
statement after WebApplication.CreateBuilder(args), before
AddApplication()/AddInfrastructure()). It reads a top-level
SecretsProvider key:
- Unset / blank /
"env"(case-insensitive) — a documented no-op. .NET's default provider chain already includesAddEnvironmentVariables(), so nothing new happens here. "keyvault"— the existingAddAzureKeyVault+ManagedIdentityCredential (SystemAssigned)mechanism, now gated behind the explicit key instead of being inferred fromKeyVault:Urialone."vault"— HashiCorp Vault viaVaultSharp(shipped 2026-08-27, the deliberate second phase this switch was built to accept). AppRole auth only (Vault:RoleId/Vault:SecretId) — no static-token auth path exists or is planned.- Anything else — fails fast, listing the valid values.
:::tip ManagedIdentityCredential(SystemAssigned), never DefaultAzureCredential
DefaultAzureCredential probes multiple credential sources in order before it
succeeds, adding real cold-start latency even when it eventually reaches
managed identity. ManagedIdentityCredential(SystemAssigned) goes straight to
the IMDS endpoint, skipping that probe chain. Follow this same preference for
any future Azure credential acquisition in this codebase.
:::
Fail-fast, not silent
SecretsProvider=keyvault with a blank KeyVault:Uri throws an
InvalidOperationException naming the missing key before app.Run() — the
same "worse to start wrong than not start" posture EosDatabaseMigrator
already uses. An unrecognized SecretsProvider value throws too, listing
valid values.
HashiCorp Vault (SecretsProvider=vault)
For Model C clients with enterprise IT already running Vault. Requires
Vault:RoleId/Vault:SecretId (AppRole), Vault:Address, and
Vault:SecretPath (the KV v2 secret path); Vault:MountPoint defaults to
"secret".
:::caution Single blocking read at startup, not lazy
Unlike AddAzureKeyVault's lazy provider, this is a single blocking KV v2
read at startup (VaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(...) .GetAwaiter().GetResult()) — VaultSharp is async-only and
IConfigurationBuilder.Build() is synchronous. Vault secret data keys must
already be full IConfiguration keys (e.g. a secret literally named
"ConnectionStrings:EosDb") — there's no --/__-style escaping to apply,
since Vault keys can contain : directly. A read failure (bad address,
invalid AppRole credentials, missing path) is caught and wrapped in a clear
InvalidOperationException naming the provider/path.
:::
Not verified against a real Vault server — no HashiCorp Vault instance exists in this environment or for any real Model C client yet.
Backward compatible, no consumer changes
Every current caller of IConfiguration (the Jwt:SigningKey read,
DependencyInjection.cs's GetConnectionString("EosDb"), the OpenTelemetry
Azure Monitor exporter's connection-string read) stays completely unaware of
which provider supplied a value — only the three Program.cs call sites
changed, from passing a single KeyVault:Uri string to passing the whole
IConfiguration. Every existing appsettings.json in this repo has no
SecretsProvider key today, so every deployment that relied on a blank/
non-blank KeyVault:Uri no-op keeps working unmodified.
Reference files
SecretConfigurationExtensions.cs, docs-internal/keyvault-provisioning-runbook.md,
SecretConfigurationExtensionsTests.cs, Api/Program.cs/Workers/Program.cs/
Api.External/Program.cs (the AddEosSecrets call site).