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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
+41
-2
@@ -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; }
|
||||
}
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user