fix(secrets): close the pre-host hole in the store-path guard (0.6.1)
Found by the HistorianGateway adoption of 0.6.0, which probed the rejected paths
instead of observing a clean boot — a clean boot being equally consistent with the
validator not being wired at all.
An application that expands ${secret:} references before building its host composes
this subsystem in a throwaway ServiceCollection with no IHostEnvironment in it, then
migrates the store from that container. SecretsOptionsValidator resolves the content
root from IHostEnvironment and skips the under-content-root rule when it is absent, so
pre-host an absolute in-content-root path passed the only rule still applying, the
migrator CREATED the database, and the boot failed a second later once the real host
registered IHostEnvironment and the rule finally fired.
The observable result was a rejected boot that still left an empty store, with -wal and
-shm siblings, at the rejected path — the same artifact that made the MxGateway outage
read as "the database is there, it's just empty", which is precisely what this guard
exists to prevent. A relative path was unaffected because the absolute-path rule fires
with or without a content root; that asymmetry is what exposed it.
The mistake was conflating "no IHostEnvironment registered" with "no content root
exists". New AddZbSecrets overload takes the content root explicitly; the 3-argument
overload is unchanged and still falls back to IHostEnvironment, so this is additive.
SecretsOptionsValidator gains a string? constructor alongside the IHostEnvironment? one.
Reproduced first in an isolated harness before changing anything: a bare host rejects
both bad paths leaving no artifacts, and a bare ServiceCollection with no
IHostEnvironment migrates an in-content-root path to disk. Both are now regression tests,
the second paired with a positive control asserting the old behaviour still holds when no
content root is supplied — so the test is about the explicit content root rather than
about paths in general.
Also documents an upgrade note that applies to 0.6.0: a store seeded under the old
relative default is orphaned rather than migrated, and nothing signals it.
This commit is contained in:
@@ -25,11 +25,35 @@ public sealed class SecretsOptionsValidator : OptionsValidatorBase<SecretsOption
|
||||
/// </summary>
|
||||
/// <param name="hostEnvironment">
|
||||
/// The host environment supplying the content root, or <see langword="null"/> when the secrets
|
||||
/// subsystem is composed outside a host (the CLI does this). With no content root the
|
||||
/// under-content-root rule is skipped; the absolute-path rule still applies.
|
||||
/// subsystem is composed outside a host. With no content root the under-content-root rule is
|
||||
/// skipped; the absolute-path rule still applies.
|
||||
/// </param>
|
||||
public SecretsOptionsValidator(IHostEnvironment? hostEnvironment) =>
|
||||
_contentRootPath = hostEnvironment?.ContentRootPath;
|
||||
public SecretsOptionsValidator(IHostEnvironment? hostEnvironment)
|
||||
: this(hostEnvironment?.ContentRootPath)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretsOptionsValidator"/> class with an
|
||||
/// explicit content root.
|
||||
/// </summary>
|
||||
/// <param name="contentRootPath">
|
||||
/// The application content root, or <see langword="null"/> when there genuinely is none.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// This overload exists because "no <see cref="IHostEnvironment"/> registered" and "no content
|
||||
/// root exists" are different things, and conflating them left a hole in the guard. An
|
||||
/// application that expands <c>${secret:}</c> references BEFORE building its host composes the
|
||||
/// subsystem in a throwaway <c>ServiceCollection</c> with no <see cref="IHostEnvironment"/> in
|
||||
/// it, then migrates the store from that container — so the content-root rule was skipped at
|
||||
/// exactly the moment the store file gets created. The boot then failed a second later, once
|
||||
/// the real host registered <see cref="IHostEnvironment"/> and the rule finally fired, leaving
|
||||
/// an empty database sitting at the rejected path: the same artifact that made the outage this
|
||||
/// guard exists to prevent read as "the database is there, it's just empty".
|
||||
/// Pre-host composition should pass the content root here explicitly.
|
||||
/// </remarks>
|
||||
public SecretsOptionsValidator(string? contentRootPath) =>
|
||||
_contentRootPath = contentRootPath;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, SecretsOptions options)
|
||||
|
||||
+27
-2
@@ -33,7 +33,29 @@ public static class SecretsServiceCollectionExtensions
|
||||
public static IServiceCollection AddZbSecrets(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sectionPath)
|
||||
string sectionPath) => services.AddZbSecrets(config, sectionPath, contentRootPath: null);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="AddZbSecrets(IServiceCollection, IConfiguration, string)"/>, with the content
|
||||
/// root supplied explicitly rather than resolved from <see cref="IHostEnvironment"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="sectionPath">Path of the configuration section holding the secrets options.</param>
|
||||
/// <param name="contentRootPath">
|
||||
/// The application content root. Pass this whenever the subsystem is composed OUTSIDE a host —
|
||||
/// most importantly from a pre-host <c>${secret:}</c> expander, which builds a throwaway
|
||||
/// container with no <see cref="IHostEnvironment"/> in it and then migrates the store. Without
|
||||
/// it the under-content-root rule is skipped at exactly the moment the store file is created,
|
||||
/// so a rejected path still gets an empty database written to it before the real host aborts.
|
||||
/// Pass <see langword="null"/> only where there genuinely is no content root, such as the CLI.
|
||||
/// </param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbSecrets(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sectionPath,
|
||||
string? contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
@@ -53,7 +75,10 @@ public static class SecretsServiceCollectionExtensions
|
||||
// rather than skipping it. Guarding on the concrete registration gives both properties.
|
||||
if (services.All(d => d.ServiceType != typeof(SecretsOptionsValidator)))
|
||||
{
|
||||
services.AddSingleton(sp => new SecretsOptionsValidator(sp.GetService<IHostEnvironment>()));
|
||||
// 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));
|
||||
services.AddSingleton<IValidateOptions<SecretsOptions>>(
|
||||
sp => sp.GetRequiredService<SecretsOptionsValidator>());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user