fix(secrets): review round 1 — pre-host expander reads the same store the runtime serves, raw-string Replication:Enabled parse, leftover-key warning

- SecretsRegistration: UsesCentralSharedStore predicate shared by AddScadaBridgeSecrets and the
  new AddPreHostExpander, so a Central node's Layer-A ${secret:} expander composes against the
  shared SQL store whenever Secrets:Store:Provider=SqlServer does — closing the gap where a
  central migrated off the retired Grpc topology would boot-time-resolve config from per-node
  SQLite while serving the shared store.
- Program.cs: expander block now calls AddPreHostExpander + resolves the store-agnostic
  ISecretsStoreMigrator seam; stale Grpc-hub/replication comment rewritten.
- DeployedSecretsMirror.cs: stale "gRPC-hub follower half" doc comment rewritten to state the
  now-permanent local-SQLite-only invariant.
- StartupValidator: Secrets:Replication:Enabled read as a raw string (any non-empty, non-"false"
  value rejected) instead of GetValue<bool>, which threw a raw binder crash on "yes"/"1"; added a
  CollectWarnings entry for leftover Secrets:Replication/Secrets:GrpcHub child keys once Enabled
  is false/unset.
- Tests: +15 (AddPreHostExpander/UsesCentralSharedStore wiring, Replication:Enabled edge values,
  leftover-key warnings). Full solution build and full Host.Tests green (593/593).
This commit is contained in:
Joseph Doherty
2026-09-28 20:10:17 -04:00
parent 2bac13170c
commit bc29dc412b
6 changed files with 423 additions and 30 deletions
+42 -16
View File
@@ -33,7 +33,6 @@ using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Ui;
using ZB.MOM.WW.Telemetry;
using Serilog;
@@ -75,11 +74,35 @@ var configuration = new ConfigurationBuilder()
// Expand ${secret:...} config references before any validator/binder sees them (Layer A).
// Throwaway provider — disposed here, shares no singletons with the host container.
//
// This always reads the node's LOCAL SQLite store: Task 11 retired the gRPC secrets hub, the only
// topology that ever put a central node's store somewhere else pre-host. (Secrets:Store:Provider
// =SqlServer, the still-supported shared-central-store feature, is a post-host DI registration —
// see SecretsRegistration — and does not change what the Layer-A expander itself reads.)
// The expander MUST read the SAME store the running node will serve — SecretsRegistration.
// UsesCentralSharedStore is the single predicate deciding that, shared with the runtime
// registration in AddScadaBridgeSecrets, so the two can never disagree. On a Central node with
// Secrets:Store:Provider=SqlServer that is the SHARED SQL-Server store (Task 5): an expander left
// on SQLite there would resolve pre-host ${secret:} references (e.g. ScadaBridge:Security:
// JwtSigningKey) from a store the node does not actually serve, so a value rotated through the
// shared store would never reach the pre-host config, and two central nodes could disagree on
// what a token resolves to. Every other case (sites, the default per-node SQLite store) keeps the
// local SQLite path exactly as it always was. A blank or ${secret:}-valued connection string on
// the shared-store path fails HERE, through the same EnsureCentralSharedStoreConnectionString
// check AddScadaBridgeSecrets runs — this is the earliest code that would otherwise hand the bad
// value to SqlConnection, whose generic "initialization string" format error would bury the
// designed message.
//
// Two consequences worth knowing. This block runs BEFORE Serilog exists and outside the
// try/catch below, so a SQL Server outage at central boot exits with a bare stderr stack
// trace, no structured log — honest and container-restart-retryable, just unenriched; the
// SQLite path had the same window but its only failure mode was a local file. (That exit
// is real only because of the UnhandledException handler at the top of this file — the
// runtime's own crash path wedges as container PID 1, ScadaBridge#34.) And the role read
// stays here at the composition root rather than inside SecretsRegistration: that class
// refuses to read the node role from configuration (a config-read role could silently turn a
// site into a shared-store central — see SecretsNodeRole), while Program.cs must read it to
// branch the whole composition anyway.
var expanderRole = string.Equals(
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
? SecretsNodeRole.Central
: SecretsNodeRole.Site;
// 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
@@ -87,14 +110,17 @@ var configuration = new ConfigurationBuilder()
// AddPreHostSqliteExpander for why the content-root argument is load-bearing and silent when
// dropped, and PreHostSecretsContentRootTests for the regression guard.
var expanderServices = new ServiceCollection();
expanderServices.AddPreHostSqliteExpander(
configuration, SecretsRegistration.ResolveHostContentRoot());
expanderServices.AddPreHostExpander(
configuration, expanderRole, SecretsRegistration.ResolveHostContentRoot());
#pragma warning disable ASP0000 // deliberate throwaway container
await using (var secretsProvider = expanderServices.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
// ISecretsStoreMigrator is the store-agnostic seam both AddZbSecrets (SQLite) and
// AddZbSecretsSqlServerStore register — resolving through it means this block does not need
// to know, or re-derive, which concrete store AddPreHostExpander just composed.
await secretsProvider.GetRequiredService<ISecretsStoreMigrator>()
.MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
@@ -359,14 +385,14 @@ try
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
builder.Services.AddSecurity(disableLogin);
builder.Services.AddCentralUI();
// Local SQLite store by default. With Secrets:Replication:Enabled true it becomes either a
// local store synced against a shared SQL-Server hub (Mode=SqlServer, and only when a hub
// connection string is present) or — the production topology, scadaproj#3 — the HOST half
// of the pull-only gRPC secrets hub (Mode=Grpc), which central serves and sites sweep.
// The Central role never registers the sweep client; passing the role explicitly is what
// makes that a compile-time property of this composition root rather than a config guess.
// See SecretsRegistration for why the SQL gate exists (eager options validation), why the
// hub credential can never itself come from the hub, and why gRPC mode fails closed.
// Local SQLite store by default. With Secrets:Store:Provider=SqlServer this central node's
// store becomes the SHARED SQL-Server database instead — no hub, no follower sweep, just
// both central nodes reading and writing the same rows, so a clustered pair can never ship
// sites a drifted secret set (Task 5). The Layer-A pre-host expander above resolves this
// same predicate (SecretsRegistration.UsesCentralSharedStore) so it always reads the store
// this node actually serves. See SecretsRegistration for why the SQL gate exists (eager
// options validation) and why the connection string can never itself be a ${secret:}
// reference.
builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
// Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal
// (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS