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