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:
Joseph Doherty
2026-07-18 04:08:23 -04:00
parent e46060fada
commit dd0a846b64
55 changed files with 4848 additions and 51 deletions
@@ -0,0 +1,81 @@
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// The DTO is the trust boundary for anything a peer sends. Every rejection here must surface as an
/// <see cref="ArgumentException"/>, because that is the exception type the receiving actor filters
/// on — anything else escapes its handler, restarts the actor, and (since the row is redelivered)
/// loops.
/// </summary>
public sealed class HostileWireInputTests
{
private static SecretRowDto Valid() => new()
{
Name = "app/ok",
ContentType = "Text",
Ciphertext = [1],
Nonce = [2],
Tag = [3],
WrappedDek = [4],
WrapNonce = [5],
WrapTag = [6],
KekId = "sha256:x",
Revision = 0,
CreatedUtc = "2026-01-01T00:00:00.0000000+00:00",
UpdatedUtc = "2026-01-01T00:00:00.0000000+00:00",
};
[Fact]
public void A_valid_row_materializes()
{
StoredSecret row = Valid().ToStoredSecret();
Assert.Equal("app/ok", row.Name.Value);
Assert.Equal(SecretContentType.Text, row.ContentType);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("/rooted")]
[InlineData("has space")]
[InlineData("")]
public void A_malformed_name_is_rejected(string name)
{
Assert.Throws<ArgumentException>(() => (Valid() with { Name = name }).ToStoredSecret());
}
[Theory]
// An in-range number names no member — Enum.Parse would happily produce (SecretContentType)4096
// and the store would persist it for later readers that assume a defined value.
[InlineData("4096")]
// Overflows the underlying type: Enum.Parse throws OverflowException, which is NOT an
// ArgumentException, so it would escape the actor's filter and restart it.
[InlineData("99999999999999999999")]
[InlineData("NotAContentType")]
[InlineData("")]
public void An_unrecognized_content_type_is_rejected_as_an_ArgumentException(string contentType)
{
Assert.Throws<ArgumentException>(
() => (Valid() with { ContentType = contentType }).ToStoredSecret());
}
[Fact]
public void A_null_crypto_blob_is_rejected_at_the_boundary()
{
// `required byte[]` means "present in the payload", not "non-null" — an explicit JSON null
// satisfies it. Caught here, or it blows up deep in the store's parameter binding instead.
Assert.Throws<ArgumentException>(
() => (Valid() with { Ciphertext = null! }).ToStoredSecret());
Assert.Throws<ArgumentException>(
() => (Valid() with { WrappedDek = null! }).ToStoredSecret());
}
[Fact]
public void A_malformed_timestamp_is_rejected()
{
Assert.ThrowsAny<FormatException>(
() => (Valid() with { UpdatedUtc = "not-a-date" }).ToStoredSecret());
}
}