feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka replicator" fork to build only the former; both are built here so the choice is a deployment decision (availability vs partition tolerance) rather than a library limitation. Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over distributed pub/sub). Core gains ISecretsStoreMigrator, one shared SecretLastWriterWins predicate so no two stores can disagree on a tie, the transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was inert and local writes would not have propagated at all. Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural divergence between the stores fails) and a 9-test in-process 2-node Akka cluster over real remoting. A post-build review caught six defects, all fixed and now covered: both replication modes could not resolve from the container (no test had built one), an unbounded fetch that broke past SQL Server's 2100-parameter cap, a poison row that aborted the rest of its batch forever, Enum.Parse on peer input that could restart the actor in a loop, null crypto blobs crossing the trust boundary, and a silently dropped pull-read failure. Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the shared SQL-Server secret store and its hub replication. Bound from a
|
||||
/// configuration section by the <c>AddZbSecretsSqlServer*</c> extensions.
|
||||
/// </summary>
|
||||
public sealed class SqlServerSecretsOptions
|
||||
{
|
||||
private string _schemaName = SqlServerSecretsSchema.DefaultSchemaName;
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the shared secret database. Required. Supply it through a
|
||||
/// <c>${secret:}</c> / <c>secret:</c> reference like every other connection string in the family
|
||||
/// — the shared store holds ciphertext only, but its credentials are still credentials.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SQL schema the secret tables live in. Defaults to <c>zbsecrets</c> so the secret tables never
|
||||
/// collide with an application's own <c>dbo</c> objects when they share a database.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The value is not a plain identifier (letters, digits, and underscore, not starting with a
|
||||
/// digit, at most 128 characters).
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// Validated on assignment rather than at use: the schema name is interpolated into DDL and
|
||||
/// query text (T-SQL cannot parameterize an object name), so the allow-list is the injection
|
||||
/// boundary and it must be impossible to get an unvalidated value into the property at all.
|
||||
/// </remarks>
|
||||
public string SchemaName
|
||||
{
|
||||
get => _schemaName;
|
||||
set => _schemaName = ValidateIdentifier(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How often the sync service reconciles the local store against the hub, in hub-replication
|
||||
/// mode. Defaults to 30 seconds. Ignored in shared-store mode (there is nothing to reconcile).
|
||||
/// </summary>
|
||||
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> (the default), hub-replication mode runs a reconcile at startup
|
||||
/// before the first interval elapses, so a node that was down while a peer wrote does not serve
|
||||
/// stale secrets for a whole interval after it comes back.
|
||||
/// </summary>
|
||||
public bool SyncOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for a
|
||||
/// contended shared database, short enough that a hung hub surfaces as an error rather than
|
||||
/// stalling a resolve indefinitely.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable. Called by the DI extensions at registration time so a
|
||||
/// misconfigured node fails at startup rather than on the first secret resolve.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The connection string is missing, or an interval is not positive.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"SQL-Server secret store requires a ConnectionString (Secrets:SqlServer:ConnectionString).");
|
||||
}
|
||||
|
||||
if (SyncInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"SQL-Server secret replication SyncInterval must be positive (was {SyncInterval}).");
|
||||
}
|
||||
|
||||
if (CommandTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"SQL-Server secret store CommandTimeout must be positive (was {CommandTimeout}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text.
|
||||
/// Rejects everything that is not <c>[A-Za-z_][A-Za-z0-9_]{0,127}</c> — no brackets, quotes,
|
||||
/// dots, or spaces — so no bracket-escape (<c>]]</c>) trick can break out of the quoting applied
|
||||
/// at the use site. Uses the ASCII-only character tests deliberately, so homoglyph and
|
||||
/// full-width look-alikes are rejected rather than accepted as "letters".
|
||||
/// </summary>
|
||||
/// <param name="value">The candidate identifier.</param>
|
||||
/// <returns>The same value, once validated.</returns>
|
||||
/// <exception cref="ArgumentException">The value is not a plain identifier.</exception>
|
||||
internal static string ValidateIdentifier(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
if (value.Length > 128)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"SQL schema name must be at most 128 characters (was {value.Length}).", nameof(value));
|
||||
}
|
||||
|
||||
if (!char.IsAsciiLetter(value[0]) && value[0] != '_')
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"SQL schema name must start with a letter or underscore (was '{value}').", nameof(value));
|
||||
}
|
||||
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(c) && c != '_')
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"SQL schema name may contain only letters, digits, and underscore (was '{value}').",
|
||||
nameof(value));
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user