diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs index f0adcad8..821138c0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs @@ -131,6 +131,17 @@ public static class SecretsRegistration /// Configuration key holding the shared SQL-Server hub connection string. public const string HubConnectionStringKey = "Secrets:SqlServer:ConnectionString"; + /// + /// Configuration key selecting a CENTRAL node's own local store provider — independent of + /// / and the gRPC hub. + /// Absent or blank = Sqlite (per-node, the historical default); SqlServer points + /// both central nodes at ONE shared SQL-Server database directly, with no hub in between — + /// for a fleet that ships secrets to sites on every artifact deploy, a clustered central needs + /// both nodes to see the same secret set, and this is the lighter-weight way to get that + /// without standing up the gRPC hub. A Site node always ignores this key. + /// + public const string StoreProviderKey = "Secrets:Store:Provider"; + /// /// Reads . An absent or blank value is /// ; anything unrecognised throws. @@ -319,6 +330,28 @@ public static class SecretsRegistration ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); + // Secrets:Store:Provider=SqlServer on Central, BEFORE the replication branch below and + // gated OFF whenever clustered replication is enabled (that is a different, existing + // feature — the gRPC hub or SQL-Server replication already decide central's store in that + // case). A Site never reaches this: it always keeps its local SQLite store, straight + // through a WAN outage, same as every other topology. + if (role == SecretsNodeRole.Central + && string.Equals(config[StoreProviderKey], "SqlServer", StringComparison.OrdinalIgnoreCase) + && !config.GetValue(ReplicationEnabledKey)) + { + // Same fail-closed rules as the Grpc-mode central branch below: blank, or a + // ${secret:} reference (bootstrap circularity — the pre-host expander needs this + // same store to resolve it). + EnsureCentralSharedStoreConnectionString(config); + + // Registers the shared SQL-Server store as ISecretStore directly — no hub, no + // follower sweep. Both central nodes just point at the same database, so they serve + // identical manifests by construction, the same guarantee the Grpc-mode branch gets + // from the hub, without standing one up. + return services.AddZbSecretsSqlServerStore( + config, SecretsSectionPath, SqlServerSectionPath); + } + // UsesGrpcHub is the single predicate shared with MapScadaBridgeSecretsHub — the branch // condition must never be re-derived here, or registration and endpoint mapping could // disagree about whether the hub is on. diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs index 35559383..a353e2c6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs @@ -97,6 +97,17 @@ public static class StartupValidator value => string.IsNullOrWhiteSpace(value) || (Enum.TryParse(value, ignoreCase: true, out var provider) && Enum.IsDefined(provider)), + "must be 'Sqlite' or 'SqlServer' when set") + // The central shared secret store provider (Secrets:Store:Provider — a fleet that + // ships secrets to sites on every artifact deploy needs a clustered central pair to + // see the same secret set). Not required — an unset value defaults to Sqlite, + // per-node, mirroring the inbound API-key store default above — but when it IS set, + // it must be one of the two legal values so a typo fails fast at boot instead of + // silently keeping the per-node SQLite store it looks like it opted out of. + .Require("Secrets:Store:Provider", + value => string.IsNullOrWhiteSpace(value) + || string.Equals(value, "Sqlite", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "SqlServer", StringComparison.OrdinalIgnoreCase), "must be 'Sqlite' or 'SqlServer' when set")) // SeedNodes count (unconditional, after SiteId). .Require("ScadaBridge:Cluster:SeedNodes", @@ -226,20 +237,33 @@ public static class StartupValidator var role = configuration.GetSection("ScadaBridge:Node")["Role"]; var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get>(); var provider = InboundApiKeyStoreOptions.ReadProvider(configuration); + var secretsStoreProvider = configuration["Secrets:Store:Provider"]; + var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 }; - if (role == "Central" - && seedNodes is { Count: >= 2 } - && provider == InboundApiKeyStoreProvider.Sqlite) + var warnings = new List(); + + if (isClusteredCentral && provider == InboundApiKeyStoreProvider.Sqlite) { - return new[] - { - $"Inbound API keys use the per-node SQLite store on a {seedNodes.Count}-node central " + warnings.Add( + $"Inbound API keys use the per-node SQLite store on a {seedNodes!.Count}-node central " + "cluster; a failover will reject every inbound client until keys are re-issued. Set " - + "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)." - }; + + "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)."); } - return Array.Empty(); + // Secrets:Store:Provider defaults to Sqlite (per-node) when unset, mirroring the inbound + // API-key store default above — so this fires both when the key is absent and when it is + // explicitly "Sqlite". A fleet that ships secrets to sites on every artifact deploy needs + // both central nodes to deploy the SAME secret set; a per-node store means each node has + // silently drifted the moment anyone edits a secret on only one of them. + if (isClusteredCentral + && !string.Equals(secretsStoreProvider, "SqlServer", StringComparison.OrdinalIgnoreCase)) + { + warnings.Add( + "Secrets use a per-node SQLite store on a clustered central — each node would " + + "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer"); + } + + return warnings; } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json index 7fcb8ace..13c3bc40 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json @@ -14,6 +14,10 @@ "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, "RunMigrationsOnStartup": true, "ResolveCacheTtl": "00:00:30", + "Store": { + "_comment": "CENTRAL ONLY (a Site always ignores this and keeps its local SQLite store). Independent of Replication above and the gRPC hub - no hub is stood up either way. 'Sqlite' (default) is per-node: on a clustered central, each node's secrets can silently drift the moment anyone edits one on only a single node, which then ships an inconsistent secret set to sites on artifact deploy - StartupValidator warns on a >=2-seed central left on Sqlite. 'SqlServer' points BOTH central nodes at ONE shared SQL-Server database directly (Secrets:SqlServer:ConnectionString, same fail-closed blank/${secret:} rules as Grpc mode's shared store), so the pair can never diverge without standing up the gRPC hub.", + "Provider": "Sqlite" + }, "Replication": { "_comment": "Default-OFF pin: no deployment enables clustered secret replication by shipping this file. Mode selects the transport when Enabled is true - 'SqlServer' (the default, kept so an existing config that sets only Enabled behaves exactly as before) or 'Grpc' (the PRODUCTION topology, scadaproj#3: central hosts a pull-only hub under Secrets:GrpcHub and sites sweep it, so a site never needs a connection string to central's database). Every node in either topology must carry the SAME ZB_SECRETS_MASTER_KEY - only ciphertext crosses the wire, so a node with a different KEK fails closed on resolve with a kek_id mismatch that reads like corruption but is a deployment error.", "Enabled": false, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs index 25157dae..3a1b2213 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs @@ -196,6 +196,77 @@ public class SecretsReplicationWiringTests Assert.False(SecretsRegistration.UsesGrpcHub(config)); } + // --------------------------------------------------------------------------------------- + // Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store + // directly, without standing up the gRPC hub (task 5). Independent of Replication/Mode. + // --------------------------------------------------------------------------------------- + + [Fact] + public void Central_StoreProviderSqlServer_UsesSharedSqlStore_WithoutHub() + { + IConfiguration config = BuildConfig( + ("Secrets:Store:Provider", "SqlServer"), + ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); + + using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central); + + Assert.IsType(provider.GetRequiredService()); + + // No hub: neither the fail-closed bearer interceptor nor the follower sweep is wired, + // and UsesGrpcHub — the single predicate MapScadaBridgeSecretsHub also checks — is false. + Assert.Null(provider.GetService()); + Assert.DoesNotContain(provider.GetServices(), IsGrpcHubSweepService); + Assert.False(SecretsRegistration.UsesGrpcHub(config)); + } + + [Fact] + public void Central_StoreProviderSqlServer_BlankConnectionString_Throws() + { + var services = new ServiceCollection(); + services.AddLogging(); + + IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer")); + + var ex = Assert.Throws( + () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central)); + + // Same helper, same message style as the Grpc-mode central check. + Assert.Contains( + SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Central_StoreProviderSqlServer_SecretReferenceConnectionString_Throws() + { + var services = new ServiceCollection(); + services.AddLogging(); + + IConfiguration config = BuildConfig( + ("Secrets:Store:Provider", "SqlServer"), + ("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}")); + + var ex = Assert.Throws( + () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central)); + + Assert.Contains( + SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal); + Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Site_IgnoresStoreProvider() + { + // A Site node ignores Secrets:Store:Provider entirely and always keeps its local SQLite + // store — even with the value set to SqlServer and no connection string configured at + // all, which would fail closed on Central. + IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer")); + + using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site); + + Assert.IsType(provider.GetRequiredService()); + } + // --------------------------------------------------------------------------------------- // SQL-Server mode — regression pins. Behaviour must be byte-for-byte what it was before the // mode key existed, both when the mode is left unset and when it is named explicitly. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs index a89216d3..3d5c3527 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -699,7 +699,11 @@ public class StartupValidatorTests { // Design 2026-09-02: the inbound API key store is per-node SQLite by default, so a // 2+-node central cluster is one failover away from rejecting every inbound client. - var config = BuildConfig(ValidCentralConfig()); + // Secrets:Store:Provider is set to SqlServer here to isolate this warning from the + // independent one added for task 5 (ClusteredCentral_SqliteSecretStore_Warns below). + var values = ValidCentralConfig(); + values["Secrets:Store:Provider"] = "SqlServer"; + var config = BuildConfig(values); var warnings = StartupValidator.CollectWarnings(config); @@ -714,6 +718,7 @@ public class StartupValidatorTests { var values = ValidCentralConfig(); values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "sqlite"; + values["Secrets:Store:Provider"] = "SqlServer"; var config = BuildConfig(values); var warnings = StartupValidator.CollectWarnings(config); @@ -726,6 +731,39 @@ public class StartupValidatorTests { var values = ValidCentralConfig(); values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer"; + values["Secrets:Store:Provider"] = "SqlServer"; + var config = BuildConfig(values); + + var warnings = StartupValidator.CollectWarnings(config); + + Assert.Empty(warnings); + } + + [Fact] + public void ClusteredCentral_SqliteSecretStore_Warns() + { + // Task 5: a clustered central left on the per-node SQLite secret store silently drifts + // the moment anyone edits a secret on only one node, then ships an inconsistent set to + // sites. ApiKeyStore:Provider is set to SqlServer here to isolate this warning from the + // pre-existing one above. + var values = ValidCentralConfig(); + values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer"; + var config = BuildConfig(values); + + var warnings = StartupValidator.CollectWarnings(config); + + var warning = Assert.Single(warnings); + Assert.Contains("clustered central", warning); + Assert.Contains("SQLite", warning); + Assert.Contains("Secrets:Store:Provider=SqlServer", warning); + } + + [Fact] + public void ClusteredCentral_SqlServerSecretStore_Silent() + { + var values = ValidCentralConfig(); + values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer"; + values["Secrets:Store:Provider"] = "SqlServer"; var config = BuildConfig(values); var warnings = StartupValidator.CollectWarnings(config); @@ -779,6 +817,28 @@ public class StartupValidatorTests Assert.Null(ex); } + [Fact] + public void UnknownSecretStoreProvider_Fails() + { + var values = ValidCentralConfig(); + values["Secrets:Store:Provider"] = "Postgres"; + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("Secrets:Store:Provider", ex.Message); + } + + [Fact] + public void Validate_AcceptsSecretStoreProviderCaseInsensitively() + { + var values = ValidCentralConfig(); + values["Secrets:Store:Provider"] = "sqlserver"; + var config = BuildConfig(values); + + var ex = Record.Exception(() => StartupValidator.Validate(config)); + Assert.Null(ex); + } + [Fact] public void MultipleErrors_AllReported() {