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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user