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.Abstractions;
using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Ui; using ZB.MOM.WW.Secrets.Ui;
using ZB.MOM.WW.Telemetry; using ZB.MOM.WW.Telemetry;
using Serilog; using Serilog;
@@ -75,11 +74,35 @@ var configuration = new ConfigurationBuilder()
// Expand ${secret:...} config references before any validator/binder sees them (Layer A). // Expand ${secret:...} config references before any validator/binder sees them (Layer A).
// Throwaway provider — disposed here, shares no singletons with the host container. // 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 // The expander MUST read the SAME store the running node will serve — SecretsRegistration.
// topology that ever put a central node's store somewhere else pre-host. (Secrets:Store:Provider // UsesCentralSharedStore is the single predicate deciding that, shared with the runtime
// =SqlServer, the still-supported shared-central-store feature, is a post-host DI registration — // registration in AddScadaBridgeSecrets, so the two can never disagree. On a Central node with
// see SecretsRegistration — and does not change what the Layer-A expander itself reads.) // 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 // 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 // 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 // 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 // AddPreHostSqliteExpander for why the content-root argument is load-bearing and silent when
// dropped, and PreHostSecretsContentRootTests for the regression guard. // dropped, and PreHostSecretsContentRootTests for the regression guard.
var expanderServices = new ServiceCollection(); var expanderServices = new ServiceCollection();
expanderServices.AddPreHostSqliteExpander( expanderServices.AddPreHostExpander(
configuration, SecretsRegistration.ResolveHostContentRoot()); configuration, expanderRole, SecretsRegistration.ResolveHostContentRoot());
#pragma warning disable ASP0000 // deliberate throwaway container #pragma warning disable ASP0000 // deliberate throwaway container
await using (var secretsProvider = expanderServices.BuildServiceProvider()) await using (var secretsProvider = expanderServices.BuildServiceProvider())
#pragma warning restore ASP0000 #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); .MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>(); var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
@@ -359,14 +385,14 @@ try
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment))); .GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
builder.Services.AddSecurity(disableLogin); builder.Services.AddSecurity(disableLogin);
builder.Services.AddCentralUI(); builder.Services.AddCentralUI();
// Local SQLite store by default. With Secrets:Replication:Enabled true it becomes either a // Local SQLite store by default. With Secrets:Store:Provider=SqlServer this central node's
// local store synced against a shared SQL-Server hub (Mode=SqlServer, and only when a hub // store becomes the SHARED SQL-Server database instead — no hub, no follower sweep, just
// connection string is present) or — the production topology, scadaproj#3 — the HOST half // both central nodes reading and writing the same rows, so a clustered pair can never ship
// of the pull-only gRPC secrets hub (Mode=Grpc), which central serves and sites sweep. // sites a drifted secret set (Task 5). The Layer-A pre-host expander above resolves this
// The Central role never registers the sweep client; passing the role explicitly is what // same predicate (SecretsRegistration.UsesCentralSharedStore) so it always reads the store
// makes that a compile-time property of this composition root rather than a config guess. // this node actually serves. See SecretsRegistration for why the SQL gate exists (eager
// See SecretsRegistration for why the SQL gate exists (eager options validation), why the // options validation) and why the connection string can never itself be a ${secret:}
// hub credential can never itself come from the hub, and why gRPC mode fails closed. // reference.
builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central); builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
// Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal // Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal
// (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS // (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS
@@ -62,9 +62,10 @@ public enum SecretsNodeRole
/// <b>Bootstrap constraint.</b> The shared store's own connection string can never itself come from /// <b>Bootstrap constraint.</b> The shared store's own connection string can never itself come from
/// that store — a node cannot read the database to learn how to reach the database. It must arrive /// that store — a node cannot read the database to learn how to reach the database. It must arrive
/// from outside it: an environment variable, or a literal value in appsettings. It can never be a /// from outside it: an environment variable, or a literal value in appsettings. It can never be a
/// <c>${secret:}</c> reference, because on central the pre-host expander that would resolve such a /// <c>${secret:}</c> reference, because on central the pre-host expander (<see cref="AddPreHostExpander"/>,
/// reference runs against the very store this string points at (see <c>Program.cs</c>) — the /// called from <c>Program.cs</c> — see <see cref="UsesCentralSharedStore"/>) resolves EVERY
/// reference could never resolve, so registration rejects it outright. /// <c>${secret:}</c> token in configuration, including this one, from the very store this string
/// points at — the reference could never resolve, so registration rejects it outright.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class SecretsRegistration public static class SecretsRegistration
@@ -87,6 +88,30 @@ public static class SecretsRegistration
/// </summary> /// </summary>
public const string StoreProviderKey = "Secrets:Store:Provider"; public const string StoreProviderKey = "Secrets:Store:Provider";
/// <summary>
/// Whether this node's secret store is the shared SQL-Server database — the single predicate
/// behind BOTH <see cref="AddScadaBridgeSecrets"/>'s runtime registration AND
/// <see cref="AddPreHostExpander"/>'s Layer-A <c>${secret:}</c> expander, so the two can never
/// disagree about which store a Central node serves. That matters more than tidiness: an
/// expander that resolved boot-time <c>${secret:}</c> tokens from the per-node SQLite file
/// while the running node served the shared SQL store would let two central nodes boot with
/// different resolved values (e.g. a different <c>JwtSigningKey</c>) — exactly the per-node
/// drift <see cref="StoreProviderKey"/>=SqlServer exists to prevent.
/// </summary>
/// <param name="config">Application configuration.</param>
/// <param name="role">Which half of the composition this node is.</param>
/// <returns>
/// <see langword="true"/> when <paramref name="role"/> is <see cref="SecretsNodeRole.Central"/>
/// and <see cref="StoreProviderKey"/> is (case-insensitively) <c>SqlServer</c>.
/// </returns>
internal static bool UsesCentralSharedStore(IConfiguration config, SecretsNodeRole role)
{
ArgumentNullException.ThrowIfNull(config);
return role == SecretsNodeRole.Central
&& string.Equals(config[StoreProviderKey], "SqlServer", StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// Resolves the content root the HOST will use, from outside the host — the Layer-A /// 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>${secret:}</c> expander in <c>Program.cs</c> runs before any builder exists, so
@@ -146,6 +171,62 @@ public static class SecretsRegistration
return services.AddZbSecrets(config, SecretsSectionPath, contentRoot); return services.AddZbSecrets(config, SecretsSectionPath, contentRoot);
} }
/// <summary>
/// Composes the Layer-A pre-host <c>${secret:}</c> expander stack for <paramref name="role"/> —
/// the shared SQL-Server store when <see cref="UsesCentralSharedStore"/> is true, otherwise the
/// plain local SQLite store (<see cref="AddPreHostSqliteExpander"/>).
/// </summary>
/// <remarks>
/// <para>
/// This is the fix for the gap <see cref="UsesCentralSharedStore"/>'s own remarks describe: the
/// expander MUST read the same store <see cref="AddScadaBridgeSecrets"/> will register for the
/// running node, or a boot-time <c>${secret:}</c> reference (e.g. in
/// <c>ScadaBridge:Security:JwtSigningKey</c>) resolves from a store the node does not actually
/// serve. Both this method and <see cref="AddScadaBridgeSecrets"/> branch on the exact same
/// <see cref="UsesCentralSharedStore"/> call, so they cannot drift apart the way the old
/// Grpc-mode expander branch in <c>Program.cs</c> and the Grpc-mode runtime branch here once
/// had to be kept in step by hand.
/// </para>
/// <para>
/// On the shared-store path this also runs <see cref="EnsureCentralSharedStoreConnectionString"/>
/// BEFORE composing anything — the same fail-closed rules apply here as at runtime: a blank
/// connection string, or one holding a <c>${secret:}</c> reference (which could never resolve;
/// this very expander is what would have to resolve it), fails the boot with the designed
/// message instead of a generic SqlConnection format error.
/// </para>
/// </remarks>
/// <param name="services">The throwaway expander service collection.</param>
/// <param name="config">Application configuration.</param>
/// <param name="role">Which half of the composition this node is.</param>
/// <param name="contentRoot">The host's content root, from <see cref="ResolveHostContentRoot"/> — only used on the SQLite path.</param>
/// <returns>The same collection, for chaining.</returns>
/// <exception cref="InvalidOperationException">
/// <paramref name="role"/> is Central with the shared store selected, and
/// <see cref="HubConnectionStringKey"/> is blank or a <c>${secret:}</c> reference.
/// </exception>
public static IServiceCollection AddPreHostExpander(
this IServiceCollection services,
IConfiguration config,
SecretsNodeRole role,
string contentRoot)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
if (UsesCentralSharedStore(config, role))
{
EnsureCentralSharedStoreConnectionString(config, $"{StoreProviderKey}=SqlServer");
// Same 3-arg overload as the runtime registration in AddScadaBridgeSecrets — this is
// deliberately the SAME store, not a lookalike one composed independently.
return services.AddZbSecretsSqlServerStore(
config, SecretsSectionPath, SqlServerSectionPath);
}
return services.AddPreHostSqliteExpander(config, contentRoot);
}
/// <summary> /// <summary>
/// The one definition of "a Central node has a usable shared-store connection string", shared /// The one definition of "a Central node has a usable shared-store connection string", shared
/// by every caller that puts a Central node on the shared SQL-Server store — the /// by every caller that puts a Central node on the shared SQL-Server store — the
@@ -230,9 +311,10 @@ public static class SecretsRegistration
// Secrets:Store:Provider=SqlServer on Central only. A Site never reaches this: it always // Secrets:Store:Provider=SqlServer on Central only. A Site never reaches this: it always
// keeps its local SQLite store, straight through a WAN outage, same as every other // keeps its local SQLite store, straight through a WAN outage, same as every other
// topology, and never decorated — see the class remarks. // topology, and never decorated — see the class remarks. UsesCentralSharedStore is the
if (role == SecretsNodeRole.Central // SAME predicate AddPreHostExpander branches on, so the two can never disagree about
&& string.Equals(config[StoreProviderKey], "SqlServer", StringComparison.OrdinalIgnoreCase)) // which store a Central node serves.
if (UsesCentralSharedStore(config, role))
{ {
// Fail-closed rules: blank, or a ${secret:} reference (bootstrap circularity — the // Fail-closed rules: blank, or a ${secret:} reference (bootstrap circularity — the
// pre-host expander needs this same store to resolve it). // pre-host expander needs this same store to resolve it).
@@ -31,7 +31,7 @@ public static class StartupValidator
// Deploy Artifacts, and a replicated site store would publish DeployedSecretsMirror's local // Deploy Artifacts, and a replicated site store would publish DeployedSecretsMirror's local
// deletes fleet-wide. Checked unconditionally (both roles): a Site used to be the gRPC // deletes fleet-wide. Checked unconditionally (both roles): a Site used to be the gRPC
// hub's follower half, so the flag was never Central-only. // hub's follower half, so the flag was never Central-only.
var secretsReplicationEnabled = configuration.GetValue<bool>("Secrets:Replication:Enabled"); var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
// GrpcPort: default 8083 when absent; only fails the range rule when the key is // GrpcPort: default 8083 when absent; only fails the range rule when the key is
// present AND invalid. The out-param assignment mirrors the original so the // present AND invalid. The out-param assignment mirrors the original so the
@@ -251,6 +251,7 @@ public static class StartupValidator
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>(); var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
var provider = InboundApiKeyStoreOptions.ReadProvider(configuration); var provider = InboundApiKeyStoreOptions.ReadProvider(configuration);
var secretsStoreProvider = configuration["Secrets:Store:Provider"]; var secretsStoreProvider = configuration["Secrets:Store:Provider"];
var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 }; var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 };
var warnings = new List<string>(); var warnings = new List<string>();
@@ -283,9 +284,46 @@ public static class StartupValidator
+ "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer"); + "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer");
} }
// Review round 1, Minor #4: a config that still carries Secrets:Replication or
// Secrets:GrpcHub child keys — left over from before Task 11 retired the hub — is not
// itself an error (Validate already rejects Enabled=true outright), but every key under
// either section is now silently ignored. That is worth a Warning so an operator cleans
// QA/prod appsettings rather than assuming a still-present Mode/BearerToken/Endpoint value
// does anything. Excludes the bare "Enabled" key itself: "Enabled: false" alone is the
// normal, still-valid shape of every existing deployment's config and must not warn.
var hasStaleReplicationKeys = configuration.GetSection("Secrets:Replication").GetChildren()
.Any(child => !string.Equals(child.Key, "Enabled", StringComparison.OrdinalIgnoreCase));
var hasStaleGrpcHubKeys = configuration.GetSection("Secrets:GrpcHub").GetChildren().Any();
if (!secretsReplicationEnabled && (hasStaleReplicationKeys || hasStaleGrpcHubKeys))
{
warnings.Add(
"Secrets:Replication / Secrets:GrpcHub settings are ignored — the secrets hub is "
+ "retired; remove them");
}
return warnings; return warnings;
} }
/// <summary>
/// Whether <c>Secrets:Replication:Enabled</c> is "set" — read as a raw string rather than
/// <c>IConfiguration.GetValue&lt;bool&gt;</c> (review round 1, Minor #3), which throws a raw
/// binder <see cref="InvalidOperationException"/> on a non-boolean value like <c>"yes"</c> or
/// <c>"1"</c>, so a malformed leftover key would surface as an unrelated parse crash instead of
/// the designed retirement message in <see cref="Validate"/>. Any value other than absent,
/// empty, or (case-insensitively) <c>"false"</c> counts as "set" — deliberately stricter than
/// bool parsing, so a typo'd leftover (<c>Enabled: 1</c>, <c>Enabled: yes</c>) still gets the
/// explanatory message rather than silently passing as falsy. Shared by <see cref="Validate"/>
/// (which rejects "set" outright) and <see cref="CollectWarnings"/> (which, for the "not set"
/// case, separately warns about any OTHER leftover key under the same retired sections).
/// </summary>
private static bool IsSecretsReplicationEnabled(IConfiguration configuration)
{
var raw = configuration["Secrets:Replication:Enabled"];
return !string.IsNullOrEmpty(raw)
&& !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// Extracts the TCP port from an Akka seed-node address of the form /// Extracts the TCP port from an Akka seed-node address of the form
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed. /// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
@@ -116,13 +116,16 @@ public sealed record MirrorResult(
/// can observe a stale pre-rotation plaintext for up to the cache TTL. /// can observe a stale pre-rotation plaintext for up to the cache TTL.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Node-local store assumption (review Minor #5).</b> This mirror assumes the site's /// <b>Node-local store assumption (review Minor #5; Task 11 removed every replication mode).</b>
/// <see cref="ISecretStore"/> is node-local (plain local SQLite, or the gRPC-hub follower half, /// This mirror assumes the site's <see cref="ISecretStore"/> is always plain, node-local, local
/// both of which keep writes local). It is NOT safe to run against a site node whose store is /// SQLite — the ONLY shape a Site node's store can be after Task 11 retired both the pull-only
/// decorated by SQL-Server secrets REPLICATION (<c>Secrets:Replication:Mode=SqlServer</c>) — its /// gRPC secrets hub and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it (see
/// <c>DeleteAsync</c> publishes the tombstone to the shared hub, so a site merely un-referencing /// <c>SecretsRegistration</c>). It would NOT have been safe to run this mirror against a store
/// a secret would delete it fleet-wide. Task 11 rejects that configuration on the Site role; /// decorated by either retired replication mode: <c>DeleteAsync</c> would have published the
/// no code change is made here. /// tombstone to every other node sharing the decorator, so a site merely un-referencing a secret
/// would have deleted it fleet-wide. <c>StartupValidator</c> now rejects
/// <c>Secrets:Replication:Enabled=true</c> outright on either role, so that configuration can no
/// longer reach this mirror at all.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Resolve semantics and the resolver cache.</b> The trial-resolve verify step goes through /// <b>Resolve semantics and the resolver cache.</b> The trial-resolve verify step goes through
@@ -36,6 +36,17 @@ public class SecretsReplicationWiringTests
private const string DummyConnectionString = private const string DummyConnectionString =
"Server=unused;Database=x;Integrated Security=true;"; "Server=unused;Database=x;Integrated Security=true;";
/// <summary>
/// A fresh directory, never the one holding <see cref="BuildConfig"/>'s default
/// <c>Secrets:SqlitePath</c> (<c>Path.GetTempPath()</c> itself) — the SQLite path-rules
/// validator runs eagerly on the first <c>IOptions&lt;SecretsOptions&gt;.Value</c> read (i.e.
/// on resolving <c>ISecretStore</c> itself, not merely on migrating), so passing
/// <c>Path.GetTempPath()</c> as the content root here would make every SQLite-path expander
/// test fail on "path is inside the content root" instead of testing what it means to.
/// </summary>
private static string NewContentRoot() =>
Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-content-root-" + Guid.NewGuid().ToString("N"));
private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides) private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
{ {
var settings = new Dictionary<string, string?> var settings = new Dictionary<string, string?>
@@ -214,4 +225,121 @@ public class SecretsReplicationWiringTests
// the configured value, because a mistyped value here could be a pasted real credential. // the configured value, because a mistyped value here could be a pasted real credential.
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal); Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
} }
// ---------------------------------------------------------------------------------------
// The Layer-A pre-host ${secret:} expander (SecretsRegistration.AddPreHostExpander, called
// from Program.cs) — review round 1, Important #1. It MUST compose against the SAME store
// AddScadaBridgeSecrets registers for the running node: an expander that resolved boot-time
// ${secret:} tokens (e.g. ScadaBridge:Security:JwtSigningKey) from the per-node SQLite file
// while the running node served the shared SQL store would let two central nodes disagree on
// a rotated value — exactly the per-node drift Store:Provider=SqlServer exists to prevent.
// UsesCentralSharedStore is the single predicate shared by both methods.
// ---------------------------------------------------------------------------------------
[Fact]
public void UsesCentralSharedStore_TrueOnlyForCentralWithProviderSqlServer()
{
IConfiguration sqlServerConfig = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
IConfiguration sqliteConfig = BuildConfig();
Assert.True(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Central));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Site));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqliteConfig, SecretsNodeRole.Central));
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_ResolvesSharedSqlStore()
{
// The bug this guards against: the expander used to be wired ONLY to the retired Grpc
// mode, so a Central node on Store:Provider=SqlServer (the retirement message's own
// prescribed replacement) resolved boot-time ${secret:} tokens from the wrong store.
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
// The store-agnostic migrator seam — resolving through it is what lets Program.cs's
// expander block stay store-agnostic instead of re-deriving which concrete migrator to
// ask for.
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void ExpanderCentral_Default_ResolvesSqliteStore()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(BuildConfig(), SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void ExpanderSite_StoreProviderSqlServer_StillResolvesSqliteStore()
{
// A Site ignores Store:Provider entirely, exactly as the runtime registration does — the
// expander must not be a second place where that rule could be got wrong.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Site, NewContentRoot());
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
// The bootstrap-circularity rule applies here too: this expander is exactly the code that
// would otherwise have to resolve the reference in order to build the store that resolves
// it.
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
} }
@@ -784,6 +784,11 @@ public class StartupValidatorTests
[Theory] [Theory]
[InlineData("true")] [InlineData("true")]
[InlineData("True")] [InlineData("True")]
// Review round 1, Minor #3: these are not valid booleans. GetValue<bool> used to throw a raw
// OptionsValidationException-style binder crash on them instead of the designed message; the
// key is read as a raw string now, so any non-"false" value is "set" and rejected the same way.
[InlineData("yes")]
[InlineData("1")]
public void ReplicationEnabled_IsRejected_OnCentral(string value) public void ReplicationEnabled_IsRejected_OnCentral(string value)
{ {
var values = ValidCentralConfig(); var values = ValidCentralConfig();
@@ -825,6 +830,117 @@ public class StartupValidatorTests
Assert.Null(ex); Assert.Null(ex);
} }
[Fact]
public void ReplicationDisabled_CaseInsensitive_PassesValidation()
{
var values = ValidCentralConfig();
values["Secrets:Replication:Enabled"] = "FALSE";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
/// <summary>
/// Deliberately stricter than <c>bool</c> parsing (review round 1, Minor #3): only an absent
/// key or exactly (case-insensitively) <c>"false"</c> is safe. <c>"0"</c> is a legal
/// <c>bool.TryParse</c>-adjacent falsy spelling in some conventions but is NOT exempted here —
/// a leftover key this malformed should surface the retirement message, not silently pass.
/// </summary>
[Fact]
public void ReplicationEnabled_Zero_IsRejected()
{
var values = ValidCentralConfig();
values["Secrets:Replication:Enabled"] = "0";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains(
"Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message);
}
/// <summary>
/// Review round 1, Minor #4: a leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key
/// from before Task 11 is not itself a boot failure (Validate already rejects Enabled=true
/// outright), but every OTHER key under either section is now silently ignored — worth a
/// Warning so an operator cleans up QA/prod appsettings.
/// </summary>
[Fact]
public void CollectWarnings_WarnsForLeftoverReplicationModeKey()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:Replication:Mode"] = "SqlServer";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal)
&& w.Contains("the secrets hub is retired; remove them", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_WarnsForLeftoverGrpcHubKey()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:GrpcHub:BearerToken"] = "stale-leftover-token";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_WarnsForLeftoverKeys_OnSiteToo()
{
// The warning is not central-scoped: a Site config can carry leftover GrpcHub follower
// settings just as easily.
var values = ValidSiteConfig();
values["Secrets:GrpcHub:Endpoint"] = "http://stale-central:8083";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Contains(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_SilentForBareReplicationEnabledFalse()
{
// "Enabled: false" alone is the normal, still-valid shape of every existing deployment's
// config (it is what ships in appsettings.json) — this must NOT warn just because the
// Secrets:Replication section exists.
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
values["Secrets:Replication:Enabled"] = "false";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.DoesNotContain(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact]
public void CollectWarnings_SilentWhenNeitherSectionPresent()
{
var values = ValidCentralConfig();
values["Secrets:Store:Provider"] = "SqlServer";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.DoesNotContain(warnings, w =>
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
[Fact] [Fact]
public void CollectWarnings_SilentForSingleSeedCentral() public void CollectWarnings_SilentForSingleSeedCentral()
{ {