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:
+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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user