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
@@ -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; }
}