From 8e12f994323198d0b0e843ab8801b16964a50ea5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Jul 2026 11:10:48 -0400 Subject: [PATCH] feat(secrets): opt-in SQL-Server hub replication for the host secret store Routes both host-container secret registrations (central role in Program.cs, site role in SiteServiceRegistration) through a new SecretsRegistration composition seam that optionally enables ZB.MOM.WW.Secrets.Replicator.SqlServer hub-replication mode: each node keeps a LOCAL store that syncs bidirectionally with a shared central SQL hub, so a site cluster keeps resolving secrets straight through a WAN outage to central. OPT-IN GATE (the load-bearing part). AddZbSecretsSqlServerReplication validates its options EAGERLY at registration time, so wiring it unconditionally would make ScadaBridge fail to START anywhere Secrets:SqlServer:ConnectionString is unset -- every dev box, every docker node, every existing deployment. The SQL-Server package is therefore only touched when BOTH Secrets:Replication: Enabled is true AND a non-blank connection string is present; otherwise the registration is byte-identical to the previous plain AddZbSecrets call. Enabled-without-a-connection-string falls back to local-only and logs a warning rather than failing the node or silently looking healthy. BOOTSTRAP CYCLE. The hub connection string is itself a secret and can never come from the hub -- a node cannot read the hub to learn how to reach the hub. It must arrive from outside the replicated set: an environment variable, or a ${secret:} reference seeded in that node's own LOCAL store. appsettings.json therefore ships ConnectionString empty with a _comment saying so (leaf keys starting with '_' are skipped by the reference expander, verified in SecretReferenceExpander). No real connection string is committed. The pre-host ${secret:} expander in Program.cs is deliberately left on a plain local SQLite store for the same reason. Per-node docker appsettings are intentionally NOT modified -- replication stays off there for now. Tests written before the wiring and confirmed red first (2 failed / 4 passed), green after (6/6). They BUILD a container and RESOLVE from it rather than asserting over ServiceDescriptors: a decorator can look correct as a descriptor list and still throw on first resolve because the undecorated concrete store it depends on is missing -- that exact defect shipped once in this library with all descriptor-level tests green, so the undecorated SqliteSecretStore gets its own resolution test. Verified: Host.Tests 285/285 pass; full build (all projects except the pre-existing AngleSharp NU1902 CentralUI.Tests restore break) 0 warnings, 0 errors. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts --- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 6 +- .../SecretsRegistration.cs | 92 +++++++++++ .../SiteServiceRegistration.cs | 6 +- .../appsettings.json | 10 +- .../SecretsReplicationWiringTests.cs | 150 ++++++++++++++++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index fdaf18d0..ddda2787 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -179,7 +179,11 @@ try .GetValue(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment))); builder.Services.AddSecurity(disableLogin); builder.Services.AddCentralUI(); - builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); + // Local SQLite store by default; a local store replicating against a shared SQL-Server hub + // only when Secrets:Replication:Enabled is true AND a hub connection string is present. + // See SecretsRegistration for why the gate exists (eager options validation) and why the + // hub connection string can never itself come from the hub. + builder.Services.AddScadaBridgeSecrets(builder.Configuration); // Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal // (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS // these two policies via Configure — it composes additively with diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs new file mode 100644 index 00000000..9b9f75d6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs @@ -0,0 +1,92 @@ +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection; + +namespace ZB.MOM.WW.ScadaBridge.Host; + +/// +/// Single composition-root entry point for the host container's secret store, shared by the +/// central-role registrations in Program.cs and the site-role registrations in +/// so both roles wire secrets identically. +/// +/// +/// +/// Clustered replication is opt-in and off by default. ScadaBridge is hub-and-spoke — one +/// central cluster plus N separate site clusters — and hub mode gives each node a LOCAL store that +/// syncs bidirectionally with a shared central SQL hub, so a site keeps resolving secrets straight +/// through a WAN outage. +/// +/// +/// The gate is not a style preference. AddZbSecretsSqlServerReplication validates its options +/// EAGERLY at registration time, so calling it unconditionally would throw at startup on every node +/// where Secrets:SqlServer:ConnectionString is unset — which is every dev box, every docker +/// node and every existing deployment. Both the explicit Secrets:Replication:Enabled flag and +/// a non-blank connection string are therefore required before the SQL-Server package is touched at +/// all; otherwise this is byte-for-byte the plain local-SQLite registration it has always been. +/// +/// +/// Bootstrap constraint. The hub connection string is itself a secret, and it can never come +/// from the hub — a node cannot read the hub to learn how to reach the hub. It must arrive from +/// outside the replicated set: an environment variable, or a ${secret:} reference that is +/// seeded in that node's own LOCAL store (the pre-host expander in Program.cs runs against a +/// plain local SQLite store before the host container exists, so such a reference does resolve). +/// That expander is deliberately left un-replicated for exactly this reason. +/// +/// +public static class SecretsRegistration +{ + /// Configuration section holding the core secrets options. + public const string SecretsSectionPath = "Secrets"; + + /// Configuration section holding the SQL-Server hub options. + public const string SqlServerSectionPath = "Secrets:SqlServer"; + + /// Configuration key gating clustered replication. Absent or false = off. + public const string ReplicationEnabledKey = "Secrets:Replication:Enabled"; + + /// Configuration key holding the shared SQL-Server hub connection string. + public const string HubConnectionStringKey = "Secrets:SqlServer:ConnectionString"; + + /// + /// Registers the host container's secret store: a plain local SQLite store by default, or a + /// local store replicating against a shared SQL-Server hub when replication is explicitly + /// enabled and a hub connection string is present. + /// + /// The service collection to register into. + /// Application configuration for options binding. + /// The same instance, for chaining. + public static IServiceCollection AddScadaBridgeSecrets( + this IServiceCollection services, + IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + + var enabled = config.GetValue(ReplicationEnabledKey); + var connectionString = config[HubConnectionStringKey]; + + if (enabled && !string.IsNullOrWhiteSpace(connectionString)) + { + // Registers the validated hub options, decorates ISecretStore with + // ReplicatingSecretStore, and adds the hub-schema migration + bidirectional sweep + // hosted services. Calls AddZbSecrets internally — do NOT also call it here. + return services.AddZbSecretsSqlServerReplication( + config, SecretsSectionPath, SqlServerSectionPath); + } + + if (enabled) + { + // Asked for replication but gave nothing to replicate against. Falling back to the + // local-only store keeps the node up, but it is silently NOT participating in the + // cluster, so say so loudly rather than letting it look healthy. + Serilog.Log.Warning( + "{Key} is true but {ConnKey} is empty — clustered secret replication is DISABLED " + + "and this node's secrets are local-only. The hub connection string cannot come " + + "from the hub itself; supply it via environment variable or seed it in this " + + "node's local store.", + ReplicationEnabledKey, + HubConnectionStringKey); + } + + return services.AddZbSecrets(config, SecretsSectionPath); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index a2bde125..6d282c33 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -50,7 +50,11 @@ public static class SiteServiceRegistration var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db"; services.AddSiteRuntime($"Data Source={siteDbPath}"); services.AddDataConnectionLayer(); - services.AddZbSecrets(config, "Secrets"); + // Local SQLite store by default; a local store replicating against a shared SQL-Server hub + // only when Secrets:Replication:Enabled is true AND a hub connection string is present. + // Hub mode matters most here: a site node keeps resolving secrets from its local store + // straight through a WAN outage to central. See SecretsRegistration. + services.AddScadaBridgeSecrets(config); // Adapter that surfaces the site id to // StoreAndForwardService through DI WITHOUT introducing a // StoreAndForward → HealthMonitoring project-reference cycle. Must be diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json index d9041375..ccde2cb1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json @@ -13,7 +13,15 @@ "SqlitePath": "scadabridge-secrets.db", "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, "RunMigrationsOnStartup": true, - "ResolveCacheTtl": "00:00:30" + "ResolveCacheTtl": "00:00:30", + "Replication": { "Enabled": false }, + "SqlServer": { + "ConnectionString": "", + "SchemaName": "zbsecrets", + "SyncInterval": "00:00:30", + "SyncOnStartup": true, + "_comment": "Hub connstr must be seeded in the LOCAL store (or supplied via env) - it cannot come from the hub itself. Enable via Secrets:Replication:Enabled." + } }, "Serilog": { "Using": [ diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs new file mode 100644 index 00000000..89348a4a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Replication; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// DI-resolution tests for . +/// +/// These deliberately BUILD a container and RESOLVE out of it rather than asserting over +/// s. A decorator registration can look perfectly correct as a +/// descriptor list and still throw on the first resolve because the undecorated concrete store it +/// depends on is missing — that exact gap shipped once in this library, with every descriptor-level +/// unit test green. Resolution is the only assertion that catches it. +/// +/// +/// Registration must not need a reachable SQL Server: the SQL-Server package validates its options +/// eagerly, but validation only checks that the connection string is non-empty and the schema name +/// is a legal identifier. Nothing connects until a sweep or a write runs, and these tests do +/// neither — so the dummy connection string below never resolves to a real host. +/// +/// +public class SecretsReplicationWiringTests +{ + // Syntactically valid, deliberately unreachable. Never connected to by these tests. + private const string DummyConnectionString = + "Server=unused;Database=x;Integrated Security=true;"; + + private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides) + { + var settings = new Dictionary + { + ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-test.db"), + ["Secrets:MasterKey:Source"] = "Environment", + ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY", + ["Secrets:RunMigrationsOnStartup"] = "false", + }; + + foreach ((string key, string value) in overrides) + { + settings[key] = value; + } + + return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + } + + private static ServiceProvider BuildProvider(IConfiguration config) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddScadaBridgeSecrets(config); + return services.BuildServiceProvider(validateScopes: true); + } + + /// + /// Any hosted service contributed by the SQL-Server replicator package. Asserted by assembly + /// rather than by type because SqlServerHubMigrationHostedService is internal to it. + /// + private static bool IsReplicatorHostedService(ServiceDescriptor descriptor) => + descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType?.Assembly.GetName().Name + == "ZB.MOM.WW.Secrets.Replicator.SqlServer"; + + [Fact] + public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore() + { + using ServiceProvider provider = BuildProvider(BuildConfig()); + + var store = provider.GetRequiredService(); + + Assert.IsType(store); + Assert.IsNotType(store); + } + + [Fact] + public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddScadaBridgeSecrets(BuildConfig()); + + Assert.DoesNotContain(services, IsReplicatorHostedService); + } + + [Fact] + public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore() + { + // The gate requires BOTH the flag and a connection string. Enabling the flag alone must not + // reach the SQL-Server package, whose options validation would throw at registration and + // take the whole node down at startup. + var services = new ServiceCollection(); + services.AddLogging(); + + IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true")); + + services.AddScadaBridgeSecrets(config); + + using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); + + Assert.IsType(provider.GetRequiredService()); + Assert.DoesNotContain(services, IsReplicatorHostedService); + } + + [Fact] + public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore() + { + IConfiguration config = BuildConfig( + ("Secrets:Replication:Enabled", "true"), + ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); + + using ServiceProvider provider = BuildProvider(config); + + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void ReplicationEnabledWithConnectionString_UndecoratedLocalStoreAlsoResolves() + { + // ReplicatingSecretStore is constructed from the CONCRETE SqliteSecretStore, not from + // ISecretStore (which is the decorator itself — that would recurse). If the concrete + // registration were ever dropped, the decorator would fail on first resolve. This is the + // exact defect that shipped once, so it gets its own test. + IConfiguration config = BuildConfig( + ("Secrets:Replication:Enabled", "true"), + ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); + + using ServiceProvider provider = BuildProvider(config); + + Assert.NotNull(provider.GetRequiredService()); + } + + [Fact] + public void ReplicationEnabledWithConnectionString_RegistersReplicatorHostedServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + + IConfiguration config = BuildConfig( + ("Secrets:Replication:Enabled", "true"), + ("Secrets:SqlServer:ConnectionString", DummyConnectionString)); + + services.AddScadaBridgeSecrets(config); + + // Both the hub-schema migration service and the bidirectional sweep. + Assert.Equal(2, services.Count(IsReplicatorHostedService)); + } +}