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
@@ -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