fix(secrets): review round 1 — pre-host expander reads the same store the runtime serves, raw-string Replication:Enabled parse, leftover-key warning
- SecretsRegistration: UsesCentralSharedStore predicate shared by AddScadaBridgeSecrets and the
new AddPreHostExpander, so a Central node's Layer-A ${secret:} expander composes against the
shared SQL store whenever Secrets:Store:Provider=SqlServer does — closing the gap where a
central migrated off the retired Grpc topology would boot-time-resolve config from per-node
SQLite while serving the shared store.
- Program.cs: expander block now calls AddPreHostExpander + resolves the store-agnostic
ISecretsStoreMigrator seam; stale Grpc-hub/replication comment rewritten.
- DeployedSecretsMirror.cs: stale "gRPC-hub follower half" doc comment rewritten to state the
now-permanent local-SQLite-only invariant.
- StartupValidator: Secrets:Replication:Enabled read as a raw string (any non-empty, non-"false"
value rejected) instead of GetValue<bool>, which threw a raw binder crash on "yes"/"1"; added a
CollectWarnings entry for leftover Secrets:Replication/Secrets:GrpcHub child keys once Enabled
is false/unset.
- Tests: +15 (AddPreHostExpander/UsesCentralSharedStore wiring, Replication:Enabled edge values,
leftover-key warnings). Full solution build and full Host.Tests green (593/593).
This commit is contained in:
@@ -33,7 +33,6 @@ using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Configuration;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
using ZB.MOM.WW.Secrets.Ui;
|
||||
using ZB.MOM.WW.Telemetry;
|
||||
using Serilog;
|
||||
@@ -75,11 +74,35 @@ var configuration = new ConfigurationBuilder()
|
||||
// Expand ${secret:...} config references before any validator/binder sees them (Layer A).
|
||||
// Throwaway provider — disposed here, shares no singletons with the host container.
|
||||
//
|
||||
// This always reads the node's LOCAL SQLite store: Task 11 retired the gRPC secrets hub, the only
|
||||
// topology that ever put a central node's store somewhere else pre-host. (Secrets:Store:Provider
|
||||
// =SqlServer, the still-supported shared-central-store feature, is a post-host DI registration —
|
||||
// see SecretsRegistration — and does not change what the Layer-A expander itself reads.)
|
||||
// The expander MUST read the SAME store the running node will serve — SecretsRegistration.
|
||||
// UsesCentralSharedStore is the single predicate deciding that, shared with the runtime
|
||||
// registration in AddScadaBridgeSecrets, so the two can never disagree. On a Central node with
|
||||
// Secrets:Store:Provider=SqlServer that is the SHARED SQL-Server store (Task 5): an expander left
|
||||
// on SQLite there would resolve pre-host ${secret:} references (e.g. ScadaBridge:Security:
|
||||
// JwtSigningKey) from a store the node does not actually serve, so a value rotated through the
|
||||
// shared store would never reach the pre-host config, and two central nodes could disagree on
|
||||
// what a token resolves to. Every other case (sites, the default per-node SQLite store) keeps the
|
||||
// local SQLite path exactly as it always was. A blank or ${secret:}-valued connection string on
|
||||
// the shared-store path fails HERE, through the same EnsureCentralSharedStoreConnectionString
|
||||
// check AddScadaBridgeSecrets runs — this is the earliest code that would otherwise hand the bad
|
||||
// value to SqlConnection, whose generic "initialization string" format error would bury the
|
||||
// designed message.
|
||||
//
|
||||
// Two consequences worth knowing. This block runs BEFORE Serilog exists and outside the
|
||||
// try/catch below, so a SQL Server outage at central boot exits with a bare stderr stack
|
||||
// trace, no structured log — honest and container-restart-retryable, just unenriched; the
|
||||
// SQLite path had the same window but its only failure mode was a local file. (That exit
|
||||
// is real only because of the UnhandledException handler at the top of this file — the
|
||||
// runtime's own crash path wedges as container PID 1, ScadaBridge#34.) And the role read
|
||||
// stays here at the composition root rather than inside SecretsRegistration: that class
|
||||
// refuses to read the node role from configuration (a config-read role could silently turn a
|
||||
// site into a shared-store central — see SecretsNodeRole), while Program.cs must read it to
|
||||
// branch the whole composition anyway.
|
||||
var expanderRole = string.Equals(
|
||||
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
|
||||
? SecretsNodeRole.Central
|
||||
: SecretsNodeRole.Site;
|
||||
|
||||
// Secrets 0.6.2 validates Secrets:SqlitePath as absolute AND outside the content root, finding the
|
||||
// content root from IHostEnvironment — which this throwaway ServiceCollection does not have, and
|
||||
// which does not exist yet anyway (the host is not built until further down). Both the resolution
|
||||
@@ -87,14 +110,17 @@ var configuration = new ConfigurationBuilder()
|
||||
// AddPreHostSqliteExpander for why the content-root argument is load-bearing and silent when
|
||||
// dropped, and PreHostSecretsContentRootTests for the regression guard.
|
||||
var expanderServices = new ServiceCollection();
|
||||
expanderServices.AddPreHostSqliteExpander(
|
||||
configuration, SecretsRegistration.ResolveHostContentRoot());
|
||||
expanderServices.AddPreHostExpander(
|
||||
configuration, expanderRole, SecretsRegistration.ResolveHostContentRoot());
|
||||
|
||||
#pragma warning disable ASP0000 // deliberate throwaway container
|
||||
await using (var secretsProvider = expanderServices.BuildServiceProvider())
|
||||
#pragma warning restore ASP0000
|
||||
{
|
||||
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
|
||||
// ISecretsStoreMigrator is the store-agnostic seam both AddZbSecrets (SQLite) and
|
||||
// AddZbSecretsSqlServerStore register — resolving through it means this block does not need
|
||||
// to know, or re-derive, which concrete store AddPreHostExpander just composed.
|
||||
await secretsProvider.GetRequiredService<ISecretsStoreMigrator>()
|
||||
.MigrateAsync(default);
|
||||
|
||||
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||
@@ -359,14 +385,14 @@ try
|
||||
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
|
||||
builder.Services.AddSecurity(disableLogin);
|
||||
builder.Services.AddCentralUI();
|
||||
// Local SQLite store by default. With Secrets:Replication:Enabled true it becomes either a
|
||||
// local store synced against a shared SQL-Server hub (Mode=SqlServer, and only when a hub
|
||||
// connection string is present) or — the production topology, scadaproj#3 — the HOST half
|
||||
// of the pull-only gRPC secrets hub (Mode=Grpc), which central serves and sites sweep.
|
||||
// The Central role never registers the sweep client; passing the role explicitly is what
|
||||
// makes that a compile-time property of this composition root rather than a config guess.
|
||||
// See SecretsRegistration for why the SQL gate exists (eager options validation), why the
|
||||
// hub credential can never itself come from the hub, and why gRPC mode fails closed.
|
||||
// Local SQLite store by default. With Secrets:Store:Provider=SqlServer this central node's
|
||||
// store becomes the SHARED SQL-Server database instead — no hub, no follower sweep, just
|
||||
// both central nodes reading and writing the same rows, so a clustered pair can never ship
|
||||
// sites a drifted secret set (Task 5). The Layer-A pre-host expander above resolves this
|
||||
// same predicate (SecretsRegistration.UsesCentralSharedStore) so it always reads the store
|
||||
// this node actually serves. See SecretsRegistration for why the SQL gate exists (eager
|
||||
// options validation) and why the connection string can never itself be a ${secret:}
|
||||
// reference.
|
||||
builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
|
||||
// Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal
|
||||
// (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS
|
||||
|
||||
@@ -62,9 +62,10 @@ public enum SecretsNodeRole
|
||||
/// <b>Bootstrap constraint.</b> The shared store's own connection string can never itself come from
|
||||
/// that store — a node cannot read the database to learn how to reach the database. It must arrive
|
||||
/// from outside it: an environment variable, or a literal value in appsettings. It can never be a
|
||||
/// <c>${secret:}</c> reference, because on central the pre-host expander that would resolve such a
|
||||
/// reference runs against the very store this string points at (see <c>Program.cs</c>) — the
|
||||
/// reference could never resolve, so registration rejects it outright.
|
||||
/// <c>${secret:}</c> reference, because on central the pre-host expander (<see cref="AddPreHostExpander"/>,
|
||||
/// called from <c>Program.cs</c> — see <see cref="UsesCentralSharedStore"/>) resolves EVERY
|
||||
/// <c>${secret:}</c> token in configuration, including this one, from the very store this string
|
||||
/// points at — the reference could never resolve, so registration rejects it outright.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecretsRegistration
|
||||
@@ -87,6 +88,30 @@ public static class SecretsRegistration
|
||||
/// </summary>
|
||||
public const string StoreProviderKey = "Secrets:Store:Provider";
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node's secret store is the shared SQL-Server database — the single predicate
|
||||
/// behind BOTH <see cref="AddScadaBridgeSecrets"/>'s runtime registration AND
|
||||
/// <see cref="AddPreHostExpander"/>'s Layer-A <c>${secret:}</c> expander, so the two can never
|
||||
/// disagree about which store a Central node serves. That matters more than tidiness: an
|
||||
/// expander that resolved boot-time <c>${secret:}</c> tokens from the per-node SQLite file
|
||||
/// while the running node served the shared SQL store would let two central nodes boot with
|
||||
/// different resolved values (e.g. a different <c>JwtSigningKey</c>) — exactly the per-node
|
||||
/// drift <see cref="StoreProviderKey"/>=SqlServer exists to prevent.
|
||||
/// </summary>
|
||||
/// <param name="config">Application configuration.</param>
|
||||
/// <param name="role">Which half of the composition this node is.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when <paramref name="role"/> is <see cref="SecretsNodeRole.Central"/>
|
||||
/// and <see cref="StoreProviderKey"/> is (case-insensitively) <c>SqlServer</c>.
|
||||
/// </returns>
|
||||
internal static bool UsesCentralSharedStore(IConfiguration config, SecretsNodeRole role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
return role == SecretsNodeRole.Central
|
||||
&& string.Equals(config[StoreProviderKey], "SqlServer", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the content root the HOST will use, from outside the host — the Layer-A
|
||||
/// <c>${secret:}</c> expander in <c>Program.cs</c> runs before any builder exists, so
|
||||
@@ -146,6 +171,62 @@ public static class SecretsRegistration
|
||||
return services.AddZbSecrets(config, SecretsSectionPath, contentRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the Layer-A pre-host <c>${secret:}</c> expander stack for <paramref name="role"/> —
|
||||
/// the shared SQL-Server store when <see cref="UsesCentralSharedStore"/> is true, otherwise the
|
||||
/// plain local SQLite store (<see cref="AddPreHostSqliteExpander"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the fix for the gap <see cref="UsesCentralSharedStore"/>'s own remarks describe: the
|
||||
/// expander MUST read the same store <see cref="AddScadaBridgeSecrets"/> will register for the
|
||||
/// running node, or a boot-time <c>${secret:}</c> reference (e.g. in
|
||||
/// <c>ScadaBridge:Security:JwtSigningKey</c>) resolves from a store the node does not actually
|
||||
/// serve. Both this method and <see cref="AddScadaBridgeSecrets"/> branch on the exact same
|
||||
/// <see cref="UsesCentralSharedStore"/> call, so they cannot drift apart the way the old
|
||||
/// Grpc-mode expander branch in <c>Program.cs</c> and the Grpc-mode runtime branch here once
|
||||
/// had to be kept in step by hand.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On the shared-store path this also runs <see cref="EnsureCentralSharedStoreConnectionString"/>
|
||||
/// BEFORE composing anything — the same fail-closed rules apply here as at runtime: a blank
|
||||
/// connection string, or one holding a <c>${secret:}</c> reference (which could never resolve;
|
||||
/// this very expander is what would have to resolve it), fails the boot with the designed
|
||||
/// message instead of a generic SqlConnection format error.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The throwaway expander service collection.</param>
|
||||
/// <param name="config">Application configuration.</param>
|
||||
/// <param name="role">Which half of the composition this node is.</param>
|
||||
/// <param name="contentRoot">The host's content root, from <see cref="ResolveHostContentRoot"/> — only used on the SQLite path.</param>
|
||||
/// <returns>The same collection, for chaining.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <paramref name="role"/> is Central with the shared store selected, and
|
||||
/// <see cref="HubConnectionStringKey"/> is blank or a <c>${secret:}</c> reference.
|
||||
/// </exception>
|
||||
public static IServiceCollection AddPreHostExpander(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
SecretsNodeRole role,
|
||||
string contentRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
|
||||
|
||||
if (UsesCentralSharedStore(config, role))
|
||||
{
|
||||
EnsureCentralSharedStoreConnectionString(config, $"{StoreProviderKey}=SqlServer");
|
||||
|
||||
// Same 3-arg overload as the runtime registration in AddScadaBridgeSecrets — this is
|
||||
// deliberately the SAME store, not a lookalike one composed independently.
|
||||
return services.AddZbSecretsSqlServerStore(
|
||||
config, SecretsSectionPath, SqlServerSectionPath);
|
||||
}
|
||||
|
||||
return services.AddPreHostSqliteExpander(config, contentRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one definition of "a Central node has a usable shared-store connection string", shared
|
||||
/// by every caller that puts a Central node on the shared SQL-Server store — the
|
||||
@@ -230,9 +311,10 @@ public static class SecretsRegistration
|
||||
|
||||
// Secrets:Store:Provider=SqlServer on Central only. A Site never reaches this: it always
|
||||
// keeps its local SQLite store, straight through a WAN outage, same as every other
|
||||
// topology, and never decorated — see the class remarks.
|
||||
if (role == SecretsNodeRole.Central
|
||||
&& string.Equals(config[StoreProviderKey], "SqlServer", StringComparison.OrdinalIgnoreCase))
|
||||
// topology, and never decorated — see the class remarks. UsesCentralSharedStore is the
|
||||
// SAME predicate AddPreHostExpander branches on, so the two can never disagree about
|
||||
// which store a Central node serves.
|
||||
if (UsesCentralSharedStore(config, role))
|
||||
{
|
||||
// Fail-closed rules: blank, or a ${secret:} reference (bootstrap circularity — the
|
||||
// pre-host expander needs this same store to resolve it).
|
||||
|
||||
@@ -31,7 +31,7 @@ public static class StartupValidator
|
||||
// Deploy Artifacts, and a replicated site store would publish DeployedSecretsMirror's local
|
||||
// deletes fleet-wide. Checked unconditionally (both roles): a Site used to be the gRPC
|
||||
// hub's follower half, so the flag was never Central-only.
|
||||
var secretsReplicationEnabled = configuration.GetValue<bool>("Secrets:Replication:Enabled");
|
||||
var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
|
||||
|
||||
// GrpcPort: default 8083 when absent; only fails the range rule when the key is
|
||||
// present AND invalid. The out-param assignment mirrors the original so the
|
||||
@@ -251,6 +251,7 @@ public static class StartupValidator
|
||||
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
|
||||
var provider = InboundApiKeyStoreOptions.ReadProvider(configuration);
|
||||
var secretsStoreProvider = configuration["Secrets:Store:Provider"];
|
||||
var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
|
||||
var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 };
|
||||
|
||||
var warnings = new List<string>();
|
||||
@@ -283,9 +284,46 @@ public static class StartupValidator
|
||||
+ "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer");
|
||||
}
|
||||
|
||||
// Review round 1, Minor #4: a config that still carries Secrets:Replication or
|
||||
// Secrets:GrpcHub child keys — left over from before Task 11 retired the hub — is not
|
||||
// itself an error (Validate already rejects Enabled=true outright), but every key under
|
||||
// either section is now silently ignored. That is worth a Warning so an operator cleans
|
||||
// QA/prod appsettings rather than assuming a still-present Mode/BearerToken/Endpoint value
|
||||
// does anything. Excludes the bare "Enabled" key itself: "Enabled: false" alone is the
|
||||
// normal, still-valid shape of every existing deployment's config and must not warn.
|
||||
var hasStaleReplicationKeys = configuration.GetSection("Secrets:Replication").GetChildren()
|
||||
.Any(child => !string.Equals(child.Key, "Enabled", StringComparison.OrdinalIgnoreCase));
|
||||
var hasStaleGrpcHubKeys = configuration.GetSection("Secrets:GrpcHub").GetChildren().Any();
|
||||
|
||||
if (!secretsReplicationEnabled && (hasStaleReplicationKeys || hasStaleGrpcHubKeys))
|
||||
{
|
||||
warnings.Add(
|
||||
"Secrets:Replication / Secrets:GrpcHub settings are ignored — the secrets hub is "
|
||||
+ "retired; remove them");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <c>Secrets:Replication:Enabled</c> is "set" — read as a raw string rather than
|
||||
/// <c>IConfiguration.GetValue<bool></c> (review round 1, Minor #3), which throws a raw
|
||||
/// binder <see cref="InvalidOperationException"/> on a non-boolean value like <c>"yes"</c> or
|
||||
/// <c>"1"</c>, so a malformed leftover key would surface as an unrelated parse crash instead of
|
||||
/// the designed retirement message in <see cref="Validate"/>. Any value other than absent,
|
||||
/// empty, or (case-insensitively) <c>"false"</c> counts as "set" — deliberately stricter than
|
||||
/// bool parsing, so a typo'd leftover (<c>Enabled: 1</c>, <c>Enabled: yes</c>) still gets the
|
||||
/// explanatory message rather than silently passing as falsy. Shared by <see cref="Validate"/>
|
||||
/// (which rejects "set" outright) and <see cref="CollectWarnings"/> (which, for the "not set"
|
||||
/// case, separately warns about any OTHER leftover key under the same retired sections).
|
||||
/// </summary>
|
||||
private static bool IsSecretsReplicationEnabled(IConfiguration configuration)
|
||||
{
|
||||
var raw = configuration["Secrets:Replication:Enabled"];
|
||||
return !string.IsNullOrEmpty(raw)
|
||||
&& !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the TCP port from an Akka seed-node address of the form
|
||||
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
||||
|
||||
@@ -116,13 +116,16 @@ public sealed record MirrorResult(
|
||||
/// can observe a stale pre-rotation plaintext for up to the cache TTL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Node-local store assumption (review Minor #5).</b> This mirror assumes the site's
|
||||
/// <see cref="ISecretStore"/> is node-local (plain local SQLite, or the gRPC-hub follower half,
|
||||
/// both of which keep writes local). It is NOT safe to run against a site node whose store is
|
||||
/// decorated by SQL-Server secrets REPLICATION (<c>Secrets:Replication:Mode=SqlServer</c>) — its
|
||||
/// <c>DeleteAsync</c> publishes the tombstone to the shared hub, so a site merely un-referencing
|
||||
/// a secret would delete it fleet-wide. Task 11 rejects that configuration on the Site role;
|
||||
/// no code change is made here.
|
||||
/// <b>Node-local store assumption (review Minor #5; Task 11 removed every replication mode).</b>
|
||||
/// This mirror assumes the site's <see cref="ISecretStore"/> is always plain, node-local, local
|
||||
/// SQLite — the ONLY shape a Site node's store can be after Task 11 retired both the pull-only
|
||||
/// gRPC secrets hub and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it (see
|
||||
/// <c>SecretsRegistration</c>). It would NOT have been safe to run this mirror against a store
|
||||
/// decorated by either retired replication mode: <c>DeleteAsync</c> would have published the
|
||||
/// tombstone to every other node sharing the decorator, so a site merely un-referencing a secret
|
||||
/// would have deleted it fleet-wide. <c>StartupValidator</c> now rejects
|
||||
/// <c>Secrets:Replication:Enabled=true</c> outright on either role, so that configuration can no
|
||||
/// longer reach this mirror at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resolve semantics and the resolver cache.</b> The trial-resolve verify step goes through
|
||||
|
||||
Reference in New Issue
Block a user