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
+8 -1
View File
@@ -101,6 +101,12 @@ var expanderUsesSharedSqlStore =
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
&& SecretsRegistration.UsesGrpcHub(configuration);
// Secrets 0.6.2 validates Secrets:SqlitePath as absolute AND outside the content root, finding the
// content root from IHostEnvironment — which this throwaway ServiceCollection does not have, and
// which does not exist yet anyway (the host is not built until further down). Both the resolution
// and the registration therefore live in SecretsRegistration, where a test can enforce them; see
// AddPreHostSqliteExpander for why the content-root argument is load-bearing and silent when
// dropped, and PreHostSecretsContentRootTests for the regression guard.
var expanderServices = new ServiceCollection();
if (expanderUsesSharedSqlStore)
{
@@ -109,7 +115,8 @@ if (expanderUsesSharedSqlStore)
}
else
{
expanderServices.AddZbSecrets(configuration, "Secrets");
expanderServices.AddPreHostSqliteExpander(
configuration, SecretsRegistration.ResolveHostContentRoot());
}
#pragma warning disable ASP0000 // deliberate throwaway container
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting.WindowsServices;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
@@ -185,6 +186,65 @@ public static class SecretsRegistration
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
}
/// <summary>
/// Resolves the content root the HOST will use, from outside the host — the Layer-A
/// <c>${secret:}</c> expander in <c>Program.cs</c> runs before any builder exists, so
/// <c>builder.Environment.ContentRootPath</c> is not available to it.
/// </summary>
/// <remarks>
/// Mirrors the framework's own resolution order. The Windows-Service branch is the one that
/// is easy to get wrong: <c>UseWindowsService</c> sets both the current directory and the
/// content root to <see cref="AppContext.BaseDirectory"/>, but only once the host is built —
/// at expander time the process working directory is still <c>C:\Windows\system32</c>, so
/// taking the current directory there would compare the store path against the wrong root.
/// </remarks>
/// <returns>The absolute content-root path the host will resolve.</returns>
public static string ResolveHostContentRoot() =>
Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT")
?? Environment.GetEnvironmentVariable("DOTNET_CONTENTROOT")
?? (WindowsServiceHelpers.IsWindowsService()
? AppContext.BaseDirectory
: Directory.GetCurrentDirectory());
/// <summary>
/// Composes the SQLite secrets stack for the Layer-A pre-host <c>${secret:}</c> expander.
/// </summary>
/// <remarks>
/// <para>
/// This exists as a named method rather than an inline <c>AddZbSecrets</c> call so that the
/// content-root argument is covered by a test (<c>PreHostSecretsContentRootTests</c>). The
/// argument is load-bearing and its absence is SILENT: the expander composes into a throwaway
/// <see cref="IServiceCollection"/> that has no <c>IHostEnvironment</c>, so without it the
/// under-content-root rule does not run, the migrator creates the store at the rejected path,
/// and the boot then fails once the real host registers <c>IHostEnvironment</c> — leaving an
/// empty database behind. That "the file is there, it's just empty" artifact is the confusing
/// half of the 2026-08-09 MxGateway outage the guard exists to prevent.
/// </para>
/// <para>
/// A correctly configured path shows NO symptom either way, so this cannot be verified by
/// observing a clean boot — the exposure is that the guard is not running when the store is
/// created, not that any particular path is wrong.
/// </para>
/// </remarks>
/// <param name="services">The throwaway expander service collection.</param>
/// <param name="config">Application configuration.</param>
/// <param name="contentRoot">The host's content root, from <see cref="ResolveHostContentRoot"/>.</param>
/// <returns>The same collection, for chaining.</returns>
public static IServiceCollection AddPreHostSqliteExpander(
this IServiceCollection services,
IConfiguration config,
string contentRoot)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
// Do NOT drop the third argument. The 3-argument overload still compiles and silently
// reopens the gap described above; the regression would arrive disguised as removing a
// redundant parameter.
return services.AddZbSecrets(config, SecretsSectionPath, contentRoot);
}
/// <summary>
/// The one definition of "a Central node in Grpc mode has a usable shared-store connection
/// string", shared by <see cref="AddScadaBridgeSecrets"/> and the Layer-A expander in
@@ -1,6 +1,6 @@
{
"ScadaBridge": {
"_secretsClusterPosture": "Central pair KEK/store posture (production): see docs/operations/2026-07-16-secrets-clustered-master-key.md. Committed default is Source=Environment + relative SqlitePath (dev-safe); production uses a File KEK + shared store volume delivered out-of-band. Do NOT hardcode /shared paths here — the pre-host expander migrates the store at every Central boot and would break dev/test.",
"_secretsClusterPosture": "Central pair KEK/store posture (production): see docs/operations/2026-07-16-secrets-clustered-master-key.md. Committed default is Source=Environment with SqlitePath UNSET (Secrets 0.6.2 rejects a relative path, and a path inside the content root, at startup — so the library's absolute per-user default is taken in dev); production uses a File KEK + shared store volume delivered out-of-band. Do NOT hardcode /shared paths here — the pre-host expander migrates the store at every Central boot and would break dev/test.",
"_nodeName": "Host-018: NodeName stamps SourceNode on AuditLog/Notifications/SiteCalls rows (CLAUDE.md 'Centralized Audit Log' decision) and backs IX_AuditLog_Node_Occurred. Convention: 'central-a'/'central-b' for central nodes, 'node-a'/'node-b' for site nodes. Override per-node in multi-node deployments (the docker per-node configs do this). When left at the default below, single-node dev rows are stamped with 'central-a'; an empty value normalises to a NULL SourceNode.",
"Node": {
"Role": "Central",
@@ -10,7 +10,7 @@
}
},
"Secrets": {
"SqlitePath": "scadabridge-secrets.db",
"_sqlitePath": "Deliberately unset. ZB.MOM.WW.Secrets 0.6.2 validates Secrets:SqlitePath at startup — it must be ABSOLUTE and must NOT sit inside the content root, because a path inside the deployment directory is destroyed by an ordinary upgrade (this is what wiped the MxGateway API-key store on 2026-08-09). The former value here ('scadabridge-secrets.db') was relative and would now fail the boot. Leaving it unset gives dev the absolute per-user LocalApplicationData default; every real deployment supplies its own absolute path via its environment overlay.",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30",