dd0a846b64
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
122 lines
5.2 KiB
C#
122 lines
5.2 KiB
C#
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;
|
|
}
|
|
}
|