chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 and close the pre-host guard gap

0.6.x refuses a secret store whose path is relative or inside the content root,
because a store in the deployment directory is destroyed by an ordinary upgrade —
the failure that wiped the MxGateway API-key store on 2026-08-09 and read as an
auth outage rather than a deployment error.

The pin alone would not have protected this repo. Program.cs expands ${secret:}
before the host exists, composing secrets into a throwaway ServiceCollection with
no IHostEnvironment, so the guard would not run at the moment the migrator creates
the store. That composition now lives in SecretsRegistration with an explicit
content root — resolved to match what the host resolves later, including the
Windows-Service case where the pre-host CWD is still system32 — and is covered by
PreHostSecretsContentRootTests, verified by simulating the regression and
confirming it fails on the leftover file rather than on the exception.

The docker rig needed a fix too: /app/data is absolute but inside the container's
content root, so all 8 nodes would have failed to boot. Each node's data directory
is now mounted a second time at /data; same host directory, so existing stores
carry over untouched.

Verified: build clean, 29 test assemblies green (Playwright's 159 failures are the
pre-existing SEC-36 login baseline). Not yet deployed — the rig runs the old
config until someone redeploys.
This commit is contained in:
Joseph Doherty
2026-08-11 09:16:31 -04:00
parent 3eb7df74eb
commit fdc6b0c2bb
8 changed files with 251 additions and 17 deletions
@@ -148,16 +148,29 @@ The committed default in `appsettings.json` is:
```json
"Secrets": {
"SqlitePath": "scadabridge-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
path is relative to the working directory, so local dev and the
`WebApplicationFactory<Program>` `Host.Tests` boot cleanly with no external mount.
**`SqlitePath` is deliberately absent** (changed with `ZB.MOM.WW.Secrets` 0.6.2, 2026-08-11).
That version validates the setting at startup and rejects it unless it is both **absolute** and
**outside the application content root** — the two rules are independent, and the path that
destroyed the MxGateway key store on 2026-08-09 was absolute. The former committed value
(`"scadabridge-secrets.db"`) was relative and now fails the boot, so it was removed rather than
replaced: an unset path takes the library's absolute per-user `LocalApplicationData` default,
which satisfies both rules everywhere without a mount.
This is dev-safe: `Source=Environment` needs no filesystem key, and the per-user default is
writable, so local dev and the `WebApplicationFactory<Program>` `Host.Tests` boot cleanly with no
external mount.
**Containerized deployments need care.** The rule is lexical, so a path under the content root is
rejected even when it is a *bind mount* that an image rebuild never touches. The docker rig hit
exactly this: `/app/data/scadabridge-secrets.db` with content root `/app`. The fix was to mount
each node's existing host directory a **second** time at `/data` and point `Secrets__SqlitePath`
there — same underlying directory, so the store is found unmoved and nothing needs migrating.
Local dev and docker-compose environments supply concrete secret values via the
**whole-key environment override** (e.g. `ScadaBridge__Database__ConfigurationDb`),
which bypasses `${secret:...}` resolution entirely, so the expander is effectively