diff --git a/Fuchs/Docs/Concepts/secret-management.md b/Fuchs/Docs/Concepts/secret-management.md new file mode 100644 index 0000000..6064242 --- /dev/null +++ b/Fuchs/Docs/Concepts/secret-management.md @@ -0,0 +1,75 @@ +--- +status: Active +lastUpdated: 2026-09-18 +applyTo: + - "Fuchs/Program.cs" + - "Fuchs/appsettings*.json" + - "OCORE/OCORE/Secrets/**" + - "OCORE_web/OCORE_web/Secrets/**" +relatedDecisions: + - "0031-shared-keyvault-dpapi-secret-management.md" +--- + +# Secret management (Key Vault + DPAPI) + +## Summary +Fuchs loads its secrets (connection strings, API keys, mailer tokens, ...) +from the shared Azure Key Vault `pcwkeys` at startup, with a DPAPI-encrypted +local cache as an offline fallback. The mechanism is shared, host-type-agnostic +infrastructure owned by the `OCORE`/`OCORE_web` submodules ([0031](../Decisions/0031-shared-keyvault-dpapi-secret-management.md)); +this doc covers how Fuchs specifically wires into it and where to make +changes. The full mechanism reference (naming/encoding rules, DI wiring, +cache format) is maintained independently in +`OCORE_web/.github_instructions/secretsmanagement.instructions.md`. + +## How it works +1. `Fuchs/Program.cs` calls `builder.AddSecretManagement()` + (`OCORE_web.Secrets.SecretManagementWebExtensions`) as the first step in + `ConfigureServices`, before `FuchsOcmsIntranet.Initialize(configuration)` + or anything else reads `IConfiguration`. +2. That call, backed by `OCORE.Secrets.Extensions.SecretManagementExtensions`, + registers two configuration sources in order: the local DPAPI cache + (`secrets.cache`, offline fallback) first, then the live Azure Key Vault + (overrides the cache when reachable) using `PrefixKeyVaultSecretManager` + to filter to secrets named `fuchs--...` and map them onto + `IConfiguration` keys. +3. `KeyVaultSyncService` (a registered `BackgroundService`) periodically + re-pulls every key listed in `Fuchs:SecretManagement:ManagedSecretKeys` + from Key Vault, re-writes the DPAPI cache, and calls + `IConfigurationRoot.Reload()` so a running process picks up rotated + secrets without a restart. +4. A secret whose name is only known at runtime (not a fixed + `IConfiguration` key) — e.g. resolved from a DB job record — is instead + fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`, + bypassing `ManagedSecretKeys` and the periodic sync entirely. +5. In `Development`, `appsettings.Development.json` (git-ignored) can + override any of these values locally, bypassing Key Vault for that key; + `AssembleConnectionStrings` (`Fuchs/Program.cs`) additionally prefers + `_Dev`-suffixed credential keys so a reachable Key Vault can never hand a + dev machine production DB credentials. + +## Key files +- `Fuchs/Program.cs` — `builder.AddSecretManagement()` call site and + `AssembleConnectionStrings`. +- `Fuchs/appsettings.json` — `SecretManagement` block (`VaultUri`, + `AppName: "fuchs"`, `CacheFilePath`, `SyncIntervalHours`, + `ManagedSecretKeys`) plus `"MANAGED_BY_KEYVAULT"` placeholders for every + managed key. +- `OCORE/Secrets/Extensions/SecretManagementExtensions.cs` — shared + Key Vault + DPAPI wiring and DI registration, used by every host type. +- `OCORE/Secrets/Extensions/SecretsOnDemandLoader.cs` — direct, cached, + runtime-name secret lookup outside the `ManagedSecretKeys` sync path. +- `OCORE/Secrets/Sync/KeyVaultSyncService.cs` — periodic re-sync + + `IConfigurationRoot.Reload()`. +- `OCORE/Secrets/Cache/DpapiSecretsCache.cs` — DPAPI encrypt/decrypt of the + local cache file. +- `OCORE_web/Secrets/SecretManagementWebExtensions.cs` — the + `WebApplicationBuilder.AddSecretManagement()` entry point Fuchs calls. +- `OCORE_web/.github_instructions/secretsmanagement.instructions.md` — + canonical mechanism reference (naming/encoding rules, cache format, + "adding a new secret" steps), kept current independently of this doc. +- `.github/copilot-instructions.md` ("Azure Key Vault — Secret Naming") — + the naming rules summarized for this repo, with the `fuchs--` prefix. + +## Related decisions +- [0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web](../Decisions/0031-shared-keyvault-dpapi-secret-management.md) diff --git a/Fuchs/Docs/Decisions/0031-shared-keyvault-dpapi-secret-management.md b/Fuchs/Docs/Decisions/0031-shared-keyvault-dpapi-secret-management.md new file mode 100644 index 0000000..b81d094 --- /dev/null +++ b/Fuchs/Docs/Decisions/0031-shared-keyvault-dpapi-secret-management.md @@ -0,0 +1,76 @@ +--- +status: Accepted +date: 2026-09-09 +applyTo: + - "OCORE/OCORE/Secrets/**" + - "OCORE_web/OCORE_web/Secrets/**" + - "Fuchs/Program.cs" + - "Fuchs/appsettings*.json" +supersededBy: "" +--- + +# 0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web + +## Context +Fuchs is one of several applications (web apps, console apps, Windows +services, worker services) running on the same Windows server. Secrets +(connection strings, API keys, mailer tokens, etc.) previously lived in +plain text in `appsettings.json`/`web.config`/environment variables. A +single design needed to work identically across all host types — not just +ASP.NET Core — while keeping secrets out of source control and available +even when the Key Vault is briefly unreachable. + +## Decision +- **One shared Azure Key Vault** (`pcwkeys`) for all applications on the + server, rather than a per-app vault. Access control happens at the + secret level (RBAC), not at the vault level. +- **`{appname}--` prefix namespacing** on every secret name (e.g. + `fuchs--ConnectionStrings--ocms-ConnectionString`) prevents collisions + between apps sharing the vault. Naming/encoding rules are recorded in + `.github/copilot-instructions.md` under "Azure Key Vault — Secret + Naming". +- **Core logic lives in `OCORE`** (`OCORE/Secrets/Models/AppSecretsOptions.cs`, + `Cache/DpapiSecretsCache.cs`, `Cache/DpapiCacheConfigurationSource.cs`, + `Sync/KeyVaultSyncService.cs`, `Extensions/SecretManagementExtensions.cs`, + `Extensions/PrefixKeyVaultSecretManager.cs`, `Extensions/SecretsOnDemandLoader.cs`) + because it has no web dependency and must also work for console apps, + Windows services, worker services, and WinForms/WPF. `OCORE_web` adds a + single thin file (`Secrets/SecretManagementWebExtensions.cs`) exposing + `WebApplicationBuilder.AddSecretManagement()` for ASP.NET Core hosts; + it calls back into `OCORE`'s shared logic rather than duplicating it. +- **Local DPAPI cache as offline fallback**: secrets synced from Key Vault + are also encrypted to `secrets.cache` using Windows DPAPI with + `DataProtectionScope.LocalMachine` plus app-specific entropy derived + from `AppName`, so one app on the server cannot decrypt another app's + cache file, and the app can still start if Key Vault is briefly + unreachable. +- **One shared server-level Managed Identity** (via `DefaultAzureCredential`) + for all apps, rather than 15 separate identities — access is scoped by + the `{appname}--` secret prefix via Key Vault RBAC, not by identity. + +## Consequences +- Fuchs calls `builder.AddSecretManagement()` (`Fuchs/Program.cs`) before + `FuchsOcmsIntranet.Initialize(configuration)` — secrets must be resolved + before any singleton reads `IConfiguration`. +- New secrets must be added to Key Vault under the `fuchs--` prefix *and* + registered in `ManagedSecretKeys` in `Fuchs/appsettings.json` (see + `.github/copilot-instructions.md`), or `KeyVaultSyncService` will never + pick them up. +- A secret whose name is only known at runtime (not a fixed config key) + bypasses `ManagedSecretKeys`/`KeyVaultSyncService` entirely and is + fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader` + instead. +- Any other application added to the shared server follows the same + pattern: reference `OCORE_web` (web) or `OCORE` (non-web), set a unique + `AppName`, and grant its prefix `Get`/`List` access in the shared vault. +- Detailed wiring/encoding reference (kept current independently of this + ADR) lives in `OCORE_web/.github_instructions/secretsmanagement.instructions.md`. + +## Alternatives considered +- **Per-app Key Vaults** — rejected: 15x the management overhead for no + isolation benefit over prefix namespacing + secret-level RBAC. +- **Per-app Managed Identities** — rejected: requires provisioning and + maintaining 15 separate identities; a shared identity with per-secret + RBAC achieves the same isolation. +- **Environment variables only, no local cache** — rejected: no + offline/startup resilience if Key Vault is briefly unreachable.