fix(secrets): SqlServer/Akka extensions take contentRootPath; last explicit root wins (scadaproj#31)

The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path
validator captured the content root of whichever call registered it first.
In hub mode no registration order applied the pre-host under-content-root
guard: extension-first dropped the app's 4-arg root, core-first lost the
replicator (#30). That is the 0.6.0 store-path hole, reopened.

- AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication /
  AddZbSecretsAkkaReplication gain a trailing optional contentRootPath,
  forwarded to the 4-arg AddZbSecrets
- the root now lives in one SecretsContentRoot instance holder per service
  collection; the last explicit root wins, null never erases one; the
  IHostEnvironment fallback is unchanged
- tests: ContentRootTests (hub mode, three compositions, artifact asserted
  before the throw, positive control), last-explicit-wins theory in
  SecretsStorePathRulesTests, Akka forwarding test
- code-reviews: Secrets-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:50:07 -05:00
parent 015c556ced
commit c3b07d6766
10 changed files with 272 additions and 18 deletions
+8
View File
@@ -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)
+4 -1
View File
@@ -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)
@@ -46,12 +46,19 @@ public static class AkkaSecretsServiceCollectionExtensions
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="replicationSectionPath">Configuration section holding the replication options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
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<IOptions<AkkaSecretsReplicationOptions>>().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<ISecretReplicator, NoOpSecretReplicator>(); a TryAdd here only won when
@@ -39,16 +39,23 @@ public static class SqlServerSecretsServiceCollectionExtensions
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
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
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
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<ISecretReplicationHub>(sp => sp.GetRequiredService<SqlServerSecretStore>());
// 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
@@ -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<IHostEnvironment>()?.ContentRootPath));
sp.GetRequiredService<SecretsContentRoot>().Path
?? sp.GetService<IHostEnvironment>()?.ContentRootPath));
services.AddSingleton<IValidateOptions<SecretsOptions>>(
sp => sp.GetRequiredService<SecretsOptionsValidator>());
}
@@ -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;
}
}
/// <summary>
/// The content root the store-path validator checks against, as explicitly supplied to
/// <c>AddZbSecrets</c> (or a replication package's <c>contentRootPath</c> argument). The last
/// explicit value wins; <see langword="null"/> means "fall back to <see cref="IHostEnvironment"/>".
/// </summary>
internal sealed class SecretsContentRoot
{
/// <summary>The explicitly supplied content root, or <see langword="null"/> when none was.</summary>
public string? Path { get; set; }
}
@@ -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<string, string?>
{
["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<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<SecretsOptions>>().Value);
}
}
@@ -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;
/// <summary>
/// scadaproj#31 (Secrets-002): in hub-replication mode the node's local SQLite store is migrated by
/// a pre-host <c>${secret:}</c> expander, so the under-content-root guard must be reachable — either
/// through the extension's own <c>contentRootPath</c> or through a 4-arg <c>AddZbSecrets</c> 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).
/// </summary>
/// <remarks>
/// Every container here is pre-host — no <c>IHostEnvironment</c> — 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.
/// </remarks>
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<string, string?>
{
["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<ISecretsStoreMigrator>().MigrateAsync(default));
Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file");
Assert.IsType<OptionsValidationException>(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<IOptions<SecretsOptions>>().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<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<SecretsOptions>>().Value);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(_contentRoot, recursive: true);
}
}
@@ -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<string, string?> { ["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<SqliteSecretsStoreMigrator>().MigrateAsync(default));
Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file");
Assert.IsType<OptionsValidationException>(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()
{
+5 -6
View File
@@ -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 |
+3 -3
View File
@@ -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