diff --git a/ZB.MOM.WW.Secrets/CHANGELOG.md b/ZB.MOM.WW.Secrets/CHANGELOG.md index 54c36e8..a0bcf6d 100644 --- a/ZB.MOM.WW.Secrets/CHANGELOG.md +++ b/ZB.MOM.WW.Secrets/CHANGELOG.md @@ -18,6 +18,14 @@ Fixes six scadaproj#15 code-review findings (`code-reviews/Secrets/findings.md`) the SQLite store (shared-store mode) or the `NoOpSecretReplicator` (hub and Akka modes). They now replace those registrations after the core call; a later `AddZbSecrets` only `TryAdd`s, so either order yields the same graph, and exactly one `ISecretStore` is registered. +- **The SQL-Server and Akka extensions take a `contentRootPath`** (scadaproj#31, Secrets-002), and the + store-path validator's content root is now **the last explicitly supplied one** across all + `AddZbSecrets` calls (a `null` root never erases an earlier explicit one). Before, the first call to + register the validator captured the root, so a package's internal 3-arg call that ran first silently + dropped the application's 4-arg root and the pre-host under-content-root guard was skipped — in hub + mode no registration order applied it. New trailing optional parameter on + `AddZbSecretsSqlServerStore`, `AddZbSecretsSqlServerReplication` and `AddZbSecretsAkkaReplication` + (source-compatible; callers recompile). Pass it from every pre-host composition. ## 0.6.5 — unreleased (NOT published to the feed) diff --git a/ZB.MOM.WW.Secrets/README.md b/ZB.MOM.WW.Secrets/README.md index cc36558..d4a023e 100644 --- a/ZB.MOM.WW.Secrets/README.md +++ b/ZB.MOM.WW.Secrets/README.md @@ -182,7 +182,10 @@ node can always reach a shared database, because you pay real distributed-system Since `0.6.6` all three extensions are **order-independent** with respect to `AddZbSecrets`: call it before or after them (or not at all — each calls it internally) and the same store, migrator and -replicator are bound. +replicator are bound. Each also takes an optional `contentRootPath`, forwarded to `AddZbSecrets`: +pass it from any **pre-host** composition (e.g. the container a `${secret:}` expander builds), just as +you would the 4-argument `AddZbSecrets`, so the store-path guard can reject a store inside the content +root. The last explicitly supplied root wins across calls. ### What replication costs you (2 and 3) diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs index c0aa260..ab3db92 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs @@ -46,12 +46,19 @@ public static class AkkaSecretsServiceCollectionExtensions /// The application configuration. /// Configuration section holding the core secrets options. /// Configuration section holding the replication options. + /// + /// The application content root, forwarded to AddZbSecrets so the store-path guard can + /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a + /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as + /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls. + /// /// The same instance, for chaining. public static IServiceCollection AddZbSecretsAkkaReplication( this IServiceCollection services, IConfiguration config, string secretsSectionPath = "Secrets", - string replicationSectionPath = "Secrets:Replication") + string replicationSectionPath = "Secrets:Replication", + string? contentRootPath = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); @@ -83,7 +90,7 @@ public static class AkkaSecretsServiceCollectionExtensions sp.GetRequiredService>().Value)); // The local store + migrator come from AddZbSecrets (SQLite by default). - services.AddZbSecrets(config, secretsSectionPath); + services.AddZbSecrets(config, secretsSectionPath, contentRootPath); // REPLACED after the core call rather than TryAdd-ed before it (scadaproj#30). AddZbSecrets // does TryAddSingleton(); a TryAdd here only won when diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs index 4d01243..82aec6d 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs @@ -39,16 +39,23 @@ public static class SqlServerSecretsServiceCollectionExtensions /// The application configuration. /// Configuration section holding the core secrets options. /// Configuration section holding the SQL-Server options. + /// + /// The application content root, forwarded to AddZbSecrets so the store-path guard can + /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a + /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as + /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls. + /// /// The same instance, for chaining. public static IServiceCollection AddZbSecretsSqlServerStore( this IServiceCollection services, IConfiguration config, string secretsSectionPath = "Secrets", - string sqlServerSectionPath = "Secrets:SqlServer") + string sqlServerSectionPath = "Secrets:SqlServer", + string? contentRootPath = null) { RegisterSqlServerCore(services, config, sqlServerSectionPath); - services.AddZbSecrets(config, secretsSectionPath); + services.AddZbSecrets(config, secretsSectionPath, contentRootPath); // REPLACE, not TryAdd (scadaproj#30): the application may already have called AddZbSecrets, // whose SQLite ISecretStore / ISecretsStoreMigrator a TryAdd here would silently leave in @@ -81,19 +88,26 @@ public static class SqlServerSecretsServiceCollectionExtensions /// The application configuration. /// Configuration section holding the core secrets options. /// Configuration section holding the SQL-Server options. + /// + /// The application content root, forwarded to AddZbSecrets so the store-path guard can + /// reject a Secrets:SqlitePath inside it. Pass it from every pre-host composition (a + /// container with no IHostEnvironment, such as a ${secret:} expander's), exactly as + /// for the 4-argument AddZbSecrets. The last explicitly supplied root wins across calls. + /// /// The same instance, for chaining. public static IServiceCollection AddZbSecretsSqlServerReplication( this IServiceCollection services, IConfiguration config, string secretsSectionPath = "Secrets", - string sqlServerSectionPath = "Secrets:SqlServer") + string sqlServerSectionPath = "Secrets:SqlServer", + string? contentRootPath = null) { RegisterSqlServerCore(services, config, sqlServerSectionPath); services.TryAddSingleton(sp => sp.GetRequiredService()); // The local store + its migrator come from AddZbSecrets (SQLite by default). - services.AddZbSecrets(config, secretsSectionPath); + services.AddZbSecrets(config, secretsSectionPath, contentRootPath); // REPLACE, not TryAdd (scadaproj#30): if the application called AddZbSecrets first, its // NoOpSecretReplicator is already registered and a TryAdd would silently lose to it — live diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs index 3451622..70a83f6 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs @@ -69,16 +69,29 @@ public static class SecretsServiceCollectionExtensions // composed without a host, which the CLI legitimately does. // // But AddZbSecrets is called more than once in real compositions (a replication package - // calls it internally after the application already has), so registration has to be + // calls it internally before or after the application does), so registration has to be // idempotent — and TryAddEnumerable cannot do it here: it de-duplicates on implementation // TYPE, which a factory descriptor does not carry, so it rejects the second call outright // rather than skipping it. Guarding on the concrete registration gives both properties. + // + // The content root is NOT captured by whichever call registers the validator (scadaproj#31). + // It lives in one shared holder that every call may set, and the LAST explicitly supplied + // root wins; a call that passes null leaves an earlier explicit root alone. Before 0.6.6 the + // first call captured it, so a package's internal 3-arg call that ran first discarded the + // application's later 4-arg root, and the pre-host store-path guard was silently skipped. + SecretsContentRoot holder = GetOrAddContentRootHolder(services); + if (contentRootPath is not null) + { + holder.Path = contentRootPath; + } + if (services.All(d => d.ServiceType != typeof(SecretsOptionsValidator))) { // An explicitly supplied content root wins; otherwise fall back to IHostEnvironment, // which is present in a real host and absent in a pre-host container. services.AddSingleton(sp => new SecretsOptionsValidator( - contentRootPath ?? sp.GetService()?.ContentRootPath)); + sp.GetRequiredService().Path + ?? sp.GetService()?.ContentRootPath)); services.AddSingleton>( sp => sp.GetRequiredService()); } @@ -153,4 +166,30 @@ public static class SecretsServiceCollectionExtensions return services; } + + // The one content-root holder for this service collection, registered as an INSTANCE so every + // AddZbSecrets call (and the validator factory) sees the same object. + private static SecretsContentRoot GetOrAddContentRootHolder(IServiceCollection services) + { + if (services.FirstOrDefault(d => d.ServiceType == typeof(SecretsContentRoot)) + ?.ImplementationInstance is SecretsContentRoot existing) + { + return existing; + } + + var holder = new SecretsContentRoot(); + services.AddSingleton(holder); + return holder; + } +} + +/// +/// The content root the store-path validator checks against, as explicitly supplied to +/// AddZbSecrets (or a replication package's contentRootPath argument). The last +/// explicit value wins; means "fall back to ". +/// +internal sealed class SecretsContentRoot +{ + /// The explicitly supplied content root, or when none was. + public string? Path { get; set; } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs index dbd812d..15f5590 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Replication; @@ -108,4 +109,26 @@ public sealed class AddZbSecretsAkkaReplicationTests Assert.Contains(services, d => d.ServiceType == typeof(ISecretsStoreMigrator)); } + + [Fact] + public void The_content_root_argument_reaches_the_store_path_guard() + { + // scadaproj#31: the Akka node's local SQLite store is migrated by a pre-host expander too, + // so the extension must be able to hand the guard a content root (no IHostEnvironment here). + string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-akka-root-{Guid.NewGuid():N}"); + IConfigurationRoot config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = Path.Combine(contentRoot, "s.db"), + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddZbSecretsAkkaReplication(config, contentRootPath: contentRoot); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredService>().Value); + } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs new file mode 100644 index 0000000..891a6b3 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection; + +namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection; + +/// +/// scadaproj#31 (Secrets-002): in hub-replication mode the node's local SQLite store is migrated by +/// a pre-host ${secret:} expander, so the under-content-root guard must be reachable — either +/// through the extension's own contentRootPath or through a 4-arg AddZbSecrets in +/// either order. Before the fix no registration order applied it: the extension's internal 3-arg +/// call captured a null root first, and the core-first order lost the replicator (scadaproj#30). +/// +/// +/// Every container here is pre-host — no IHostEnvironment — which is the case the guard +/// exists for. The store file is asserted absent BEFORE the exception is asserted, so a regression +/// that stops the throw but still creates the database fails on the artifact. +/// +public sealed class ContentRootTests : IDisposable +{ + private readonly string _contentRoot = + Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-root-{Guid.NewGuid():N}"); + + public ContentRootTests() => Directory.CreateDirectory(_contentRoot); + + public enum Composition + { + ExtensionArgument, + ExtensionThenCore, + CoreThenExtension, + } + + private IConfiguration Config(string sqlitePath) => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = sqlitePath, + ["Secrets:RunMigrationsOnStartup"] = "false", + ["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;", + }).Build(); + + private ServiceProvider Compose(Composition composition, IConfiguration config) + { + var services = new ServiceCollection(); + services.AddLogging(); + switch (composition) + { + case Composition.ExtensionArgument: + services.AddZbSecretsSqlServerReplication(config, contentRootPath: _contentRoot); + break; + case Composition.ExtensionThenCore: + services.AddZbSecretsSqlServerReplication(config); + services.AddZbSecrets(config, "Secrets", _contentRoot); + break; + case Composition.CoreThenExtension: + services.AddZbSecrets(config, "Secrets", _contentRoot); + services.AddZbSecretsSqlServerReplication(config); + break; + } + + return services.BuildServiceProvider(); + } + + [Theory] + [InlineData(Composition.ExtensionArgument)] + [InlineData(Composition.ExtensionThenCore)] + [InlineData(Composition.CoreThenExtension)] + public async Task Hub_mode_refuses_a_local_store_inside_the_content_root(Composition composition) + { + string dbPath = Path.Combine(_contentRoot, "data", "s.db"); + using ServiceProvider provider = Compose(composition, Config(dbPath)); + + Exception? thrown = await Record.ExceptionAsync( + () => provider.GetRequiredService().MigrateAsync(default)); + + Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file"); + Assert.IsType(thrown); + } + + [Theory] + [InlineData(Composition.ExtensionArgument)] + [InlineData(Composition.ExtensionThenCore)] + [InlineData(Composition.CoreThenExtension)] + public void Hub_mode_accepts_a_store_outside_the_content_root(Composition composition) + { + // Positive control: the same compositions with a legal path resolve cleanly, so the refusal + // above is the content-root rule and not some other failure of the composition. + string dbPath = Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-outside-{Guid.NewGuid():N}.db"); + using ServiceProvider provider = Compose(composition, Config(dbPath)); + + Assert.Equal(dbPath, provider.GetRequiredService>().Value.SqlitePath); + } + + [Fact] + public void Shared_store_mode_forwards_the_content_root_too() + { + string dbPath = Path.Combine(_contentRoot, "s.db"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddZbSecretsSqlServerStore(Config(dbPath), contentRootPath: _contentRoot); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredService>().Value); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(_contentRoot, recursive: true); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs index b5d9126..6ad7433 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs @@ -313,6 +313,53 @@ public sealed class SecretsStorePathRulesTests } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_last_explicit_content_root_wins_whichever_call_registered_the_validator( + bool threeArgCallFirst) + { + // REGRESSION (scadaproj#31). The validator used to capture the content root of whichever + // AddZbSecrets call registered it FIRST. A replication package calls the 3-arg overload + // internally; when that ran before the application's 4-arg call, the explicit root was + // discarded and the pre-host guard silently skipped — the 0.6.0 hole, reopened. A null root + // must also never erase an explicit one supplied earlier. + string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-order-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + string dbPath = Path.Combine(contentRoot, "store.db"); + + try + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = dbPath }) + .Build(); + + var services = new ServiceCollection(); + if (threeArgCallFirst) + { + services.AddZbSecrets(config, "Secrets"); + services.AddZbSecrets(config, "Secrets", contentRoot); + } + else + { + services.AddZbSecrets(config, "Secrets", contentRoot); + services.AddZbSecrets(config, "Secrets"); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + Exception? thrown = await Record.ExceptionAsync( + () => provider.GetRequiredService().MigrateAsync(default)); + + Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file"); + Assert.IsType(thrown); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(contentRoot, recursive: true); + } + } + [Fact] public async Task Without_a_content_root_the_pre_host_container_still_cannot_be_told_it_is_fine() { diff --git a/code-reviews/README.md b/code-reviews/README.md index 18619d6..2d30401 100644 --- a/code-reviews/README.md +++ b/code-reviews/README.md @@ -40,7 +40,7 @@ code-reviews/ ## Summary -10 of 10 libraries reviewed. 29 pending findings across all libraries. +10 of 10 libraries reviewed. 28 pending findings across all libraries. Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health, Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also @@ -50,9 +50,9 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low). |----------|---------------| | Critical | 0 | | High | 0 | -| Medium | 10 | +| Medium | 9 | | Low | 19 | -| **Total** | **29** | +| **Total** | **28** | ## Library Status @@ -65,7 +65,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low). | [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 7 | | [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/3 | 7 | 7 | | [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 | -| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/5/13 | 18 | 19 | +| [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/4/13 | 17 | 19 | | [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 11 | | [Theme](Theme/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 | @@ -84,7 +84,7 @@ _None open._ _None open._ -### Medium (10) +### Medium (9) | ID | Library | Title | |----|---------|-------| @@ -93,7 +93,6 @@ _None open._ | LocalDb-002 | [LocalDb](LocalDb/findings.md) | Snapshot streams tables by name and rows by `pk_json` text, so FK children land before parents and are dead-lettered for good | | LocalDb-003 | [LocalDb](LocalDb/findings.md) | `INSERT OR REPLACE` apply silently deletes a different row on a secondary `UNIQUE` conflict, so the pair diverges | | LocalDb-004 | [LocalDb](LocalDb/findings.md) | `FailClosedOnDrift` is bypassed on the snapshot path, so a future-stamped snapshot row moves the local HLC forward permanently | -| Secrets-002 | [Secrets](Secrets/findings.md) | The SQL-Server extensions cannot pass the content root, so the pre-host store-path hole reopens in hub mode | | Secrets-003 | [Secrets](Secrets/findings.md) | `${secret:}` expansion is undone by configuration reload | | Secrets-010 | [Secrets](Secrets/findings.md) | Akka replication actor is created only when `ISecretStore` is first resolved; a node that never resolves it never replicates | | Secrets-013 | [Secrets](Secrets/findings.md) | UI add/rotate/delete: if the audit write throws after the store write succeeded, the change is reported and audited as a failure and the resolver cache is not evicted | diff --git a/code-reviews/Secrets/findings.md b/code-reviews/Secrets/findings.md index b3fbf25..42096c1 100644 --- a/code-reviews/Secrets/findings.md +++ b/code-reviews/Secrets/findings.md @@ -10,7 +10,7 @@ | Last reviewed | 2026-11-09 | | Reviewer | Claude (scadaproj#15) | | Commit reviewed | `91fb2b4` | -| Open findings | 18 | +| Open findings | 17 | ## Summary @@ -98,7 +98,7 @@ Resolved 2026-11-09 in the commit tagged `(scadaproj#30)` (Secrets `0.6.6`, unpu | Severity | Medium | | Priority | P2 | | Category | Security & secret handling | -| Status | Open | +| Status | Resolved | | Location | `ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/SqlServerSecretsServiceCollectionExtensions.cs:94` (with `ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs:76`) | **Description** @@ -119,7 +119,7 @@ Add a `string? contentRootPath` parameter to both SQL-Server extensions and forw **Resolution** -_Unresolved._ +Resolved 2026-11-09 in the commit tagged `(scadaproj#31)` (Secrets `0.6.6`, unpublished). Both recommendations done: (1) `AddZbSecretsSqlServerStore`, `AddZbSecretsSqlServerReplication` and `AddZbSecretsAkkaReplication` gained a trailing optional `string? contentRootPath = null`, forwarded to the 4-arg `AddZbSecrets`; (2) the content root moved out of the first-registering call's closure into one shared `SecretsContentRoot` instance holder in the service collection, which every `AddZbSecrets` call may set — the last explicit root wins and a `null` never erases an earlier one; the validator factory still falls back to `IHostEnvironment` only when no explicit root was given. Regression tests: `Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests` (hub mode refuses an in-root store via the extension argument, extension-then-core and core-then-extension, artifact asserted before the throw, with an outside-root positive control; shared-store mode forwards too), `SecretsStorePathRulesTests.The_last_explicit_content_root_wins_whichever_call_registered_the_validator`, and `AddZbSecretsAkkaReplicationTests.The_content_root_argument_reaches_the_store_path_guard`. Without the holder change the extension-then-core and 3-arg-first cases create the store and do not throw. ### Secrets-003 — `${secret:}` expansion is undone by configuration reload