fix(secrets): share the central connstr validation with the Layer-A expander

The blank/${secret:} pre-checks lived only in AddScadaBridgeSecrets, but on
a real central boot the Layer-A expander runs FIRST and would hand a bad
value to SqlConnection, burying the designed message under a generic
'initialization string' format error. Extracted both checks into
EnsureCentralSharedStoreConnectionString — one definition, called by the
expander (earliest point) and by registration (covers embedded/test
composition) — same single-source lesson as the UsesGrpcHub predicate.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 10:36:13 -04:00
parent 43e87a7492
commit a358244d9e
2 changed files with 64 additions and 44 deletions
+6 -5
View File
@@ -60,18 +60,19 @@ var configuration = new ConfigurationBuilder()
// scadaproj#4) — an expander left on SQLite there would resolve pre-host ${secret:} references
// from a stale/empty local store, silently diverging from what the node's own hub serves. Every
// other case (sites, SqlServer mode, replication off) keeps the local SQLite path exactly as it
// always was. Central+Grpc with a BLANK connection string deliberately falls through to the
// SQLite path too: that boot is about to fail in AddScadaBridgeSecrets with the message naming
// Secrets:SqlServer:ConnectionString, so the throw is not duplicated here.
// always was. A blank or ${secret:}-valued connection string 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.
var expanderUsesSharedSqlStore =
string.Equals(
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
&& SecretsRegistration.UsesGrpcHub(configuration)
&& !string.IsNullOrWhiteSpace(configuration[SecretsRegistration.HubConnectionStringKey]);
&& SecretsRegistration.UsesGrpcHub(configuration);
var expanderServices = new ServiceCollection();
if (expanderUsesSharedSqlStore)
{
SecretsRegistration.EnsureCentralSharedStoreConnectionString(configuration);
expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets");
}
else
@@ -185,6 +185,59 @@ public static class SecretsRegistration
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
}
/// <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
/// <c>Program.cs</c> so the two can never diverge. The expander runs FIRST on a real boot —
/// without this sharing, a bad value would die inside <c>SqlConnection</c> with a generic
/// "initialization string" format error before the designed message ever fired.
/// </summary>
/// <param name="config">Application configuration.</param>
/// <returns>The validated connection string.</returns>
/// <exception cref="InvalidOperationException">
/// <see cref="HubConnectionStringKey"/> is blank or contains a <c>${secret:}</c> reference.
/// </exception>
internal static string EnsureCentralSharedStoreConnectionString(IConfiguration config)
{
var hubConnectionString = config[HubConnectionStringKey];
if (string.IsNullOrWhiteSpace(hubConnectionString))
{
// Fail closed, matching the mode's philosophy: central in Grpc mode REQUIRES the
// shared SQL store. An independent local store per central node is exactly the
// divergence scadaproj#4 recorded — one central hub answered authenticated followers
// with an EMPTY manifest, so a hub failover would "succeed" against nothing and stop
// convergence silently.
throw new InvalidOperationException(
$"{HubConnectionStringKey} is empty, but this node is Central with "
+ $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the "
+ "SHARED SQL-Server secret store — an independent local store per "
+ "central node is the divergence scadaproj#4 recorded (a hub "
+ "failover would 'succeed' against an empty manifest). Supply the "
+ "connection string via appsettings or the environment "
+ "(Secrets__SqlServer__ConnectionString).");
}
if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal))
{
// The secrets store's own connection string can never be a secret reference: the
// pre-host expander needs the store this string points at in order to resolve it —
// bootstrap circularity, the same rule the class remarks document for the hub bearer
// token. The message deliberately does not echo the configured value: a mistyped
// value here could be a pasted real credential.
throw new InvalidOperationException(
$"{HubConnectionStringKey} contains a ${{secret:}} reference. The "
+ "secret store's own connection string can never be a secret "
+ "reference — the pre-host expander needs the store this string "
+ "points at in order to resolve it (bootstrap circularity, the "
+ "same rule as Secrets:GrpcHub:BearerToken). Supply a literal "
+ "value via appsettings or the environment "
+ "(Secrets__SqlServer__ConnectionString).");
}
return hubConnectionString;
}
/// <summary>
/// Registers the host container's secret store: a plain local SQLite store by default, or a
/// replicating topology when <see cref="ReplicationEnabledKey"/> is set — a shared SQL-Server
@@ -212,45 +265,11 @@ public static class SecretsRegistration
switch (role)
{
case SecretsNodeRole.Central:
// Both pre-checks run BEFORE any registration so the boot failure names the
// real problem instead of surfacing later as a package validation message or a
// malformed-connection-string fault on first use.
var hubConnectionString = config[HubConnectionStringKey];
if (string.IsNullOrWhiteSpace(hubConnectionString))
{
// Fail closed, matching the mode's philosophy: central in Grpc mode
// REQUIRES the shared SQL store. An independent local store per central
// node is exactly the divergence scadaproj#4 recorded — one central hub
// answered authenticated followers with an EMPTY manifest, so a hub
// failover would "succeed" against nothing and stop convergence silently.
throw new InvalidOperationException(
$"{HubConnectionStringKey} is empty, but this node is Central with "
+ $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the "
+ "SHARED SQL-Server secret store — an independent local store per "
+ "central node is the divergence scadaproj#4 recorded (a hub "
+ "failover would 'succeed' against an empty manifest). Supply the "
+ "connection string via appsettings or the environment "
+ "(Secrets__SqlServer__ConnectionString).");
}
if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal))
{
// The secrets store's own connection string can never be a secret
// reference: the pre-host expander needs the store this string points at
// in order to resolve it — bootstrap circularity, the same rule the class
// remarks document for the hub bearer token. The message deliberately
// does not echo the configured value: a mistyped value here could be a
// pasted real credential.
throw new InvalidOperationException(
$"{HubConnectionStringKey} contains a ${{secret:}} reference. The "
+ "secret store's own connection string can never be a secret "
+ "reference — the pre-host expander needs the store this string "
+ "points at in order to resolve it (bootstrap circularity, the "
+ "same rule as Secrets:GrpcHub:BearerToken). Supply a literal "
+ "value via appsettings or the environment "
+ "(Secrets__SqlServer__ConnectionString).");
}
// Runs BEFORE any registration so the boot failure names the real problem
// instead of surfacing later as a package validation message or a
// malformed-connection-string fault on first use. On a real boot the Layer-A
// expander in Program.cs has already run the same check even earlier.
EnsureCentralSharedStoreConnectionString(config);
// Central's store is the SHARED SQL-Server store: registers
// SqlServerSecretStore as ISecretStore BEFORE calling AddZbSecrets internally,