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:
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.3</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -16,8 +16,25 @@
|
||||
which carries advisory GHSA-2m69-gcr7-jv3q (NU1903). Pin the patched 2.1.12 native lib. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
|
||||
<!-- Shared SQL-Server secret store (ZB.MOM.WW.Secrets.Replication.SqlServer).
|
||||
6.0.2 matches the version ScadaBridge and ZB.MOM.WW.GalaxyRepository already resolve, so a
|
||||
consumer taking both gets one SqlClient rather than a downgrade warning. -->
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
|
||||
<!-- Akka cluster replication (ZB.MOM.WW.Secrets.Replication.Akka).
|
||||
1.5.62 matches ScadaBridge's pinned Akka.NET line — the clustered consumer must not be
|
||||
forced into an Akka version bump just to adopt secret replication. -->
|
||||
<PackageVersion Include="Akka" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Remote" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
|
||||
<!-- Cryptography -->
|
||||
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
|
||||
<!-- 9.0.4, not 9.0.0: Microsoft.Data.SqlClient 6.0.2 pulls System.Configuration.ConfigurationManager
|
||||
9.0.4, which requires ProtectedData >= 9.0.4. With transitive pinning on, the lower central
|
||||
version is a hard NU1109 downgrade error rather than a silent resolve. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.4" />
|
||||
|
||||
<!-- Extensions -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
|
||||
@@ -28,6 +45,8 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
|
||||
<!-- ASP.NET Core Authentication / Authorization -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
|
||||
@@ -11,6 +11,8 @@ plaintext back on demand — from application code, from configuration, or from
|
||||
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only (`ISecretStore`, `ISecretResolver`, `IMasterKeyProvider`, `ISecretCipher`, `ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`), value types, exceptions. Dependency-light. |
|
||||
| `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. |
|
||||
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. |
|
||||
| `ZB.MOM.WW.Secrets.Replicator.SqlServer` | Cluster-wide secrets via SQL Server — as one shared store, or as a hub each node syncs against. |
|
||||
| `ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` | Peer-to-peer replication over an Akka.NET cluster — partition-tolerant, no shared database. |
|
||||
|
||||
A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate` / `rewrap-all`) ships in the repo (not packed).
|
||||
|
||||
@@ -78,8 +80,73 @@ SQL-Server store; once per node for per-node SQLite on a shared KEK). See the op
|
||||
|
||||
## Clustered deployments
|
||||
|
||||
Secrets storage is local SQLite by default. The schema already carries the
|
||||
`revision` / `updated_utc` / tombstone columns and an `ISecretReplicator` seam for
|
||||
cluster replication, but the Akka replicator (`ZB.MOM.WW.Secrets.Akka`) is a deferred
|
||||
follow-on. **When replicating across a node pair, every node must resolve the _same_ master
|
||||
KEK** (e.g. the same mounted key file) — a row wrapped by an unknown KEK fails closed.
|
||||
Storage is local SQLite by default, which is correct for a single-process app and wrong the
|
||||
moment a secret written on one node has to be readable on another. Three topologies are
|
||||
supported; pick the least complicated one that meets the requirement.
|
||||
|
||||
> **Every topology requires the same master KEK on every node.** The database and the wire carry
|
||||
> ciphertext only, so a node with a different key cannot decrypt what another wrote — it fails
|
||||
> closed on a `kek_id` mismatch, which looks like corruption but is a deployment mistake. Mount the
|
||||
> same key file, or supply the same key environment variable, everywhere.
|
||||
|
||||
### 1. Shared SQL-Server store — *the default choice*
|
||||
|
||||
Every node points at one database. There is exactly one copy of each row, so there is no
|
||||
replication, no reconciliation, no split-brain, and no clock skew to reason about.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddZbSecretsSqlServerStore(builder.Configuration);
|
||||
```
|
||||
|
||||
```jsonc
|
||||
"Secrets": {
|
||||
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
|
||||
"SqlServer": {
|
||||
"ConnectionString": "Server=sql01;Database=ZbSecrets;...",
|
||||
"SchemaName": "zbsecrets"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trade-off:** availability is coupled to that database. A node cut off from it falls back only to
|
||||
the resolver's short in-memory cache. Rotation (`rewrap-all`) runs **once**, against the one store.
|
||||
|
||||
### 2. SQL-Server hub replication
|
||||
|
||||
Each node keeps its local store and stays converged with a shared hub: writes publish immediately,
|
||||
and a background sweep reconciles **both directions** on an interval (default 30s).
|
||||
|
||||
```csharp
|
||||
builder.Services.AddZbSecretsSqlServerReplication(builder.Configuration);
|
||||
```
|
||||
|
||||
Choose this over (1) when a node must keep **resolving** secrets while cut off from the hub — reads
|
||||
never leave the node, so a hub outage degrades propagation rather than availability.
|
||||
|
||||
### 3. Akka cluster peer-to-peer
|
||||
|
||||
No shared database at all. Nodes broadcast writes over distributed pub/sub and repair divergence
|
||||
with a periodic manifest exchange.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddZbSecretsAkkaReplication(builder.Configuration);
|
||||
// Recommended — pins the serializer that carries ciphertext:
|
||||
Config config = AkkaSecretsReplication.SerializationConfig.WithFallback(myAppConfig);
|
||||
```
|
||||
|
||||
Requires the application to register its `ActorSystem` in DI. The local store is SQLite. This is
|
||||
the right answer for air-gapped or intermittently-connected sites; it is the wrong answer if every
|
||||
node can always reach a shared database, because you pay real distributed-systems behaviour for it.
|
||||
|
||||
### What replication costs you (2 and 3)
|
||||
|
||||
- **Eventual consistency.** Rows converge within the sweep interval, not instantly.
|
||||
- **Last-writer-wins.** Two nodes writing the same secret concurrently: the later `updated_utc`
|
||||
wins and the other write is discarded. There is no merge and no conflict report.
|
||||
- **Publishing is best-effort.** A failed broadcast is logged, not thrown — the local write is
|
||||
already durable and anti-entropy repairs it. Writes never fail because a peer was unreachable.
|
||||
- **KEK rotation does not replicate.** A re-wrap deliberately leaves the revision untouched so it
|
||||
stays invisible to last-writer-wins. Run `rewrap-all` against **each** independent store.
|
||||
|
||||
See [`docs/operations/clustered-secrets.md`](docs/operations/clustered-secrets.md) for operator
|
||||
setup, and [`docs/operations/kek-rotation.md`](docs/operations/kek-rotation.md) for rotation.
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.SqlServer/ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Operating clustered secrets
|
||||
|
||||
How to run `ZB.MOM.WW.Secrets` across more than one node. Covers the shared SQL-Server store, the
|
||||
SQL-Server hub, and Akka peer-to-peer replication.
|
||||
|
||||
Companion: [`kek-rotation.md`](kek-rotation.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. The one rule that breaks everything if you get it wrong
|
||||
|
||||
**Every node must resolve the same master KEK.**
|
||||
|
||||
Rows are ciphertext everywhere — in the database, in a backup, on the wire. Only the KEK decrypts
|
||||
them, and it never leaves the node. So if node B has a different master key than node A:
|
||||
|
||||
- replication still "works" — rows arrive and are stored;
|
||||
- every resolve on B of a secret written on A **fails closed** with a `kek_id` mismatch.
|
||||
|
||||
The symptom looks like data corruption and is not. Before enabling any topology below, confirm the
|
||||
same key material is present on every node:
|
||||
|
||||
```bash
|
||||
# On each node — compare the digests, never print the key itself.
|
||||
sha256sum /run/secrets/zb-kek
|
||||
# or, for the environment source:
|
||||
printf '%s' "$ZB_SECRETS_MASTER_KEY" | sha256sum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared SQL-Server store
|
||||
|
||||
One database, one copy of each row. Prefer this unless a node must keep resolving secrets while
|
||||
partitioned from the database.
|
||||
|
||||
### Provision
|
||||
|
||||
The schema is created automatically by the first node that starts (idempotent, and safe if several
|
||||
start at once). You only need a database and a login:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE ZbSecrets;
|
||||
GO
|
||||
USE ZbSecrets;
|
||||
CREATE LOGIN zbsecrets WITH PASSWORD = '<strong password>';
|
||||
CREATE USER zbsecrets FOR LOGIN zbsecrets;
|
||||
GO
|
||||
-- The app creates and owns the zbsecrets schema; it needs permission to do so.
|
||||
GRANT CREATE SCHEMA, CREATE TABLE TO zbsecrets;
|
||||
ALTER ROLE db_owner ADD MEMBER zbsecrets; -- or grant on the schema once it exists
|
||||
GO
|
||||
```
|
||||
|
||||
To pre-create the schema instead (so the app can run with narrower rights), run the DDL from
|
||||
`SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets")` and then grant only
|
||||
`SELECT, INSERT, UPDATE` on `[zbsecrets].[secret]` and `[zbsecrets].[schema_version]`.
|
||||
|
||||
> `DELETE` is deliberately **not** required. Deletes are tombstones — an `UPDATE` — so a compromised
|
||||
> app credential cannot destroy secret history.
|
||||
|
||||
### Configure
|
||||
|
||||
```jsonc
|
||||
"Secrets": {
|
||||
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
|
||||
"SqlServer": {
|
||||
"ConnectionString": "Server=sql01;Database=ZbSecrets;User Id=zbsecrets;Password=${secret:sql/zbsecrets-password};TrustServerCertificate=False",
|
||||
"SchemaName": "zbsecrets",
|
||||
"CommandTimeout": "00:00:30"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The connection string is itself a credential — deliver it by `${secret:}` reference, an orchestrator
|
||||
secret, or an environment variable. Never commit it.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
secret set smoke/test hello --description "delete me" # on node A
|
||||
secret get smoke/test # on node B — expect: hello
|
||||
secret rm smoke/test
|
||||
```
|
||||
|
||||
### Rotation
|
||||
|
||||
Run `rewrap-all` **once**, against the shared store, with resolve traffic quiesced.
|
||||
|
||||
---
|
||||
|
||||
## 2. SQL-Server hub replication
|
||||
|
||||
Each node keeps its local SQLite store and syncs against a shared hub.
|
||||
|
||||
Same provisioning as topology 1. Configure with `AddZbSecretsSqlServerReplication` and:
|
||||
|
||||
```jsonc
|
||||
"Secrets": {
|
||||
"SqlitePath": "/var/lib/zb/secrets.db",
|
||||
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
|
||||
"SqlServer": {
|
||||
"ConnectionString": "...",
|
||||
"SyncInterval": "00:00:30",
|
||||
"SyncOnStartup": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SyncInterval` is the convergence bound, not the propagation latency — a live write reaches the hub
|
||||
in milliseconds; the interval governs how fast a *dropped* write or a rejoining node is repaired.
|
||||
|
||||
### Rotation
|
||||
|
||||
Run `rewrap-all` against the hub **and** against every node's local SQLite store. A re-wrap does not
|
||||
bump the revision (by design — otherwise every node's independent re-wrap would churn replication),
|
||||
so it does **not** propagate.
|
||||
|
||||
### Verifying convergence
|
||||
|
||||
```
|
||||
Secret hub sync converged: pulled N row(s) from the hub, pushed M row(s) to it.
|
||||
```
|
||||
|
||||
Logged at Information only when something actually moved. A healthy steady-state cluster logs
|
||||
nothing. Continuous non-zero pushes on every tick means two nodes are fighting over the same secret
|
||||
— look for a script writing the same name on a loop.
|
||||
|
||||
---
|
||||
|
||||
## 3. Akka cluster peer-to-peer
|
||||
|
||||
No shared database. Requires the application to register its `ActorSystem` in DI.
|
||||
|
||||
```jsonc
|
||||
"Secrets": {
|
||||
"SqlitePath": "/var/lib/zb/secrets.db",
|
||||
"MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" },
|
||||
"Replication": {
|
||||
"AnnounceInterval": "00:00:30",
|
||||
"ActorName": "zb-secret-replication"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Merge the serializer config when creating the actor system:
|
||||
|
||||
```csharp
|
||||
Config config = AkkaSecretsReplication.SerializationConfig.WithFallback(myAppConfig);
|
||||
ActorSystem.Create("my-system", config);
|
||||
```
|
||||
|
||||
Optional but recommended: without it the protocol still round-trips under Akka's default JSON
|
||||
serializer, but the serializer carrying secret ciphertext becomes an inherited default rather than
|
||||
a deliberate choice.
|
||||
|
||||
### Securing the transport
|
||||
|
||||
Rows on the wire are ciphertext and the KEK never travels, so a passive eavesdropper learns
|
||||
nothing but secret *names*, revisions, and timestamps. That is still metadata worth protecting, and
|
||||
an unauthenticated cluster port is a much larger problem than secret replication — configure
|
||||
Akka.Remote TLS and cluster authentication as you would for any other cluster traffic.
|
||||
|
||||
### Rotation
|
||||
|
||||
Run `rewrap-all` on **each node's** local store. Re-wraps do not replicate (see above).
|
||||
|
||||
### Announce traffic
|
||||
|
||||
One manifest per node per interval: names, revisions, timestamps, tombstone flags — no ciphertext.
|
||||
Cost grows as O(nodes × secrets), so a large cluster with a large secret set should lengthen
|
||||
`AnnounceInterval` rather than shorten it.
|
||||
|
||||
---
|
||||
|
||||
## Choosing between them
|
||||
|
||||
| | Shared store | SQL hub | Akka peer-to-peer |
|
||||
|---|---|---|---|
|
||||
| Resolves while cut off from the DB | ✗ (cache TTL only) | ✓ | ✓ (no DB at all) |
|
||||
| Distributed failure modes | none | LWW, sweep lag | LWW, sweep lag |
|
||||
| Rotation runs | once | hub + each node | each node |
|
||||
| Needs a shared database | ✓ | ✓ | ✗ |
|
||||
| Needs an Akka cluster | ✗ | ✗ | ✓ |
|
||||
|
||||
Start at the left and move right only when a stated requirement forces it.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`SecretDecryptionException` / `kek_id` mismatch after enabling replication.**
|
||||
The KEKs differ between nodes. Compare digests as in §0. Do not "fix" it by re-writing the secret on
|
||||
the failing node — that hides the mismatch until the next secret.
|
||||
|
||||
**A secret written on node A never appears on node B.**
|
||||
Check, in order: (1) are both nodes actually in the cluster / pointed at the same hub database and
|
||||
schema; (2) has one `AnnounceInterval` / `SyncInterval` elapsed; (3) is there a warning-level log
|
||||
about a failed publish or a failed sweep. A dropped publish is expected and self-repairing — a
|
||||
sweep that fails every interval is not.
|
||||
|
||||
**A deleted secret came back.**
|
||||
Deletes are tombstones and propagate as newer rows, so this should not happen through replication.
|
||||
It does happen if a node's local store was restored from a backup taken before the delete: the
|
||||
restore reintroduces the row with an old timestamp, loses to LWW, and the tombstone re-wins. If
|
||||
instead the row is *live* again, someone re-created it — check the audit log for the write.
|
||||
|
||||
**The migration refuses to run.** `SecretStoreMigrationException` naming a version newer than
|
||||
supported means another node is running a newer build against the same store. Upgrade this node;
|
||||
do not downgrade the store.
|
||||
@@ -2,7 +2,8 @@ namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation;
|
||||
/// <c>ZB.MOM.WW.Secrets.Akka</c> (deferred) provides the real cluster implementation.
|
||||
/// <c>ZB.MOM.WW.Secrets.Replication.Akka</c> (peer-to-peer over the cluster) and
|
||||
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> (via a shared hub database) provide the real ones.
|
||||
/// </summary>
|
||||
public interface ISecretReplicator
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions the schema backing an <see cref="ISecretStore"/>. Implementations are idempotent —
|
||||
/// running a migration repeatedly is safe — and refuse to run against a store whose on-disk schema
|
||||
/// version is <em>newer</em> than the build supports, rather than silently downgrading it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This seam exists so the startup migration hosted service and the CLI stay store-agnostic: the
|
||||
/// SQLite store, the shared SQL-Server store, and any future provider are all provisioned through
|
||||
/// the same call, and swapping the store never touches the migration wiring.
|
||||
/// </remarks>
|
||||
public interface ISecretsStoreMigrator
|
||||
{
|
||||
/// <summary>Creates or updates the secret-store schema to the version this build supports.</summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task that completes when the schema is at the supported version.</returns>
|
||||
/// <exception cref="SecretStoreMigrationException">
|
||||
/// The store's schema version is newer than this build supports.
|
||||
/// </exception>
|
||||
Task MigrateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// The single definition of the last-writer-wins (LWW) ordering used to reconcile a secret row
|
||||
/// against a peer's copy: newer <see cref="StoredSecret.UpdatedUtc"/> wins, and
|
||||
/// <see cref="StoredSecret.Revision"/> breaks a timestamp tie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every store's <see cref="ISecretStore.ApplyReplicatedAsync"/> and every replication transport
|
||||
/// reconciler routes its "is the incoming row newer?" decision through here. Keeping one definition
|
||||
/// is a correctness requirement, not a tidiness one: a cluster can mix stores (a node backed by the
|
||||
/// shared SQL-Server store alongside nodes on local SQLite), and two implementations that disagree
|
||||
/// about a tie would converge to <em>different</em> rows on different nodes — a silent split-brain
|
||||
/// that no single-node test can catch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The comparison is deliberately <b>strict</b>: a row that merely ties the local one is not newer
|
||||
/// and is ignored. That makes replication idempotent — re-delivering a row a node already has is a
|
||||
/// no-op rather than a write — so at-least-once transports (pub/sub redelivery, an anti-entropy
|
||||
/// sweep racing a live broadcast) cost nothing and cannot flap a row back and forth.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecretLastWriterWins
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if a row at (<paramref name="incomingUpdatedUtc"/>,
|
||||
/// <paramref name="incomingRevision"/>) should overwrite a local row at
|
||||
/// (<paramref name="localUpdatedUtc"/>, <paramref name="localRevision"/>).
|
||||
/// </summary>
|
||||
/// <param name="incomingUpdatedUtc">The incoming row's last-updated timestamp.</param>
|
||||
/// <param name="incomingRevision">The incoming row's revision.</param>
|
||||
/// <param name="localUpdatedUtc">The local row's last-updated timestamp.</param>
|
||||
/// <param name="localRevision">The local row's revision.</param>
|
||||
/// <returns><see langword="true"/> if the incoming row is strictly newer.</returns>
|
||||
public static bool IsNewer(
|
||||
DateTimeOffset incomingUpdatedUtc,
|
||||
long incomingRevision,
|
||||
DateTimeOffset localUpdatedUtc,
|
||||
long localRevision) =>
|
||||
incomingUpdatedUtc > localUpdatedUtc ||
|
||||
(incomingUpdatedUtc == localUpdatedUtc && incomingRevision > localRevision);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if <paramref name="incoming"/> should overwrite the local row
|
||||
/// described by <paramref name="local"/>.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The manifest entry received from a peer.</param>
|
||||
/// <param name="local">The corresponding local manifest entry.</param>
|
||||
/// <returns><see langword="true"/> if the incoming entry is strictly newer.</returns>
|
||||
public static bool IsNewer(SecretManifestEntry incoming, SecretManifestEntry local)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
|
||||
return IsNewer(incoming.UpdatedUtc, incoming.Revision, local.UpdatedUtc, local.Revision);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Cli;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.MasterKey;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
|
||||
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
|
||||
@@ -20,8 +18,9 @@ builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||
|
||||
using IHost host = builder.Build();
|
||||
|
||||
// Ensure the schema exists before any store operation.
|
||||
await host.Services.GetRequiredService<SqliteSecretsStoreMigrator>()
|
||||
// Ensure the schema exists before any store operation. Resolved through the ISecretsStoreMigrator
|
||||
// seam so the CLI provisions whichever store the app configured (local SQLite or shared SQL Server).
|
||||
await host.Services.GetRequiredService<ISecretsStoreMigrator>()
|
||||
.MigrateAsync(CancellationToken.None);
|
||||
|
||||
var commands = new SecretCommands(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Akka.Actor;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes local secret writes to cluster peers by handing them to this node's
|
||||
/// <see cref="SecretReplicationActor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hand-off is a <c>Tell</c>, so <see cref="PublishAsync"/> completes as soon as the row is in
|
||||
/// the actor's mailbox rather than when peers have applied it. That is the correct contract here:
|
||||
/// the caller (<c>ReplicatingSecretStore</c>) has already durably written the row locally and treats
|
||||
/// publishing as best-effort, and blocking a write until every peer acknowledged would make each
|
||||
/// node's availability depend on all the others. Delivery is guaranteed by anti-entropy, not by
|
||||
/// this call.
|
||||
/// </remarks>
|
||||
/// <param name="replicationActor">This node's replication actor.</param>
|
||||
public sealed class AkkaSecretReplicator(IActorRef replicationActor) : ISecretReplicator
|
||||
{
|
||||
private readonly IActorRef _replicationActor =
|
||||
replicationActor ?? throw new ArgumentNullException(nameof(replicationActor));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
_replicationActor.Tell(new SecretReplicationActor.PublishLocalWrite(row));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using Akka.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Akka configuration this package contributes.
|
||||
/// </summary>
|
||||
public static class AkkaSecretsReplication
|
||||
{
|
||||
/// <summary>
|
||||
/// HOCON binding the secret-replication wire protocol to
|
||||
/// <see cref="SecretReplicationSerializer"/>. Merge it into the application's Akka
|
||||
/// configuration when creating the <c>ActorSystem</c>:
|
||||
/// <code>
|
||||
/// Config config = AkkaSecretsReplication.SerializationConfig
|
||||
/// .WithFallback(myAppConfig);
|
||||
/// ActorSystem.Create("my-system", config);
|
||||
/// </code>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Optional — the protocol DTOs round-trip under Akka's default JSON serializer without it — but
|
||||
/// recommended, because it makes the serializer carrying secret ciphertext an explicit choice
|
||||
/// rather than an inherited default, and pins the manifest strings that a future protocol
|
||||
/// version would bump.
|
||||
/// </remarks>
|
||||
public static Config SerializationConfig { get; } = ConfigurationFactory.ParseString($$"""
|
||||
akka.actor {
|
||||
serializers {
|
||||
zb-secrets-replication = "{{typeof(SecretReplicationSerializer).AssemblyQualifiedName}}"
|
||||
}
|
||||
serialization-bindings {
|
||||
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretRowsMessage, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
|
||||
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretManifestAnnounce, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
|
||||
"ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol.SecretPullRequest, ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" = zb-secrets-replication
|
||||
}
|
||||
serialization-identifiers {
|
||||
"{{typeof(SecretReplicationSerializer).AssemblyQualifiedName}}" = {{SecretReplicationSerializer.SerializerId}}
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for peer-to-peer secret replication over the Akka cluster.
|
||||
/// </summary>
|
||||
public sealed class AkkaSecretsReplicationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// How often this node broadcasts its manifest for anti-entropy. Defaults to 30 seconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the convergence bound, not the propagation latency: a live write reaches peers in
|
||||
/// milliseconds, and this interval only governs how quickly a <em>dropped</em> write or a
|
||||
/// rejoining node is repaired. The traffic is one manifest per node per interval — names,
|
||||
/// revisions, and timestamps, no ciphertext — so shortening it is cheap for a small cluster and
|
||||
/// grows as O(nodes × secrets).
|
||||
/// </remarks>
|
||||
public TimeSpan AnnounceInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Name of the replication actor within the actor system. Defaults to <c>zb-secret-replication</c>.</summary>
|
||||
public string ActorName { get; set; } = "zb-secret-replication";
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The announce interval is not positive, or the actor name is blank.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (AnnounceInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Secret replication AnnounceInterval must be positive (was {AnnounceInterval}).");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ActorName))
|
||||
{
|
||||
throw new InvalidOperationException("Secret replication ActorName must not be blank.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection entry point for peer-to-peer secret replication over an Akka.NET cluster.
|
||||
/// </summary>
|
||||
public static class AkkaSecretsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers secret replication over the cluster: each node keeps its own local store, local
|
||||
/// writes broadcast to peers, and a periodic manifest exchange repairs any divergence in both
|
||||
/// directions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Choose this transport when a node must keep resolving secrets while <b>partitioned</b> from
|
||||
/// the rest of the cluster — reads never leave the node, so an isolated site keeps running on its
|
||||
/// last-known-good state and re-converges when the partition heals. If every node can always
|
||||
/// reach a shared database, the SQL-Server package's shared-store mode is simpler and has no
|
||||
/// distributed failure modes at all; prefer it unless partition tolerance is a stated requirement.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Requirements.</b> The application must (1) register its <see cref="ActorSystem"/> in DI
|
||||
/// before the first secret resolve, and (2) give every node the <b>same KEK</b> — rows carry
|
||||
/// ciphertext only, so a node with a different master key fails closed on a <c>kek_id</c>
|
||||
/// mismatch. Merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the actor
|
||||
/// system's configuration is recommended.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>KEK rotation does not replicate.</b> A re-wrap deliberately leaves the revision untouched
|
||||
/// so it stays invisible to last-writer-wins; run <c>rewrap-all</c> against every node's local
|
||||
/// store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
|
||||
/// <param name="replicationSectionPath">Configuration section holding the replication options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbSecretsAkkaReplication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string secretsSectionPath = "Secrets",
|
||||
string replicationSectionPath = "Secrets:Replication")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(secretsSectionPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(replicationSectionPath);
|
||||
|
||||
IConfigurationSection section = config.GetSection(replicationSectionPath);
|
||||
services.Configure<AkkaSecretsReplicationOptions>(section);
|
||||
|
||||
// Validate eagerly so a bad interval fails at startup, not on the first announce tick.
|
||||
var eager = new AkkaSecretsReplicationOptions();
|
||||
section.Bind(eager);
|
||||
eager.Validate();
|
||||
|
||||
// The local store + migrator come from AddZbSecrets (SQLite by default).
|
||||
services.AddZbSecrets(config, secretsSectionPath);
|
||||
|
||||
// The actor is created lazily on first use rather than at registration: the ActorSystem is
|
||||
// typically registered by the application AFTER this call, and a cluster node may also not be
|
||||
// ready to join at the moment the container is built.
|
||||
services.TryAddSingleton<SecretReplicationActorProvider>(sp => new SecretReplicationActorProvider(
|
||||
sp.GetRequiredService<ActorSystem>(),
|
||||
sp.GetRequiredService<SqliteSecretStore>(),
|
||||
sp.GetService<ISecretCacheInvalidator>(),
|
||||
sp.GetRequiredService<IOptions<AkkaSecretsReplicationOptions>>().Value));
|
||||
|
||||
services.TryAddSingleton<ISecretReplicator>(sp =>
|
||||
new AkkaSecretReplicator(sp.GetRequiredService<SecretReplicationActorProvider>().ActorRef));
|
||||
|
||||
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store,
|
||||
// not ISecretStore — asking the container for ISecretStore here would resolve this very
|
||||
// decorator and recurse forever.
|
||||
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
|
||||
sp.GetRequiredService<SqliteSecretStore>(),
|
||||
sp.GetRequiredService<ISecretReplicator>(),
|
||||
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates this node's <see cref="SecretReplicationActor"/> on first access and hands out its ref.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The indirection exists because the actor must be created lazily — the application usually
|
||||
/// registers its <see cref="ActorSystem"/> after <c>AddZbSecretsAkkaReplication</c>, so eagerly
|
||||
/// resolving one at registration time would fail. Creation is guarded by
|
||||
/// <see cref="Lazy{T}"/> with <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so
|
||||
/// concurrent first writes cannot spawn two actors on the same node.
|
||||
/// </remarks>
|
||||
/// <param name="system">The application's actor system.</param>
|
||||
/// <param name="store">The node's local secret store — the undecorated one.</param>
|
||||
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
|
||||
/// <param name="options">Replication options.</param>
|
||||
public sealed class SecretReplicationActorProvider(
|
||||
ActorSystem system,
|
||||
ISecretStore store,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
AkkaSecretsReplicationOptions options)
|
||||
{
|
||||
private readonly Lazy<IActorRef> _actorRef = new(
|
||||
() => system.ActorOf(
|
||||
SecretReplicationActor.Props(store, cacheInvalidator, options.AnnounceInterval),
|
||||
options.ActorName),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>This node's replication actor, created on first access.</summary>
|
||||
public IActorRef ActorRef => _actorRef.Value;
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The wire representation of one encrypted secret row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is a deliberate hand-written mirror of <see cref="StoredSecret"/> rather than the type
|
||||
/// itself, and the duplication is the point. It makes the trust boundary <b>structural</b>: the wire
|
||||
/// contract is a flat set of primitives that a reader can check against "ciphertext only, never the
|
||||
/// KEK" in one glance, and a field added to <see cref="StoredSecret"/> in future cannot start
|
||||
/// crossing the network merely by existing — someone has to add it here on purpose.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All fields are primitives (<see cref="string"/>, <see cref="byte"/> arrays, <see cref="long"/>,
|
||||
/// <see cref="bool"/>) so the DTO round-trips through any serializer, including Akka's default JSON
|
||||
/// one if an application never registers <see cref="SecretReplicationSerializer"/>. Timestamps are
|
||||
/// ISO-8601 <c>"O"</c> strings for the same reason the stores use them: last-writer-wins compares
|
||||
/// them for equality, so the representation has to be exact rather than merely close.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SecretRowDto
|
||||
{
|
||||
/// <summary>The normalized secret name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Optional human-readable description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>The <see cref="SecretContentType"/> enum name.</summary>
|
||||
public required string ContentType { get; init; }
|
||||
|
||||
/// <summary>AES-256-GCM ciphertext of the secret plaintext.</summary>
|
||||
public required byte[] Ciphertext { get; init; }
|
||||
|
||||
/// <summary>Nonce sealing <see cref="Ciphertext"/>.</summary>
|
||||
public required byte[] Nonce { get; init; }
|
||||
|
||||
/// <summary>Authentication tag for <see cref="Ciphertext"/>.</summary>
|
||||
public required byte[] Tag { get; init; }
|
||||
|
||||
/// <summary>The per-secret data key, wrapped by the KEK. The KEK itself never travels.</summary>
|
||||
public required byte[] WrappedDek { get; init; }
|
||||
|
||||
/// <summary>Nonce used to wrap <see cref="WrappedDek"/>.</summary>
|
||||
public required byte[] WrapNonce { get; init; }
|
||||
|
||||
/// <summary>Authentication tag for <see cref="WrappedDek"/>.</summary>
|
||||
public required byte[] WrapTag { get; init; }
|
||||
|
||||
/// <summary>Identifier of the KEK that wrapped the DEK — an identifier, not key material.</summary>
|
||||
public required string KekId { get; init; }
|
||||
|
||||
/// <summary>Monotonic revision.</summary>
|
||||
public required long Revision { get; init; }
|
||||
|
||||
/// <summary>Whether the row is a tombstone.</summary>
|
||||
public bool IsDeleted { get; init; }
|
||||
|
||||
/// <summary>ISO-8601 deletion timestamp, or <see langword="null"/>.</summary>
|
||||
public string? DeletedUtc { get; init; }
|
||||
|
||||
/// <summary>ISO-8601 creation timestamp.</summary>
|
||||
public required string CreatedUtc { get; init; }
|
||||
|
||||
/// <summary>ISO-8601 last-update timestamp — half of the last-writer-wins ordering key.</summary>
|
||||
public required string UpdatedUtc { get; init; }
|
||||
|
||||
/// <summary>Principal that created the row, if known.</summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Principal that last updated the row, if known.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>Projects a stored row onto the wire contract.</summary>
|
||||
/// <param name="row">The encrypted row.</param>
|
||||
/// <returns>The wire representation.</returns>
|
||||
public static SecretRowDto FromStoredSecret(StoredSecret row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return new SecretRowDto
|
||||
{
|
||||
Name = row.Name.Value,
|
||||
Description = row.Description,
|
||||
ContentType = row.ContentType.ToString(),
|
||||
Ciphertext = row.Ciphertext,
|
||||
Nonce = row.Nonce,
|
||||
Tag = row.Tag,
|
||||
WrappedDek = row.WrappedDek,
|
||||
WrapNonce = row.WrapNonce,
|
||||
WrapTag = row.WrapTag,
|
||||
KekId = row.KekId,
|
||||
Revision = row.Revision,
|
||||
IsDeleted = row.IsDeleted,
|
||||
DeletedUtc = row.DeletedUtc?.ToString("O"),
|
||||
CreatedUtc = row.CreatedUtc.ToString("O"),
|
||||
UpdatedUtc = row.UpdatedUtc.ToString("O"),
|
||||
CreatedBy = row.CreatedBy,
|
||||
UpdatedBy = row.UpdatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the stored row from the wire contract.</summary>
|
||||
/// <returns>The encrypted row.</returns>
|
||||
/// <exception cref="ArgumentException">The name or content type is not valid.</exception>
|
||||
public StoredSecret ToStoredSecret() => new()
|
||||
{
|
||||
// SecretName re-validates on construction, so a malformed or path-traversing name from a
|
||||
// peer is rejected here rather than reaching the store.
|
||||
Name = new SecretName(Name),
|
||||
Description = Description,
|
||||
ContentType = ParseContentType(ContentType),
|
||||
// `required` on a byte[] means "present in the payload", NOT "non-null" — a peer sending an
|
||||
// explicit JSON null satisfies it and yields a null array, which would then blow up deep in
|
||||
// the store's parameter binding instead of being rejected at the boundary.
|
||||
Ciphertext = RequireBlob(Ciphertext, nameof(Ciphertext)),
|
||||
Nonce = RequireBlob(Nonce, nameof(Nonce)),
|
||||
Tag = RequireBlob(Tag, nameof(Tag)),
|
||||
WrappedDek = RequireBlob(WrappedDek, nameof(WrappedDek)),
|
||||
WrapNonce = RequireBlob(WrapNonce, nameof(WrapNonce)),
|
||||
WrapTag = RequireBlob(WrapTag, nameof(WrapTag)),
|
||||
KekId = KekId ?? throw new ArgumentException("Replicated secret row has a null KekId."),
|
||||
Revision = Revision,
|
||||
IsDeleted = IsDeleted,
|
||||
DeletedUtc = DeletedUtc is null ? null : ParseUtc(DeletedUtc),
|
||||
CreatedUtc = ParseUtc(CreatedUtc),
|
||||
UpdatedUtc = ParseUtc(UpdatedUtc),
|
||||
CreatedBy = CreatedBy,
|
||||
UpdatedBy = UpdatedBy,
|
||||
};
|
||||
|
||||
private static DateTimeOffset ParseUtc(string value) => DateTimeOffset.Parse(
|
||||
value,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind);
|
||||
|
||||
private static byte[] RequireBlob(byte[]? value, string field) =>
|
||||
value ?? throw new ArgumentException($"Replicated secret row has a null {field}.", field);
|
||||
|
||||
// Enum.Parse is wrong for untrusted input on two counts: a numeric string that overflows the
|
||||
// underlying type throws OverflowException (which the receiving actor's ArgumentException filter
|
||||
// would not catch, restarting it in a redelivery loop), and any in-range number is accepted even
|
||||
// if it names no member — persisting an undefined enum value that later readers assume is valid.
|
||||
private static SecretContentType ParseContentType(string value) =>
|
||||
Enum.TryParse(value, out SecretContentType parsed) && Enum.IsDefined(parsed)
|
||||
? parsed
|
||||
: throw new ArgumentException(
|
||||
$"Replicated secret row has an unrecognized content type '{value}'.", nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>One manifest entry on the wire: enough to decide who is newer, with no ciphertext.</summary>
|
||||
public sealed record SecretManifestEntryDto
|
||||
{
|
||||
/// <summary>The normalized secret name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Monotonic revision.</summary>
|
||||
public required long Revision { get; init; }
|
||||
|
||||
/// <summary>ISO-8601 last-update timestamp.</summary>
|
||||
public required string UpdatedUtc { get; init; }
|
||||
|
||||
/// <summary>Whether the row is a tombstone.</summary>
|
||||
public bool IsDeleted { get; init; }
|
||||
|
||||
/// <summary>Projects a manifest entry onto the wire contract.</summary>
|
||||
/// <param name="entry">The manifest entry.</param>
|
||||
/// <returns>The wire representation.</returns>
|
||||
public static SecretManifestEntryDto FromEntry(SecretManifestEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new SecretManifestEntryDto
|
||||
{
|
||||
Name = entry.Name.Value,
|
||||
Revision = entry.Revision,
|
||||
UpdatedUtc = entry.UpdatedUtc.ToString("O"),
|
||||
IsDeleted = entry.IsDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the manifest entry from the wire contract.</summary>
|
||||
/// <returns>The manifest entry.</returns>
|
||||
public SecretManifestEntry ToEntry() => new()
|
||||
{
|
||||
Name = new SecretName(Name),
|
||||
Revision = Revision,
|
||||
UpdatedUtc = DateTimeOffset.Parse(
|
||||
UpdatedUtc,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||
IsDeleted = IsDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node broadcasting its full inventory so peers can work out, in one round trip, both what they
|
||||
/// need from it and what it needs from them.
|
||||
/// </summary>
|
||||
/// <param name="Entries">The sender's manifest.</param>
|
||||
public sealed record SecretManifestAnnounce(IReadOnlyList<SecretManifestEntryDto> Entries);
|
||||
|
||||
/// <summary>A peer asking for the encrypted rows behind names it found it was missing or behind on.</summary>
|
||||
/// <param name="Names">The requested secret names.</param>
|
||||
public sealed record SecretPullRequest(IReadOnlyList<string> Names);
|
||||
|
||||
/// <summary>
|
||||
/// Encrypted rows delivered to a peer — the reply to a <see cref="SecretPullRequest"/>, the
|
||||
/// unsolicited push half of an anti-entropy exchange, and the live broadcast of a fresh local write.
|
||||
/// </summary>
|
||||
/// <param name="Rows">The encrypted rows.</param>
|
||||
public sealed record SecretRowsMessage(IReadOnlyList<SecretRowDto> Rows);
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// One per node. Owns this node's participation in secret replication: broadcasts local writes,
|
||||
/// periodically announces its manifest, and services peers' pulls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The protocol is deliberately small — three messages, no leader, no singleton:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>
|
||||
/// <b>Live push.</b> A local write publishes a <see cref="SecretRowsMessage"/> to the topic. Peers
|
||||
/// apply it last-writer-wins. This is the fast path — propagation in milliseconds — and it is
|
||||
/// allowed to fail.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <b>Anti-entropy.</b> Every node periodically publishes a <see cref="SecretManifestAnnounce"/>.
|
||||
/// A peer receiving it computes <em>both</em> directions in one pass: it pushes back rows it holds
|
||||
/// newer, and asks (<see cref="SecretPullRequest"/>) for rows it is missing or behind on. This is
|
||||
/// the correctness path — it repairs anything the fast path dropped, in either direction.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <b>Pull service.</b> A <see cref="SecretPullRequest"/> is answered with the requested rows.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// There is no coordinator because there does not need to be one: every exchange is idempotent and
|
||||
/// commutative under last-writer-wins, so nodes converge regardless of message order, duplication,
|
||||
/// or which subset of peers is currently reachable. A node that was partitioned keeps serving its
|
||||
/// local store the whole time and re-converges on the first announce after the partition heals —
|
||||
/// which is the entire reason to choose this transport over the shared SQL-Server store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecretReplicationActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
/// <summary>The distributed pub/sub topic secret replication traffic rides on.</summary>
|
||||
public const string Topic = "zb-mom-ww-secrets";
|
||||
|
||||
private readonly ISecretStore _store;
|
||||
private readonly SecretReplicationReconciler _reconciler;
|
||||
private readonly TimeSpan _announceInterval;
|
||||
private readonly IActorRef _mediator;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
/// <summary>Creates the replication actor.</summary>
|
||||
/// <param name="store">The node's local secret store.</param>
|
||||
/// <param name="cacheInvalidator">Resolver-cache seam, evicted for every applied row.</param>
|
||||
/// <param name="announceInterval">How often this node announces its manifest.</param>
|
||||
public SecretReplicationActor(
|
||||
ISecretStore store,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
TimeSpan announceInterval)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
_store,
|
||||
cacheInvalidator,
|
||||
onRowFailed: (name, ex) => _log.Warning(
|
||||
ex, "Discarding replicated secret row {0} that could not be applied locally.", name.Value));
|
||||
_announceInterval = announceInterval;
|
||||
_mediator = DistributedPubSub.Get(Context.System).Mediator;
|
||||
|
||||
_mediator.Tell(new Subscribe(Topic, Self));
|
||||
|
||||
Receive<PublishLocalWrite>(HandlePublishLocalWrite);
|
||||
Receive<SecretRowsMessage>(HandleRows);
|
||||
Receive<SecretManifestAnnounce>(HandleManifestAnnounce);
|
||||
Receive<SecretPullRequest>(HandlePullRequest);
|
||||
Receive<AnnounceTick>(_ => HandleAnnounceTick());
|
||||
Receive<SubscribeAck>(_ => _log.Debug("Subscribed to secret replication topic {0}.", Topic));
|
||||
Receive<ExchangeFailed>(f =>
|
||||
_log.Warning(f.Cause, "Secret anti-entropy exchange with a peer failed; retrying next announce."));
|
||||
Receive<ExchangeDone>(_ => { /* successful exchange, piped back to self — nothing to do. */ });
|
||||
}
|
||||
|
||||
/// <summary>Props for the replication actor.</summary>
|
||||
/// <param name="store">The node's local secret store.</param>
|
||||
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
|
||||
/// <param name="announceInterval">How often this node announces its manifest.</param>
|
||||
/// <returns>Props creating the actor.</returns>
|
||||
public static Props Props(
|
||||
ISecretStore store, ISecretCacheInvalidator? cacheInvalidator, TimeSpan announceInterval) =>
|
||||
Akka.Actor.Props.Create(() =>
|
||||
new SecretReplicationActor(store, cacheInvalidator, announceInterval));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart() =>
|
||||
Timers.StartPeriodicTimer(AnnounceTick.Instance, AnnounceTick.Instance, _announceInterval, _announceInterval);
|
||||
|
||||
// A local write, handed over by AkkaSecretReplicator. Published from inside the actor so the
|
||||
// message carries Self as sender — that is what lets a peer reply directly to this node instead
|
||||
// of broadcasting its answer to everyone.
|
||||
private void HandlePublishLocalWrite(PublishLocalWrite message)
|
||||
{
|
||||
_mediator.Tell(new Publish(
|
||||
Topic,
|
||||
new SecretRowsMessage([SecretRowDto.FromStoredSecret(message.Row)])));
|
||||
}
|
||||
|
||||
private void HandleRows(SecretRowsMessage message)
|
||||
{
|
||||
if (IsFromSelf())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<StoredSecret> rows = Materialize(message.Rows);
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// PipeTo nothing: applying is fire-and-forget from the mailbox's point of view. Failures are
|
||||
// logged, and anti-entropy re-delivers anything that did not land.
|
||||
_reconciler.ApplyAsync(rows, CancellationToken.None)
|
||||
.ContinueWith(
|
||||
task => _log.Warning(
|
||||
task.Exception,
|
||||
"Failed to apply {0} replicated secret row(s) from a peer; anti-entropy will retry.",
|
||||
rows.Count),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private void HandleManifestAnnounce(SecretManifestAnnounce message)
|
||||
{
|
||||
if (IsFromSelf())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IActorRef peer = Sender;
|
||||
IReadOnlyList<SecretManifestEntry> remote = [.. message.Entries.Select(e => e.ToEntry())];
|
||||
|
||||
ReconcileWithPeerAsync(peer, remote).PipeTo(Self, failure: ex => new ExchangeFailed(ex));
|
||||
}
|
||||
|
||||
private void HandlePullRequest(SecretPullRequest message)
|
||||
{
|
||||
if (IsFromSelf())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IActorRef peer = Sender;
|
||||
// Captured before the continuation: Self is context-dependent and there is no guarantee the
|
||||
// actor context is still current when the continuation runs (Akka analyzer AK1005).
|
||||
IActorRef self = Self;
|
||||
IReadOnlyList<SecretName> names = ParseNames(message.Names);
|
||||
|
||||
_reconciler.ReadLocalAsync(names, CancellationToken.None)
|
||||
.ContinueWith(
|
||||
task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
// Logged rather than swallowed: the peer is waiting for a reply it will never
|
||||
// get, and without this the only symptom is a secret that quietly never
|
||||
// converges. The peer re-asks on its next announce.
|
||||
_log.Warning(
|
||||
task.Exception,
|
||||
"Failed to read {0} local secret row(s) for a peer's pull request.",
|
||||
names.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.IsCompletedSuccessfully && task.Result.Count > 0)
|
||||
{
|
||||
peer.Tell(
|
||||
new SecretRowsMessage([.. task.Result.Select(SecretRowDto.FromStoredSecret)]),
|
||||
self);
|
||||
}
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private void HandleAnnounceTick()
|
||||
{
|
||||
_store.GetManifestAsync(CancellationToken.None)
|
||||
.ContinueWith(
|
||||
task =>
|
||||
{
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
_mediator.Tell(new Publish(
|
||||
Topic,
|
||||
new SecretManifestAnnounce(
|
||||
[.. task.Result.Select(SecretManifestEntryDto.FromEntry)])));
|
||||
}
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Both halves of an anti-entropy exchange, computed from one manifest: push what the peer is
|
||||
// behind on, ask for what we are behind on.
|
||||
private async Task<ExchangeDone> ReconcileWithPeerAsync(
|
||||
IActorRef peer, IReadOnlyList<SecretManifestEntry> remote)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> localManifest =
|
||||
await _store.GetManifestAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<SecretName> push =
|
||||
SecretReplicationReconciler.ComputePushSet(localManifest, remote);
|
||||
|
||||
if (push.Count > 0)
|
||||
{
|
||||
IReadOnlyList<StoredSecret> rows =
|
||||
await _reconciler.ReadLocalAsync(push, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
peer.Tell(new SecretRowsMessage([.. rows.Select(SecretRowDto.FromStoredSecret)]), Self);
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<SecretName> pull =
|
||||
SecretReplicationReconciler.ComputePullSet(localManifest, remote);
|
||||
|
||||
if (pull.Count > 0)
|
||||
{
|
||||
peer.Tell(new SecretPullRequest([.. pull.Select(n => n.Value)]), Self);
|
||||
}
|
||||
|
||||
return ExchangeDone.Instance;
|
||||
}
|
||||
|
||||
// DistributedPubSub delivers a node's own publications back to it. Dropping them here is what
|
||||
// stops a broadcast from being re-applied locally and, worse, echoed onward.
|
||||
private bool IsFromSelf() => Sender.Equals(Self);
|
||||
|
||||
// A peer's rows are untrusted input: a malformed name or content type must drop that row, not
|
||||
// fault the actor and stall replication for every other secret.
|
||||
private IReadOnlyList<StoredSecret> Materialize(IReadOnlyList<SecretRowDto> dtos)
|
||||
{
|
||||
var rows = new List<StoredSecret>(dtos.Count);
|
||||
|
||||
foreach (SecretRowDto dto in dtos)
|
||||
{
|
||||
try
|
||||
{
|
||||
rows.Add(dto.ToStoredSecret());
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException)
|
||||
{
|
||||
_log.Warning(ex, "Discarding a malformed replicated secret row from a peer.");
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private IReadOnlyList<SecretName> ParseNames(IReadOnlyList<string> names)
|
||||
{
|
||||
var parsed = new List<SecretName>(names.Count);
|
||||
|
||||
foreach (string name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
parsed.Add(new SecretName(name));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_log.Warning(ex, "Discarding a malformed secret name in a peer's pull request.");
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>Instruction from <see cref="AkkaSecretReplicator"/> to broadcast a local write.</summary>
|
||||
/// <param name="Row">The encrypted row to broadcast.</param>
|
||||
public sealed record PublishLocalWrite(StoredSecret Row);
|
||||
|
||||
private sealed record AnnounceTick
|
||||
{
|
||||
public static readonly AnnounceTick Instance = new();
|
||||
}
|
||||
|
||||
private sealed record ExchangeFailed(Exception Cause);
|
||||
|
||||
private sealed record ExchangeDone
|
||||
{
|
||||
public static readonly ExchangeDone Instance = new();
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.Serialization;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit System.Text.Json serializer for the secret-replication wire protocol.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Registering this is <b>optional but recommended</b>. The protocol DTOs are all primitives, so
|
||||
/// Akka's default JSON serializer round-trips them correctly if an application never wires this up —
|
||||
/// but relying on the default means secret ciphertext is serialized by whatever the cluster's
|
||||
/// fallback happens to be, which is not a decision worth leaving implicit for this payload. Binding
|
||||
/// it explicitly also pins the manifest strings below as the versioning boundary: a future protocol
|
||||
/// change adds a manifest, so a mixed-version cluster fails loudly on an unknown manifest instead of
|
||||
/// silently mis-deserializing a secret.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Wire it up by merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the
|
||||
/// application's Akka configuration — see that member for the HOCON.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="system">The actor system this serializer belongs to.</param>
|
||||
public sealed class SecretReplicationSerializer(ExtendedActorSystem system) : SerializerWithStringManifest(system)
|
||||
{
|
||||
/// <summary>Stable serializer id. Must not collide with other serializers in the cluster.</summary>
|
||||
public const int SerializerId = 7710;
|
||||
|
||||
private const string RowsManifest = "zbs-rows-v1";
|
||||
private const string ManifestAnnounceManifest = "zbs-manifest-v1";
|
||||
private const string PullRequestManifest = "zbs-pull-v1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
// Explicitly NOT indented and NOT camel-cased: the wire form should be compact and stable,
|
||||
// not pretty. Property names are the C# names, pinned by the manifest version.
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Identifier => SerializerId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Manifest(object o) => o switch
|
||||
{
|
||||
SecretRowsMessage => RowsManifest,
|
||||
SecretManifestAnnounce => ManifestAnnounceManifest,
|
||||
SecretPullRequest => PullRequestManifest,
|
||||
_ => throw new ArgumentException(
|
||||
$"{nameof(SecretReplicationSerializer)} cannot serialize {o?.GetType().FullName ?? "null"}.",
|
||||
nameof(o)),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public override byte[] ToBinary(object obj) => obj switch
|
||||
{
|
||||
SecretRowsMessage rows => JsonSerializer.SerializeToUtf8Bytes(rows, JsonOptions),
|
||||
SecretManifestAnnounce announce => JsonSerializer.SerializeToUtf8Bytes(announce, JsonOptions),
|
||||
SecretPullRequest pull => JsonSerializer.SerializeToUtf8Bytes(pull, JsonOptions),
|
||||
_ => throw new ArgumentException(
|
||||
$"{nameof(SecretReplicationSerializer)} cannot serialize {obj?.GetType().FullName ?? "null"}.",
|
||||
nameof(obj)),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object FromBinary(byte[] bytes, string manifest) => manifest switch
|
||||
{
|
||||
RowsManifest => Deserialize<SecretRowsMessage>(bytes, manifest),
|
||||
ManifestAnnounceManifest => Deserialize<SecretManifestAnnounce>(bytes, manifest),
|
||||
PullRequestManifest => Deserialize<SecretPullRequest>(bytes, manifest),
|
||||
_ => throw new SerializationException(
|
||||
$"Unknown secret-replication manifest '{manifest}'. This usually means a peer is running a " +
|
||||
"newer protocol version than this node."),
|
||||
};
|
||||
|
||||
private static T Deserialize<T>(byte[] bytes, string manifest) =>
|
||||
JsonSerializer.Deserialize<T>(bytes, JsonOptions)
|
||||
?? throw new SerializationException(
|
||||
$"Secret-replication payload with manifest '{manifest}' deserialized to null.");
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Secrets.Replicator.AkkaDotNet</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Peer-to-peer secret replication over an Akka.NET cluster for the ZB.MOM.WW SCADA family — partition-tolerant, ciphertext-only.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Akka.Cluster" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection entry points for backing a cluster's secrets with SQL Server, in either of
|
||||
/// the two supported topologies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Both require the same KEK on every node.</b> The database stores ciphertext only, so a node
|
||||
/// whose master key differs cannot decrypt rows another node wrote — it fails closed on resolve with
|
||||
/// a <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error. Mount the
|
||||
/// same key file or supply the same key environment variable everywhere.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SqlServerSecretsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// <b>Shared-store mode.</b> Points every node's <see cref="ISecretStore"/> at one shared
|
||||
/// SQL-Server database. There is exactly one copy of each row, so there is nothing to replicate
|
||||
/// and nothing to reconcile — no split-brain, no anti-entropy lag, no clock skew.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Prefer this unless a node must keep resolving secrets while <em>partitioned</em> from the
|
||||
/// shared database. The trade is availability: a node cut off from the database falls back only
|
||||
/// to the resolver's short in-memory cache. If that is unacceptable, use
|
||||
/// <see cref="AddZbSecretsSqlServerReplication"/> (local store + hub) or the Akka package
|
||||
/// (peer-to-peer, no shared database at all).
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
|
||||
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbSecretsSqlServerStore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string secretsSectionPath = "Secrets",
|
||||
string sqlServerSectionPath = "Secrets:SqlServer")
|
||||
{
|
||||
RegisterSqlServerCore(services, config, sqlServerSectionPath);
|
||||
|
||||
// Registered BEFORE AddZbSecrets so its TryAdd calls find these already present and leave
|
||||
// them alone — that is the whole mechanism by which the SQL-Server store displaces SQLite.
|
||||
services.TryAddSingleton<ISecretStore>(sp => sp.GetRequiredService<SqlServerSecretStore>());
|
||||
services.TryAddSingleton<ISecretsStoreMigrator>(sp =>
|
||||
sp.GetRequiredService<SqlServerSecretsStoreMigrator>());
|
||||
|
||||
services.AddZbSecrets(config, secretsSectionPath);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Hub-replication mode.</b> Each node keeps its own local store (SQLite by default) and
|
||||
/// stays converged with a shared SQL-Server hub: local writes publish to the hub immediately, and
|
||||
/// a background sweep reconciles both directions on an interval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Choose this over <see cref="AddZbSecretsSqlServerStore"/> when a node must keep resolving
|
||||
/// secrets while cut off from the hub — reads are served from local state, so a hub outage
|
||||
/// degrades propagation rather than availability. The cost is real distributed-systems
|
||||
/// behaviour: rows converge eventually (bounded by the sweep interval), conflicting concurrent
|
||||
/// writes to the same secret resolve last-writer-wins, and KEK rotation must be run against the
|
||||
/// hub <em>and</em> each node's local store, because a re-wrap deliberately does not replicate.
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
|
||||
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbSecretsSqlServerReplication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string secretsSectionPath = "Secrets",
|
||||
string sqlServerSectionPath = "Secrets:SqlServer")
|
||||
{
|
||||
RegisterSqlServerCore(services, config, sqlServerSectionPath);
|
||||
|
||||
services.TryAddSingleton<ISecretReplicationHub>(sp => sp.GetRequiredService<SqlServerSecretStore>());
|
||||
|
||||
services.TryAddSingleton<ISecretReplicator>(sp =>
|
||||
new SqlServerSecretReplicator(sp.GetRequiredService<ISecretReplicationHub>()));
|
||||
|
||||
// The local store + its migrator come from AddZbSecrets (SQLite by default).
|
||||
services.AddZbSecrets(config, secretsSectionPath);
|
||||
|
||||
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store
|
||||
// rather than ISecretStore, because ISecretStore is what we are replacing — asking the
|
||||
// container for it here would resolve this very decorator and recurse forever.
|
||||
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
|
||||
sp.GetRequiredService<Secrets.Sqlite.SqliteSecretStore>(),
|
||||
sp.GetRequiredService<ISecretReplicator>(),
|
||||
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
|
||||
|
||||
// The hub's schema must exist too — the node's own migrator only provisions the local store.
|
||||
services.AddHostedService<SqlServerHubMigrationHostedService>();
|
||||
services.AddHostedService<SqlServerSecretSyncService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Registers the pieces both modes need: validated options, connection factory, hub store, and
|
||||
// hub migrator.
|
||||
private static void RegisterSqlServerCore(
|
||||
IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sqlServerSectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sqlServerSectionPath);
|
||||
|
||||
IConfigurationSection section = config.GetSection(sqlServerSectionPath);
|
||||
services.Configure<SqlServerSecretsOptions>(section);
|
||||
|
||||
// Bind and validate NOW as well as through the options pipeline: a missing connection string
|
||||
// or a bad schema name should fail at startup with a clear message, not on the first resolve.
|
||||
var eager = new SqlServerSecretsOptions();
|
||||
section.Bind(eager);
|
||||
eager.Validate();
|
||||
|
||||
services.TryAddSingleton(sp =>
|
||||
new SecretsSqlServerConnectionFactory(
|
||||
sp.GetRequiredService<IOptions<SqlServerSecretsOptions>>().Value));
|
||||
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqlServerSecretStore(sp.GetRequiredService<SecretsSqlServerConnectionFactory>()));
|
||||
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqlServerSecretsStoreMigrator(sp.GetRequiredService<SecretsSqlServerConnectionFactory>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// The subset of store operations hub replication needs from the shared database: compare
|
||||
/// inventories, fetch rows in bulk, and accept a row verbatim.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Narrower than <see cref="ISecretStore"/> on purpose. The hub is never resolved from, never
|
||||
/// written through <see cref="ISecretStore.UpsertAsync"/> (which would stamp the hub's own revision
|
||||
/// and bounce the row back), and never rewrapped by a syncing node — so exposing those operations
|
||||
/// would only create ways to use it wrongly. It also makes the sync sweep testable against an
|
||||
/// in-process fake instead of requiring a live SQL Server for every convergence case.
|
||||
/// </remarks>
|
||||
public interface ISecretReplicationHub
|
||||
{
|
||||
/// <summary>Returns the hub's manifest — names, revisions, timestamps, tombstone flags.</summary>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The hub's manifest entries.</returns>
|
||||
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Fetches several encrypted rows by name; names absent from the hub are omitted.</summary>
|
||||
/// <param name="names">The names to fetch.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The matching encrypted rows.</returns>
|
||||
Task<IReadOnlyList<StoredSecret>> GetManyAsync(IReadOnlyList<SecretName> names, CancellationToken ct);
|
||||
|
||||
/// <summary>Applies a row to the hub verbatim, last-writer-wins.</summary>
|
||||
/// <param name="row">The encrypted row.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>A task that completes when the row has been applied or ignored.</returns>
|
||||
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Opens connections to the shared SQL-Server secret database and carries the settings every
|
||||
/// command needs (schema name, command timeout).
|
||||
/// </summary>
|
||||
/// <param name="options">The validated SQL-Server store options.</param>
|
||||
public sealed class SecretsSqlServerConnectionFactory(SqlServerSecretsOptions options)
|
||||
{
|
||||
private readonly SqlServerSecretsOptions _options =
|
||||
options ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
/// <summary>The bracket-quoted, allow-list-validated schema prefix for object names.</summary>
|
||||
public string SchemaName => _options.SchemaName;
|
||||
|
||||
/// <summary>Command timeout, in whole seconds, for every command issued against this store.</summary>
|
||||
public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
|
||||
|
||||
/// <summary>Opens a connection to the shared secret database.</summary>
|
||||
/// <param name="cancellationToken">A token to cancel the connect.</param>
|
||||
/// <returns>An opened connection. The caller owns disposal.</returns>
|
||||
public async Task<SqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = new SqlConnection(_options.ConnectionString);
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a command on <paramref name="connection"/> with the configured timeout applied.
|
||||
/// </summary>
|
||||
/// <param name="connection">The open connection.</param>
|
||||
/// <param name="commandText">The command text.</param>
|
||||
/// <param name="transaction">The ambient transaction, if any.</param>
|
||||
/// <returns>The configured command. The caller owns disposal.</returns>
|
||||
public SqlCommand CreateCommand(
|
||||
SqlConnection connection, string commandText, SqlTransaction? transaction = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
|
||||
SqlCommand command = connection.CreateCommand();
|
||||
command.CommandText = commandText;
|
||||
command.CommandTimeout = CommandTimeoutSeconds;
|
||||
command.Transaction = transaction;
|
||||
return command;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions the shared hub schema at startup in hub-replication mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from the core migration hosted service on purpose: in hub mode there are <em>two</em>
|
||||
/// stores to provision — the node's local one (handled by the core service through the
|
||||
/// <c>ISecretsStoreMigrator</c> seam) and the shared hub, which no node "owns". Every node runs this
|
||||
/// idempotently at startup, so the hub exists as soon as any node has started, with no separate
|
||||
/// provisioning step for an operator to forget.
|
||||
/// </remarks>
|
||||
/// <param name="migrator">The hub migrator.</param>
|
||||
/// <param name="options">Core secrets options, for the shared startup-migration switch.</param>
|
||||
internal sealed class SqlServerHubMigrationHostedService(
|
||||
SqlServerSecretsStoreMigrator migrator,
|
||||
IOptions<SecretsOptions> options) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Value.RunMigrationsOnStartup)
|
||||
{
|
||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes local writes to the shared SQL-Server hub, so a secret written on one node is
|
||||
/// immediately visible to every other node that syncs against the same hub.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The row is written through <see cref="ISecretStore.ApplyReplicatedAsync"/>, not
|
||||
/// <see cref="ISecretStore.UpsertAsync"/> — the hub must record the row <em>verbatim</em>, keeping
|
||||
/// the originating node's revision and <c>updated_utc</c>. An upsert would stamp the hub's own
|
||||
/// revision and timestamp, and the row would then look newer than the writer's own copy, so it
|
||||
/// would bounce straight back on the next sync.
|
||||
/// </remarks>
|
||||
/// <param name="hub">The shared hub store.</param>
|
||||
public sealed class SqlServerSecretReplicator(ISecretReplicationHub hub) : ISecretReplicator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return hub.ApplyReplicatedAsync(row, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-Server-backed <see cref="ISecretStore"/>. Holds the same envelope-encrypted
|
||||
/// <see cref="StoredSecret"/> rows as the SQLite store, in a database every node of a cluster can
|
||||
/// reach — so one write is visible everywhere with no replication at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The database holds <b>ciphertext only</b>. The KEK stays per-node and out of the database, so a
|
||||
/// DBA, a backup tape, or a replica cannot decrypt anything. The corollary is a hard operational
|
||||
/// constraint: every node must resolve the <em>same</em> KEK, because a row whose <c>kek_id</c> does
|
||||
/// not match the local provider fails closed on resolve.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Semantics are deliberately identical to <c>SqliteSecretStore</c> — revision bump on upsert,
|
||||
/// created stamps preserved on overwrite, verbatim last-writer-wins apply, compare-and-swap rewrap.
|
||||
/// A cluster can run both stores at once (nodes on local SQLite replicating against a shared hub),
|
||||
/// so a divergence between the two would be a silent convergence bug.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared secret database.</param>
|
||||
public sealed class SqlServerSecretStore(SecretsSqlServerConnectionFactory connectionFactory)
|
||||
: ISecretStore, ISecretReplicationHub
|
||||
{
|
||||
// All columns of the secret table, in schema order, for full-row reads.
|
||||
private const string AllColumns =
|
||||
"name, description, content_type, ciphertext, nonce, tag, wrapped_dek, wrap_nonce, wrap_tag, " +
|
||||
"kek_id, revision, is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by";
|
||||
|
||||
// The safe metadata projection — deliberately excludes every ciphertext / crypto BLOB column.
|
||||
private const string MetadataColumns =
|
||||
"name, description, content_type, kek_id, revision, is_deleted, created_utc, updated_utc, created_by, updated_by";
|
||||
|
||||
private string Secret => $"[{connectionFactory.SchemaName}].[{SqlServerSecretsSchema.SecretTable}]";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(
|
||||
connection, $"SELECT {AllColumns} FROM {Secret} WHERE name = @name;");
|
||||
command.Parameters.AddWithValue("@name", name.Value);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false)
|
||||
? ReadStoredSecret(reader)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
string now = DateTimeOffset.UtcNow.ToString("O");
|
||||
|
||||
// MERGE establishes revision 0 / created==updated==now on insert; on match it overwrites the
|
||||
// crypto material in place, bumps the revision, refreshes updated_utc/updated_by, and clears
|
||||
// any tombstone — but deliberately preserves created_utc / created_by (the SQLite store's
|
||||
// ON CONFLICT semantics, expressed in T-SQL).
|
||||
//
|
||||
// HOLDLOCK is not optional: without it MERGE takes only an update lock on the key range and
|
||||
// two concurrent nodes upserting the same new name can both miss the match and both INSERT,
|
||||
// producing a primary-key violation instead of one insert + one update.
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
|
||||
MERGE {Secret} WITH (HOLDLOCK) AS target
|
||||
USING (SELECT @name AS name) AS source
|
||||
ON target.name = source.name
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
description = @description,
|
||||
content_type = @content_type,
|
||||
ciphertext = @ciphertext,
|
||||
nonce = @nonce,
|
||||
tag = @tag,
|
||||
wrapped_dek = @wrapped_dek,
|
||||
wrap_nonce = @wrap_nonce,
|
||||
wrap_tag = @wrap_tag,
|
||||
kek_id = @kek_id,
|
||||
revision = target.revision + 1,
|
||||
updated_utc = @now,
|
||||
updated_by = @updated_by,
|
||||
is_deleted = 0,
|
||||
deleted_utc = NULL
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
name, description, content_type, ciphertext, nonce, tag,
|
||||
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
|
||||
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
|
||||
VALUES (
|
||||
@name, @description, @content_type, @ciphertext, @nonce, @tag,
|
||||
@wrapped_dek, @wrap_nonce, @wrap_tag, @kek_id, 0,
|
||||
0, NULL, @now, @now, @created_by, @updated_by);
|
||||
""");
|
||||
BindCryptoColumns(command, row);
|
||||
command.Parameters.AddWithValue("@now", now);
|
||||
command.Parameters.AddWithValue("@created_by", (object?)row.CreatedBy ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
|
||||
{
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
string now = DateTimeOffset.UtcNow.ToString("O");
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
|
||||
UPDATE {Secret} SET
|
||||
is_deleted = 1,
|
||||
deleted_utc = @now,
|
||||
revision = revision + 1,
|
||||
updated_utc = @now,
|
||||
updated_by = @actor
|
||||
WHERE name = @name AND is_deleted = 0;
|
||||
""");
|
||||
command.Parameters.AddWithValue("@now", now);
|
||||
command.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@name", name.Value);
|
||||
|
||||
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
|
||||
{
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(
|
||||
connection,
|
||||
$"SELECT {MetadataColumns} FROM {Secret} WHERE (@include_deleted = 1 OR is_deleted = 0) ORDER BY name;");
|
||||
command.Parameters.AddWithValue("@include_deleted", includeDeleted ? 1 : 0);
|
||||
|
||||
var results = new List<SecretMetadata>();
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new SecretMetadata
|
||||
{
|
||||
Name = new SecretName(reader.GetString(0)),
|
||||
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
|
||||
KekId = reader.GetString(3),
|
||||
Revision = reader.GetInt64(4),
|
||||
IsDeleted = reader.GetBoolean(5),
|
||||
CreatedUtc = ParseUtc(reader.GetString(6)),
|
||||
UpdatedUtc = ParseUtc(reader.GetString(7)),
|
||||
CreatedBy = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
UpdatedBy = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(
|
||||
connection, $"SELECT name, revision, updated_utc, is_deleted FROM {Secret} ORDER BY name;");
|
||||
|
||||
var results = new List<SecretManifestEntry>();
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new SecretManifestEntry
|
||||
{
|
||||
Name = new SecretName(reader.GetString(0)),
|
||||
Revision = reader.GetInt64(1),
|
||||
UpdatedUtc = ParseUtc(reader.GetString(2)),
|
||||
IsDeleted = reader.GetBoolean(3),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlTransaction transaction = (SqlTransaction)
|
||||
await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct).ConfigureAwait(false);
|
||||
|
||||
// Read the local (updated_utc, revision) so we can apply last-writer-wins. UPDLOCK/HOLDLOCK
|
||||
// keeps another node from slipping a newer row in between this read and the write below —
|
||||
// Serializable alone would not lock a key range that currently has no row.
|
||||
await using (SqlCommand read = connectionFactory.CreateCommand(
|
||||
connection,
|
||||
$"SELECT updated_utc, revision FROM {Secret} WITH (UPDLOCK, HOLDLOCK) WHERE name = @name;",
|
||||
transaction))
|
||||
{
|
||||
read.Parameters.AddWithValue("@name", row.Name.Value);
|
||||
|
||||
await using SqlDataReader reader = await read.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
DateTimeOffset localUpdated = ParseUtc(reader.GetString(0));
|
||||
long localRevision = reader.GetInt64(1);
|
||||
|
||||
// Routed through the shared predicate so every store agrees on ties.
|
||||
if (!SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, localUpdated, localRevision))
|
||||
{
|
||||
await reader.CloseAsync().ConfigureAwait(false);
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the incoming row VERBATIM — its own revision, timestamps, tombstone flag, and crypto
|
||||
// material — with no revision bump (this is the replication path, not a local write).
|
||||
await using (SqlCommand upsert = connectionFactory.CreateCommand(connection, $"""
|
||||
MERGE {Secret} WITH (HOLDLOCK) AS target
|
||||
USING (SELECT @name AS name) AS source
|
||||
ON target.name = source.name
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
description = @description,
|
||||
content_type = @content_type,
|
||||
ciphertext = @ciphertext,
|
||||
nonce = @nonce,
|
||||
tag = @tag,
|
||||
wrapped_dek = @wrapped_dek,
|
||||
wrap_nonce = @wrap_nonce,
|
||||
wrap_tag = @wrap_tag,
|
||||
kek_id = @kek_id,
|
||||
revision = @revision,
|
||||
is_deleted = @is_deleted,
|
||||
deleted_utc = @deleted_utc,
|
||||
created_utc = @created_utc,
|
||||
updated_utc = @updated_utc,
|
||||
created_by = @created_by,
|
||||
updated_by = @updated_by
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
name, description, content_type, ciphertext, nonce, tag,
|
||||
wrapped_dek, wrap_nonce, wrap_tag, kek_id, revision,
|
||||
is_deleted, deleted_utc, created_utc, updated_utc, created_by, updated_by)
|
||||
VALUES (
|
||||
@name, @description, @content_type, @ciphertext, @nonce, @tag,
|
||||
@wrapped_dek, @wrap_nonce, @wrap_tag, @kek_id, @revision,
|
||||
@is_deleted, @deleted_utc, @created_utc, @updated_utc, @created_by, @updated_by);
|
||||
""", transaction))
|
||||
{
|
||||
BindCryptoColumns(upsert, row);
|
||||
upsert.Parameters.AddWithValue("@revision", row.Revision);
|
||||
upsert.Parameters.AddWithValue("@is_deleted", row.IsDeleted ? 1 : 0);
|
||||
upsert.Parameters.AddWithValue("@deleted_utc", (object?)row.DeletedUtc?.ToString("O") ?? DBNull.Value);
|
||||
upsert.Parameters.AddWithValue("@created_utc", row.CreatedUtc.ToString("O"));
|
||||
upsert.Parameters.AddWithValue("@updated_utc", row.UpdatedUtc.ToString("O"));
|
||||
upsert.Parameters.AddWithValue("@created_by", (object?)row.CreatedBy ?? DBNull.Value);
|
||||
upsert.Parameters.AddWithValue("@updated_by", (object?)row.UpdatedBy ?? DBNull.Value);
|
||||
|
||||
await upsert.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rewrappedRow);
|
||||
ArgumentNullException.ThrowIfNull(expectedCurrentWrappedDek);
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Overwrite ONLY the KEK-wrap envelope + kek_id. Revision, updated_utc/by, the sealed body,
|
||||
// and the tombstone state are deliberately untouched — a re-wrap is not a logical change, so
|
||||
// it must not bump the revision or updated timestamp (see ISecretStore.ApplyRewrapAsync).
|
||||
//
|
||||
// Compare-and-swap on wrapped_dek: a concurrent set/rotate generates a fresh (random) DEK
|
||||
// wrap, so this matches 0 rows instead of pairing this stale re-wrap with a newer body —
|
||||
// which would leave the row permanently undecryptable. The caller re-processes a 0-row miss.
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
|
||||
UPDATE {Secret} SET
|
||||
wrapped_dek = @wrapped_dek,
|
||||
wrap_nonce = @wrap_nonce,
|
||||
wrap_tag = @wrap_tag,
|
||||
kek_id = @kek_id
|
||||
WHERE name = @name AND wrapped_dek = @expected_wrapped_dek;
|
||||
""");
|
||||
command.Parameters.AddWithValue("@wrapped_dek", rewrappedRow.WrappedDek);
|
||||
command.Parameters.AddWithValue("@wrap_nonce", rewrappedRow.WrapNonce);
|
||||
command.Parameters.AddWithValue("@wrap_tag", rewrappedRow.WrapTag);
|
||||
command.Parameters.AddWithValue("@kek_id", rewrappedRow.KekId);
|
||||
command.Parameters.AddWithValue("@name", rewrappedRow.Name.Value);
|
||||
command.Parameters.AddWithValue("@expected_wrapped_dek", expectedCurrentWrappedDek);
|
||||
|
||||
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches several rows by name in one round trip — the replication fetch path. Names not
|
||||
/// present are simply absent from the result.
|
||||
/// </summary>
|
||||
/// <param name="names">The names to fetch.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The matching encrypted rows.</returns>
|
||||
public async Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(names);
|
||||
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// One parameter per name — still fully parameterized, no name text is interpolated.
|
||||
string[] placeholders = [.. names.Select((_, i) => $"@n{i.ToString(CultureInfo.InvariantCulture)}")];
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(
|
||||
connection,
|
||||
$"SELECT {AllColumns} FROM {Secret} WHERE name IN ({string.Join(", ", placeholders)});");
|
||||
|
||||
for (int i = 0; i < names.Count; i++)
|
||||
{
|
||||
command.Parameters.AddWithValue(placeholders[i], names[i].Value);
|
||||
}
|
||||
|
||||
var results = new List<StoredSecret>(names.Count);
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadStoredSecret(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns
|
||||
// shared by every insert path.
|
||||
private static void BindCryptoColumns(SqlCommand command, StoredSecret row)
|
||||
{
|
||||
command.Parameters.AddWithValue("@name", row.Name.Value);
|
||||
command.Parameters.AddWithValue("@description", (object?)row.Description ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@content_type", row.ContentType.ToString());
|
||||
command.Parameters.AddWithValue("@ciphertext", row.Ciphertext);
|
||||
command.Parameters.AddWithValue("@nonce", row.Nonce);
|
||||
command.Parameters.AddWithValue("@tag", row.Tag);
|
||||
command.Parameters.AddWithValue("@wrapped_dek", row.WrappedDek);
|
||||
command.Parameters.AddWithValue("@wrap_nonce", row.WrapNonce);
|
||||
command.Parameters.AddWithValue("@wrap_tag", row.WrapTag);
|
||||
command.Parameters.AddWithValue("@kek_id", row.KekId);
|
||||
}
|
||||
|
||||
private static StoredSecret ReadStoredSecret(SqlDataReader reader) => new()
|
||||
{
|
||||
Name = new SecretName(reader.GetString(0)),
|
||||
Description = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
ContentType = Enum.Parse<SecretContentType>(reader.GetString(2)),
|
||||
Ciphertext = reader.GetFieldValue<byte[]>(3),
|
||||
Nonce = reader.GetFieldValue<byte[]>(4),
|
||||
Tag = reader.GetFieldValue<byte[]>(5),
|
||||
WrappedDek = reader.GetFieldValue<byte[]>(6),
|
||||
WrapNonce = reader.GetFieldValue<byte[]>(7),
|
||||
WrapTag = reader.GetFieldValue<byte[]>(8),
|
||||
KekId = reader.GetString(9),
|
||||
Revision = reader.GetInt64(10),
|
||||
IsDeleted = reader.GetBoolean(11),
|
||||
DeletedUtc = reader.IsDBNull(12) ? null : ParseUtc(reader.GetString(12)),
|
||||
CreatedUtc = ParseUtc(reader.GetString(13)),
|
||||
UpdatedUtc = ParseUtc(reader.GetString(14)),
|
||||
CreatedBy = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
UpdatedBy = reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||
};
|
||||
|
||||
private static DateTimeOffset ParseUtc(string value) =>
|
||||
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a node's local secret store converged with the shared SQL-Server hub, in hub-replication
|
||||
/// mode. Runs a bidirectional anti-entropy sweep on an interval: pulls rows the hub holds newer,
|
||||
/// pushes rows the node holds newer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the durability guarantee behind best-effort publishing. Live publishes make a write
|
||||
/// visible in milliseconds; this sweep is what makes it <em>certain</em>. A node that was offline
|
||||
/// while a peer wrote catches up on its next sweep, and a write made while the hub was unreachable
|
||||
/// is pushed on the next sweep instead of being lost.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sweep never throws out of <see cref="ExecuteAsync"/>: a hub outage must degrade the node to
|
||||
/// "serving its last-known-good local secrets" rather than crash the host. Failures are logged and
|
||||
/// retried on the next tick.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class SqlServerSecretSyncService : BackgroundService
|
||||
{
|
||||
private readonly SecretReplicationReconciler _reconciler;
|
||||
private readonly ISecretStore _local;
|
||||
private readonly ISecretReplicationHub _hub;
|
||||
private readonly SqlServerSecretsOptions _options;
|
||||
private readonly ILogger<SqlServerSecretSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the sync service.</summary>
|
||||
/// <param name="local">The node's local store.</param>
|
||||
/// <param name="hub">The shared hub store.</param>
|
||||
/// <param name="cacheInvalidator">Resolver-cache seam, evicted for every applied row.</param>
|
||||
/// <param name="options">SQL-Server replication options.</param>
|
||||
/// <param name="logger">Logger for sweep outcomes and failures.</param>
|
||||
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
|
||||
public SqlServerSecretSyncService(
|
||||
ISecretStore local,
|
||||
ISecretReplicationHub hub,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
IOptions<SqlServerSecretsOptions> options,
|
||||
ILogger<SqlServerSecretSyncService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_local = local ?? throw new ArgumentNullException(nameof(local));
|
||||
_hub = hub ?? throw new ArgumentNullException(nameof(hub));
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
_local,
|
||||
cacheInvalidator,
|
||||
onRowFailed: (name, ex) => LogRowDiscarded(ex, name.Value));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_options.SyncOnStartup)
|
||||
{
|
||||
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using PeriodicTimer timer = new(_options.SyncInterval, _timeProvider);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one bidirectional reconcile against the hub.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
/// <returns>How many rows were pulled from and pushed to the hub.</returns>
|
||||
internal async Task<(int Pulled, int Pushed)> SweepAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> hubManifest =
|
||||
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Pull first: adopting the hub's newer rows before computing the push set means a row the hub
|
||||
// already had newer is not then pushed straight back at it.
|
||||
int pulled = await _reconciler
|
||||
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<SecretManifestEntry> localManifest =
|
||||
await _local.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<SecretName> push =
|
||||
SecretReplicationReconciler.ComputePushSet(localManifest, hubManifest);
|
||||
|
||||
int pushed = 0;
|
||||
|
||||
if (push.Count > 0)
|
||||
{
|
||||
IReadOnlyList<StoredSecret> rows =
|
||||
await _reconciler.ReadLocalAsync(push, ct).ConfigureAwait(false);
|
||||
|
||||
foreach (StoredSecret row in rows)
|
||||
{
|
||||
// Verbatim, like the replicator — the hub records the originating node's revision.
|
||||
await _hub.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
|
||||
pushed++;
|
||||
}
|
||||
}
|
||||
|
||||
return (pulled, pushed);
|
||||
}
|
||||
|
||||
private async Task SweepSafelyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
(int pulled, int pushed) = await SweepAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (pulled > 0 || pushed > 0)
|
||||
{
|
||||
LogSweepConverged(pulled, pushed);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Host shutdown — not a failure.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSweepFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secret hub sync converged: pulled {Pulled} row(s) from the hub, pushed {Pushed} row(s) to it.")]
|
||||
private partial void LogSweepConverged(int pulled, int pushed);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Secret hub sync failed; the node continues serving its local store and will retry on the next interval.")]
|
||||
private partial void LogSweepFailed(Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 3,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Schema constants and DDL for the shared SQL-Server secret store. Mirrors
|
||||
/// <c>ZB.MOM.WW.Secrets.Sqlite.SqliteSecretsSchema</c> column-for-column so a row means exactly the
|
||||
/// same thing in either store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Timestamps are ISO-8601 <c>nvarchar</c>, not <c>datetimeoffset</c>.</b> This looks wrong for a
|
||||
/// SQL-Server schema and is deliberate: the same <see cref="ZB.MOM.WW.Secrets.Abstractions.StoredSecret"/>
|
||||
/// rows replicate between this store and SQLite (which stores <c>"O"</c>-format text), and
|
||||
/// last-writer-wins compares those timestamps for equality. <c>datetimeoffset</c> rounds to 100ns
|
||||
/// while .NET <c>DateTimeOffset</c> ticks are also 100ns but the round-trip through the driver is
|
||||
/// not guaranteed bit-identical across providers — a sub-tick difference would turn a tie into a
|
||||
/// spurious "newer", and two nodes would then overwrite each other forever. Storing the exact
|
||||
/// round-trippable text both providers already agree on removes the failure mode entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cost is that <c>ORDER BY updated_utc</c> is a lexicographic sort. That is still chronological
|
||||
/// for <c>"O"</c>-format UTC strings (fixed-width, zero-padded, always <c>+00:00</c>), which is what
|
||||
/// the store writes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SqlServerSecretsSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// The schema version this build creates and supports. The migrator refuses a database whose
|
||||
/// recorded version is <em>newer</em> than this.
|
||||
/// </summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Default schema the secret tables live in.</summary>
|
||||
public const string DefaultSchemaName = "zbsecrets";
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
/// <summary>Name of the table storing envelope-encrypted secret records.</summary>
|
||||
public const string SecretTable = "secret";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the idempotent DDL that provisions the schema, both tables, and the supporting index
|
||||
/// in <paramref name="schemaName"/>.
|
||||
/// </summary>
|
||||
/// <param name="schemaName">The already-validated SQL schema name to create the objects in.</param>
|
||||
/// <returns>The DDL batch.</returns>
|
||||
/// <remarks>
|
||||
/// <paramref name="schemaName"/> is interpolated, not parameterized — T-SQL has no parameter form
|
||||
/// for an object name. It is re-validated here against the same strict identifier allow-list
|
||||
/// <see cref="SqlServerSecretsOptions.SchemaName"/> enforces, and bracket-quoted below. The
|
||||
/// duplicate check is deliberate: this method is <c>public</c>, so it must defend itself rather
|
||||
/// than trust that every caller routed through the options type.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException"><paramref name="schemaName"/> is not a plain identifier.</exception>
|
||||
public static string CreateSchemaDdl(string schemaName)
|
||||
{
|
||||
SqlServerSecretsOptions.ValidateIdentifier(schemaName);
|
||||
|
||||
return $"""
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}')
|
||||
EXEC(N'CREATE SCHEMA [{schemaName}]');
|
||||
|
||||
IF OBJECT_ID(N'[{schemaName}].[{SchemaVersionTable}]', N'U') IS NULL
|
||||
CREATE TABLE [{schemaName}].[{SchemaVersionTable}] (
|
||||
id INT NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
version INT NOT NULL,
|
||||
applied_utc NVARCHAR(33) NOT NULL
|
||||
);
|
||||
|
||||
IF OBJECT_ID(N'[{schemaName}].[{SecretTable}]', N'U') IS NULL
|
||||
CREATE TABLE [{schemaName}].[{SecretTable}] (
|
||||
name NVARCHAR(450) NOT NULL PRIMARY KEY,
|
||||
description NVARCHAR(MAX) NULL,
|
||||
content_type NVARCHAR(64) NOT NULL,
|
||||
ciphertext VARBINARY(MAX) NOT NULL,
|
||||
nonce VARBINARY(MAX) NOT NULL,
|
||||
tag VARBINARY(MAX) NOT NULL,
|
||||
wrapped_dek VARBINARY(MAX) NOT NULL,
|
||||
wrap_nonce VARBINARY(MAX) NOT NULL,
|
||||
wrap_tag VARBINARY(MAX) NOT NULL,
|
||||
kek_id NVARCHAR(256) NOT NULL,
|
||||
revision BIGINT NOT NULL CONSTRAINT DF_secret_revision DEFAULT 0,
|
||||
is_deleted BIT NOT NULL CONSTRAINT DF_secret_is_deleted DEFAULT 0,
|
||||
deleted_utc NVARCHAR(33) NULL,
|
||||
created_utc NVARCHAR(33) NOT NULL,
|
||||
updated_utc NVARCHAR(33) NOT NULL,
|
||||
created_by NVARCHAR(256) NULL,
|
||||
updated_by NVARCHAR(256) NULL
|
||||
);
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = N'ix_secret_updated_utc'
|
||||
AND object_id = OBJECT_ID(N'[{schemaName}].[{SecretTable}]'))
|
||||
CREATE INDEX ix_secret_updated_utc
|
||||
ON [{schemaName}].[{SecretTable}] (updated_utc);
|
||||
""";
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the shared SQL-Server secret schema and records the applied version. Idempotent: safe to
|
||||
/// run on every node at every startup, including concurrently. Refuses to run against a database
|
||||
/// whose recorded version is newer than this build supports.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared secret database.</param>
|
||||
public sealed class SqlServerSecretsStoreMigrator(SecretsSqlServerConnectionFactory connectionFactory)
|
||||
: ISecretsStoreMigrator
|
||||
{
|
||||
// Concurrent Serializable DDL against the system catalogs can pick a deadlock victim (error
|
||||
// 1205) even though the IF NOT EXISTS guards make the logic itself safe. Every node runs this at
|
||||
// every startup, so a virgin database being provisioned by N nodes at once is the NORMAL case,
|
||||
// not a rare race — and an unretried victim aborts that node's whole host at startup. Retrying
|
||||
// is correct precisely because the migration is idempotent.
|
||||
private const int MaxDeadlockRetries = 5;
|
||||
private const int SqlDeadlockVictimErrorNumber = 1205;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="SecretStoreMigrationException">
|
||||
/// The recorded schema version is newer than <see cref="SqlServerSecretsSchema.CurrentVersion"/>.
|
||||
/// </exception>
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MigrateOnceAsync(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (SqlException ex)
|
||||
when (ex.Number == SqlDeadlockVictimErrorNumber && attempt < MaxDeadlockRetries)
|
||||
{
|
||||
// Staggered back-off so two victims do not retry in lockstep and deadlock again.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MigrateOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string schema = connectionFactory.SchemaName;
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Serializable: unlike the single-writer SQLite store, EVERY node in the cluster runs this
|
||||
// migration at startup, so concurrent first-boots are the normal case rather than a rare
|
||||
// race. The isolation level plus the IF NOT EXISTS guards make a simultaneous provision by
|
||||
// two nodes converge on one schema instead of one of them failing with "object exists".
|
||||
await using SqlTransaction transaction = (SqlTransaction)
|
||||
await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
int existingVersion =
|
||||
await ReadExistingSchemaVersionAsync(connection, transaction, schema, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existingVersion > SqlServerSecretsSchema.CurrentVersion)
|
||||
{
|
||||
throw new SecretStoreMigrationException(
|
||||
$"Shared secret database schema version {existingVersion} is newer than supported version " +
|
||||
$"{SqlServerSecretsSchema.CurrentVersion}. Upgrade this node before it writes to the shared store.");
|
||||
}
|
||||
|
||||
await using (SqlCommand ddl = connectionFactory.CreateCommand(
|
||||
connection, SqlServerSecretsSchema.CreateSchemaDdl(schema), transaction))
|
||||
{
|
||||
await ddl.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (SqlCommand version = connectionFactory.CreateCommand(
|
||||
connection,
|
||||
$"""
|
||||
MERGE [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] AS target
|
||||
USING (SELECT 1 AS id, @version AS version, @applied_utc AS applied_utc) AS source
|
||||
ON target.id = source.id
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET version = source.version, applied_utc = source.applied_utc
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
|
||||
""",
|
||||
transaction))
|
||||
{
|
||||
version.Parameters.AddWithValue("@version", SqlServerSecretsSchema.CurrentVersion);
|
||||
version.Parameters.AddWithValue("@applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
await version.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ReadExistingSchemaVersionAsync(
|
||||
SqlConnection connection,
|
||||
SqlTransaction transaction,
|
||||
string schema,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqlCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
|
||||
// OBJECT_ID returns NULL for an absent table, so this reads 0 on a virgin database without
|
||||
// needing the table to exist first.
|
||||
command.CommandText = $"""
|
||||
IF OBJECT_ID(N'[{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}]', N'U') IS NULL
|
||||
SELECT 0;
|
||||
ELSE
|
||||
SELECT ISNULL((SELECT version FROM [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] WHERE id = 1), 0);
|
||||
""";
|
||||
|
||||
object? version = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return version is null || version == DBNull.Value
|
||||
? 0
|
||||
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Secrets.Replicator.SqlServer</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Shared SQL-Server secret store and hub replication for the ZB.MOM.WW SCADA family — cluster-wide, ciphertext-only secrets.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,8 +4,9 @@ namespace ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// The single-node default <see cref="ISecretReplicator"/>: publishing a row is a no-op because
|
||||
/// there are no peers to broadcast to. The deferred <c>ZB.MOM.WW.Secrets.Akka</c> package replaces
|
||||
/// this registration with a real cluster replicator for clustered deployments.
|
||||
/// there are no peers to broadcast to. The <c>ZB.MOM.WW.Secrets.Replication.Akka</c> and
|
||||
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> packages replace this registration with a real
|
||||
/// replicator for clustered deployments.
|
||||
/// </summary>
|
||||
public sealed class NoOpSecretReplicator : ISecretReplicator
|
||||
{
|
||||
|
||||
+8
-3
@@ -1,16 +1,21 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the secrets SQLite schema migration at application startup when
|
||||
/// Runs the secret-store schema migration at application startup when
|
||||
/// <see cref="SecretsOptions.RunMigrationsOnStartup"/> is <see langword="true"/>. The migration is
|
||||
/// idempotent, so repeated restarts are safe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Depends on the <see cref="ISecretsStoreMigrator"/> seam rather than a concrete migrator, so the
|
||||
/// same hosted service provisions the local SQLite store or the shared SQL-Server store depending
|
||||
/// only on which migrator the application registered.
|
||||
/// </remarks>
|
||||
internal sealed class SecretsMigrationHostedService(
|
||||
SqliteSecretsStoreMigrator migrator,
|
||||
ISecretsStoreMigrator migrator,
|
||||
IOptions<SecretsOptions> options) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
+14
-2
@@ -51,9 +51,15 @@ public static class SecretsServiceCollectionExtensions
|
||||
services.TryAddSingleton<ISecretCipher>(sp =>
|
||||
new AesGcmEnvelopeCipher(sp.GetRequiredService<IMasterKeyProvider>()));
|
||||
|
||||
services.TryAddSingleton<ISecretStore>(sp =>
|
||||
// Registered BOTH concretely and behind the interface, forwarding to the one singleton.
|
||||
// The concrete registration is what lets a replication package resolve the *undecorated*
|
||||
// local store when it replaces ISecretStore with a decorator — asking the container for
|
||||
// ISecretStore there would resolve the decorator itself and recurse forever.
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqliteSecretStore(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
|
||||
|
||||
services.TryAddSingleton<ISecretStore>(sp => sp.GetRequiredService<SqliteSecretStore>());
|
||||
|
||||
services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>();
|
||||
|
||||
// ONE shared resolver instance backs both the read seam (ISecretResolver) and the
|
||||
@@ -81,10 +87,16 @@ public static class SecretsServiceCollectionExtensions
|
||||
services.TryAddSingleton<ISecretCacheInvalidator>(sp => sp.GetRequiredService<DefaultSecretResolver>());
|
||||
|
||||
// Migrator: singleton, constructed from the already-registered connection factory. Needed
|
||||
// before any store operations so the schema exists.
|
||||
// before any store operations so the schema exists. Registered BOTH concretely (for callers
|
||||
// that want the SQLite migrator specifically) and behind the ISecretsStoreMigrator seam that
|
||||
// the hosted service and CLI consume — a replication package that swaps the store registers
|
||||
// its own ISecretsStoreMigrator BEFORE calling AddZbSecrets, and TryAdd leaves it in place.
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqliteSecretsStoreMigrator(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
|
||||
|
||||
services.TryAddSingleton<ISecretsStoreMigrator>(sp =>
|
||||
sp.GetRequiredService<SqliteSecretsStoreMigrator>());
|
||||
|
||||
// Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup.
|
||||
services.AddHostedService<SecretsMigrationHostedService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replication;
|
||||
|
||||
/// <summary>
|
||||
/// Decorates a local <see cref="ISecretStore"/> so every successful local write is published to
|
||||
/// peers through <see cref="ISecretReplicator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is what turns the <see cref="ISecretReplicator"/> seam from a declaration into behaviour.
|
||||
/// Decorating the store — rather than calling the replicator from each write site — means the CLI,
|
||||
/// the Blazor UI, and any future caller all replicate automatically, and no new write path can
|
||||
/// forget to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Publishing is best-effort.</b> A transport failure is logged and swallowed: the local write is
|
||||
/// already durable, and refusing it because a peer was unreachable would make every node a single
|
||||
/// point of failure for every other. Propagation is then guaranteed by the transport's periodic
|
||||
/// anti-entropy sweep instead, which is why every transport in this library reconciles
|
||||
/// <em>bidirectionally</em> — a dropped publish is repaired by the next sweep rather than lost.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ApplyReplicatedAsync"/> deliberately does not publish (that would echo a peer's row
|
||||
/// straight back and, across three or more nodes, never stop). Neither does
|
||||
/// <see cref="ApplyRewrapAsync"/>: a re-wrap does not bump the revision, so it is invisible to
|
||||
/// last-writer-wins by design and a peer would discard it — KEK rotation is run once per independent
|
||||
/// store instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="inner">The local store doing the actual persistence.</param>
|
||||
/// <param name="replicator">The transport that publishes rows to peers.</param>
|
||||
/// <param name="logger">Logger for publish failures.</param>
|
||||
public sealed class ReplicatingSecretStore(
|
||||
ISecretStore inner,
|
||||
ISecretReplicator replicator,
|
||||
ILogger<ReplicatingSecretStore> logger) : ISecretStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
inner.GetAsync(name, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||
inner.ListAsync(includeDeleted, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
inner.GetManifestAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
inner.ApplyReplicatedAsync(row, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
||||
inner.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
await inner.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||
await PublishCurrentAsync(row.Name, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
|
||||
{
|
||||
bool deleted = await inner.DeleteAsync(name, actor, ct).ConfigureAwait(false);
|
||||
|
||||
if (deleted)
|
||||
{
|
||||
// The tombstone is the thing being replicated — a delete propagates as a newer row, not
|
||||
// as an absence, so peers converge on "deleted" instead of resurrecting it.
|
||||
await PublishCurrentAsync(name, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Re-reads the row the store actually persisted and publishes THAT, never the caller's input:
|
||||
// the store assigns the revision and updated_utc, and those two fields are exactly what peers
|
||||
// order by. Publishing the caller's copy would broadcast a row whose ordering keys are wrong.
|
||||
private async Task PublishCurrentAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
StoredSecret? persisted = await inner.GetAsync(name, ct).ConfigureAwait(false);
|
||||
|
||||
if (persisted is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await replicator.PublishAsync(persisted, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Deliberately swallowed — see the class remarks. Anti-entropy repairs it.
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to publish secret {SecretName} to peers; the local write succeeded and " +
|
||||
"anti-entropy will propagate it on the next sweep.",
|
||||
name.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replication;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic anti-entropy: compares a peer's manifest against the local store, pulls only
|
||||
/// the rows the peer holds a strictly newer copy of, and applies them last-writer-wins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the half of replication that is pure logic, deliberately separated from <em>how</em> the
|
||||
/// peer is reached. The SQL-Server package points it at a shared hub database; the Akka package
|
||||
/// points it at a cluster peer over pub/sub. Both get identical convergence behaviour, and the
|
||||
/// interesting edge cases (ties, tombstones, a peer that is behind) are testable offline without a
|
||||
/// database or a cluster.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reconciliation is a pull, never a push: it only ever writes rows the peer says are newer, so
|
||||
/// running it against a peer that is behind is a no-op rather than a regression. It is also safe to
|
||||
/// run concurrently with live replication — <see cref="ISecretStore.ApplyReplicatedAsync"/> re-checks
|
||||
/// the same predicate under its own transaction, so a row that a broadcast delivered first is simply
|
||||
/// ignored the second time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="local">The local store to bring up to date.</param>
|
||||
/// <param name="cacheInvalidator">
|
||||
/// Optional resolver-cache seam. When supplied, every applied name is evicted so a node picks up a
|
||||
/// peer's write within the reconcile interval instead of serving a stale plaintext for the rest of
|
||||
/// the cache TTL.
|
||||
/// </param>
|
||||
/// <param name="onRowFailed">
|
||||
/// Optional callback invoked when a single replicated row could not be applied. The row is skipped
|
||||
/// and the rest of the batch proceeds; without a callback the failure is silent, so transports
|
||||
/// should supply one that logs.
|
||||
/// </param>
|
||||
public sealed class SecretReplicationReconciler(
|
||||
ISecretStore local,
|
||||
ISecretCacheInvalidator? cacheInvalidator = null,
|
||||
Action<SecretName, Exception>? onRowFailed = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the names for which <paramref name="remote"/> holds a strictly newer row than
|
||||
/// <paramref name="localManifest"/> — including names absent locally, and including tombstones
|
||||
/// (a delete propagates as a newer row, not as an absence).
|
||||
/// </summary>
|
||||
/// <param name="localManifest">The local store's manifest.</param>
|
||||
/// <param name="remote">The peer's manifest.</param>
|
||||
/// <returns>The names to pull from the peer, in manifest order.</returns>
|
||||
public static IReadOnlyList<SecretName> ComputePullSet(
|
||||
IReadOnlyList<SecretManifestEntry> localManifest,
|
||||
IReadOnlyList<SecretManifestEntry> remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(localManifest);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
Dictionary<string, SecretManifestEntry> localByName =
|
||||
localManifest.ToDictionary(entry => entry.Name.Value, StringComparer.Ordinal);
|
||||
|
||||
var pull = new List<SecretName>();
|
||||
|
||||
foreach (SecretManifestEntry remoteEntry in remote)
|
||||
{
|
||||
// Absent locally, or the peer's copy wins LWW. A name we hold and the peer does not is
|
||||
// deliberately NOT handled here: this side pulls, the peer's own reconcile pushes.
|
||||
if (!localByName.TryGetValue(remoteEntry.Name.Value, out SecretManifestEntry? localEntry) ||
|
||||
SecretLastWriterWins.IsNewer(remoteEntry, localEntry))
|
||||
{
|
||||
pull.Add(remoteEntry.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return pull;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names the local store holds a strictly newer row for than <paramref name="remote"/>
|
||||
/// — the mirror of <see cref="ComputePullSet"/>, used to push local writes a peer is missing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every transport here reconciles in <b>both</b> directions, and this is why: publishing a write
|
||||
/// is best-effort (see <c>ReplicatingSecretStore</c>), so without a push sweep a write made while
|
||||
/// a peer was unreachable would sit on one node forever — the peer never learns the name exists,
|
||||
/// so its own pull can never ask for it.
|
||||
/// </remarks>
|
||||
/// <param name="localManifest">The local store's manifest.</param>
|
||||
/// <param name="remote">The peer's manifest.</param>
|
||||
/// <returns>The names to push to the peer.</returns>
|
||||
public static IReadOnlyList<SecretName> ComputePushSet(
|
||||
IReadOnlyList<SecretManifestEntry> localManifest,
|
||||
IReadOnlyList<SecretManifestEntry> remote) =>
|
||||
// Exactly the pull computation with the two sides swapped: "what does the peer need from me"
|
||||
// is "what would I need, if I were the peer".
|
||||
ComputePullSet(remote, localManifest);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the local store against <paramref name="remoteManifest"/>, fetching and applying
|
||||
/// every row the peer holds a newer copy of.
|
||||
/// </summary>
|
||||
/// <param name="remoteManifest">The peer's manifest.</param>
|
||||
/// <param name="fetchAsync">
|
||||
/// Fetches the encrypted rows for the requested names from the peer. May return fewer rows than
|
||||
/// requested (a row deleted between manifest and fetch); extra rows are applied normally.
|
||||
/// </param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The number of rows applied locally.</returns>
|
||||
public async Task<int> ReconcileAsync(
|
||||
IReadOnlyList<SecretManifestEntry> remoteManifest,
|
||||
Func<IReadOnlyList<SecretName>, CancellationToken, Task<IReadOnlyList<StoredSecret>>> fetchAsync,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remoteManifest);
|
||||
ArgumentNullException.ThrowIfNull(fetchAsync);
|
||||
|
||||
IReadOnlyList<SecretManifestEntry> localManifest =
|
||||
await local.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<SecretName> pull = ComputePullSet(localManifest, remoteManifest);
|
||||
|
||||
if (pull.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int applied = 0;
|
||||
|
||||
// Chunked, not fetched in one shot. A cold-starting node's pull set is the peer's ENTIRE
|
||||
// inventory, and an unbounded fetch breaks on real backends — SQL Server caps a command at
|
||||
// 2100 parameters, so a hub with more than that many secrets would fail the same way every
|
||||
// interval and the node would never receive a single row. Chunking also bounds the size of
|
||||
// one replication message on the wire.
|
||||
foreach (IReadOnlyList<SecretName> batch in Chunk(pull, FetchBatchSize))
|
||||
{
|
||||
IReadOnlyList<StoredSecret> rows = await fetchAsync(batch, ct).ConfigureAwait(false);
|
||||
applied += await ApplyAsync(rows, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names fetched per round trip. Well under SQL Server's 2100-parameter command limit, and small
|
||||
/// enough that one replication message stays a sane size.
|
||||
/// </summary>
|
||||
internal const int FetchBatchSize = 500;
|
||||
|
||||
private static IEnumerable<IReadOnlyList<SecretName>> Chunk(
|
||||
IReadOnlyList<SecretName> names, int size)
|
||||
{
|
||||
for (int offset = 0; offset < names.Count; offset += size)
|
||||
{
|
||||
yield return [.. names.Skip(offset).Take(size)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the local rows for <paramref name="names"/>, skipping any that vanished between the
|
||||
/// manifest and the read. Used by transports to assemble a push batch.
|
||||
/// </summary>
|
||||
/// <param name="names">The names to read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The encrypted rows that still exist locally.</returns>
|
||||
public async Task<IReadOnlyList<StoredSecret>> ReadLocalAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(names);
|
||||
|
||||
var rows = new List<StoredSecret>(names.Count);
|
||||
|
||||
foreach (SecretName name in names)
|
||||
{
|
||||
StoredSecret? row = await local.GetAsync(name, ct).ConfigureAwait(false);
|
||||
|
||||
if (row is not null)
|
||||
{
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies encrypted rows received from a peer, last-writer-wins, evicting the resolver cache for
|
||||
/// each. Used both by <see cref="ReconcileAsync"/> and by transports that receive live broadcasts.
|
||||
/// </summary>
|
||||
/// <param name="rows">The encrypted rows to apply.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The number of rows passed to the store.</returns>
|
||||
public async Task<int> ApplyAsync(IReadOnlyList<StoredSecret> rows, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
int applied = 0;
|
||||
|
||||
foreach (StoredSecret row in rows)
|
||||
{
|
||||
try
|
||||
{
|
||||
await local.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
|
||||
applied++;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Isolated per row, because a batch is not a transaction and must not be all-or-
|
||||
// nothing. One row a peer cannot supply cleanly (a hostile or buggy peer, a
|
||||
// constraint violation) would otherwise abandon every row after it — and since the
|
||||
// pull set is recomputed identically each sweep, in the same manifest order, the
|
||||
// SAME row would poison the SAME batch forever and every name behind it would never
|
||||
// converge. Drop the row, keep the batch.
|
||||
onRowFailed?.Invoke(row.Name, ex);
|
||||
continue;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Evicted unconditionally: the store may have ignored the row as stale, but an
|
||||
// eviction only costs the next resolve a re-read, whereas skipping one when the row
|
||||
// DID land serves a stale plaintext until the TTL expires.
|
||||
cacheInvalidator?.Invalidate(row.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
@@ -200,12 +200,9 @@ public sealed class SqliteSecretStore(SecretsSqliteConnectionFactory connectionF
|
||||
DateTimeOffset localUpdated = ParseUtc(reader.GetString(0));
|
||||
long localRevision = reader.GetInt64(1);
|
||||
|
||||
// Incoming wins only if strictly newer by (updated_utc, then revision).
|
||||
bool incomingIsNewer =
|
||||
row.UpdatedUtc > localUpdated ||
|
||||
(row.UpdatedUtc == localUpdated && row.Revision > localRevision);
|
||||
|
||||
if (!incomingIsNewer)
|
||||
// Incoming wins only if strictly newer by (updated_utc, then revision). Routed through
|
||||
// the shared predicate so every store agrees on ties — see SecretLastWriterWins.
|
||||
if (!SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, localUpdated, localRevision))
|
||||
{
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.Secrets.Sqlite;
|
||||
/// newer than this build supports.
|
||||
/// </summary>
|
||||
public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
|
||||
: ISecretsStoreMigrator
|
||||
{
|
||||
/// <summary>Applies the schema migration to the secret store.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+81
@@ -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());
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Serialization;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-contract tests. Secret ciphertext crosses the network as these bytes, so the round-trip is
|
||||
/// asserted field-by-field rather than by equality alone — a silently dropped crypto field would
|
||||
/// produce a row that stores fine and fails to decrypt much later, on another node.
|
||||
/// </summary>
|
||||
public sealed class SecretReplicationSerializerTests : TestKit
|
||||
{
|
||||
public SecretReplicationSerializerTests()
|
||||
: base(AkkaSecretsReplication.SerializationConfig)
|
||||
{
|
||||
}
|
||||
|
||||
private static StoredSecret Row(string name) => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
Description = "a description",
|
||||
ContentType = SecretContentType.ConnectionString,
|
||||
Ciphertext = [1, 2, 3, 250],
|
||||
Nonce = [4, 5, 6],
|
||||
Tag = [7, 8, 9],
|
||||
WrappedDek = [10, 11, 12],
|
||||
WrapNonce = [13, 14, 15],
|
||||
WrapTag = [16, 17, 18],
|
||||
KekId = "sha256:abc",
|
||||
Revision = 42,
|
||||
IsDeleted = true,
|
||||
DeletedUtc = new DateTimeOffset(2026, 3, 2, 1, 0, 0, TimeSpan.Zero),
|
||||
CreatedUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
CreatedBy = "alice",
|
||||
UpdatedBy = "bob",
|
||||
};
|
||||
|
||||
private object RoundTrip(object message)
|
||||
{
|
||||
Serializer serializer = Sys.Serialization.FindSerializerFor(message);
|
||||
Assert.IsType<SecretReplicationSerializer>(serializer);
|
||||
|
||||
byte[] bytes = serializer.ToBinary(message);
|
||||
string manifest = ((SerializerWithStringManifest)serializer).Manifest(message);
|
||||
|
||||
return ((SerializerWithStringManifest)serializer).FromBinary(bytes, manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretRowsMessage_round_trips_every_field()
|
||||
{
|
||||
StoredSecret original = Row("app/db-conn");
|
||||
var message = new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]);
|
||||
|
||||
var restored = (SecretRowsMessage)RoundTrip(message);
|
||||
StoredSecret result = restored.Rows.Single().ToStoredSecret();
|
||||
|
||||
Assert.Equal(original.Name.Value, result.Name.Value);
|
||||
Assert.Equal(original.Description, result.Description);
|
||||
Assert.Equal(original.ContentType, result.ContentType);
|
||||
Assert.Equal(original.Ciphertext, result.Ciphertext);
|
||||
Assert.Equal(original.Nonce, result.Nonce);
|
||||
Assert.Equal(original.Tag, result.Tag);
|
||||
Assert.Equal(original.WrappedDek, result.WrappedDek);
|
||||
Assert.Equal(original.WrapNonce, result.WrapNonce);
|
||||
Assert.Equal(original.WrapTag, result.WrapTag);
|
||||
Assert.Equal(original.KekId, result.KekId);
|
||||
Assert.Equal(original.Revision, result.Revision);
|
||||
Assert.Equal(original.IsDeleted, result.IsDeleted);
|
||||
Assert.Equal(original.DeletedUtc, result.DeletedUtc);
|
||||
Assert.Equal(original.CreatedUtc, result.CreatedUtc);
|
||||
Assert.Equal(original.UpdatedUtc, result.UpdatedUtc);
|
||||
Assert.Equal(original.CreatedBy, result.CreatedBy);
|
||||
Assert.Equal(original.UpdatedBy, result.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamps_survive_the_wire_exactly()
|
||||
{
|
||||
// Last-writer-wins compares UpdatedUtc for EQUALITY to break revision ties. A serializer that
|
||||
// rounded to the millisecond would turn a tie into a spurious "newer" and two nodes would
|
||||
// then overwrite each other forever, so tick-exactness is a correctness requirement.
|
||||
StoredSecret original = Row("app/precise") with
|
||||
{
|
||||
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 12, 34, 56, TimeSpan.Zero).AddTicks(1234567),
|
||||
};
|
||||
|
||||
var restored = (SecretRowsMessage)RoundTrip(
|
||||
new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]));
|
||||
|
||||
Assert.Equal(original.UpdatedUtc, restored.Rows.Single().ToStoredSecret().UpdatedUtc);
|
||||
Assert.Equal(original.UpdatedUtc.Ticks, restored.Rows.Single().ToStoredSecret().UpdatedUtc.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestAnnounce_round_trips()
|
||||
{
|
||||
var message = new SecretManifestAnnounce(
|
||||
[
|
||||
SecretManifestEntryDto.FromEntry(new SecretManifestEntry
|
||||
{
|
||||
Name = new SecretName("a/one"),
|
||||
Revision = 3,
|
||||
UpdatedUtc = new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero),
|
||||
IsDeleted = true,
|
||||
}),
|
||||
]);
|
||||
|
||||
var restored = (SecretManifestAnnounce)RoundTrip(message);
|
||||
SecretManifestEntry entry = restored.Entries.Single().ToEntry();
|
||||
|
||||
Assert.Equal("a/one", entry.Name.Value);
|
||||
Assert.Equal(3, entry.Revision);
|
||||
Assert.True(entry.IsDeleted);
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero), entry.UpdatedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullRequest_round_trips()
|
||||
{
|
||||
var restored = (SecretPullRequest)RoundTrip(new SecretPullRequest(["a/one", "b/two"]));
|
||||
|
||||
Assert.Equal(["a/one", "b/two"], restored.Names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_manifest_fails_loudly_rather_than_guessing()
|
||||
{
|
||||
// A mixed-version cluster must fail on an unrecognized payload, not mis-deserialize a secret.
|
||||
var serializer = (SerializerWithStringManifest)Sys.Serialization
|
||||
.FindSerializerFor(new SecretPullRequest([]));
|
||||
|
||||
Assert.Throws<System.Runtime.Serialization.SerializationException>(
|
||||
() => serializer.FromBinary([1, 2, 3], "zbs-from-the-future-v9"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_wire_contract_carries_no_field_that_could_hold_key_material()
|
||||
{
|
||||
// Structural guard on the trust boundary: the DTO's surface is fixed and reviewed. If someone
|
||||
// adds a property to SecretRowDto, this test fails and forces a deliberate look at whether
|
||||
// the new field is safe to put on the wire.
|
||||
string[] properties = [.. typeof(SecretRowDto)
|
||||
.GetProperties()
|
||||
.Select(p => p.Name)
|
||||
.Order(StringComparer.Ordinal)];
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"Ciphertext", "ContentType", "CreatedBy", "CreatedUtc", "DeletedUtc", "Description",
|
||||
"IsDeleted", "KekId", "Name", "Nonce", "Revision", "Tag", "UpdatedBy", "UpdatedUtc",
|
||||
"WrapNonce", "WrapTag", "WrappedDek",
|
||||
],
|
||||
properties);
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Two real cluster nodes, two real SQLite stores, real remoting over loopback — the gate the G-7
|
||||
/// design called for, run in-process so it belongs to the ordinary offline suite instead of needing
|
||||
/// a deployed rig.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing here is faked: each node runs its own <see cref="ActorSystem"/> with Akka.Remote on its
|
||||
/// own port, they form a cluster, and rows travel through the actual serializer and the actual
|
||||
/// distributed pub/sub mediator. A mock-based test of this behaviour would prove almost nothing —
|
||||
/// the failure modes worth catching (self-echo, sender loss across the mediator, tombstones that do
|
||||
/// not propagate, a partition that never re-converges) only appear when messages really cross nodes.
|
||||
/// </remarks>
|
||||
public sealed class TwoNodeClusterReplicationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly List<string> _dbPaths = [];
|
||||
private ActorSystem _systemA = null!;
|
||||
private ActorSystem _systemB = null!;
|
||||
private SqliteSecretStore _storeA = null!;
|
||||
private SqliteSecretStore _storeB = null!;
|
||||
private ISecretStore _writableA = null!;
|
||||
private IActorRef _replicatorB = null!;
|
||||
|
||||
// Short so anti-entropy assertions do not dominate the suite's runtime.
|
||||
private static readonly TimeSpan AnnounceInterval = TimeSpan.FromMilliseconds(300);
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_storeA = CreateStore();
|
||||
_storeB = CreateStore();
|
||||
|
||||
// Port 0 lets the OS assign free ports, so parallel test runs cannot collide.
|
||||
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0));
|
||||
int portA = ClusterPort(_systemA);
|
||||
|
||||
// Node A seeds itself; node B joins it.
|
||||
_systemA.Dispose();
|
||||
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(portA, seedPort: portA));
|
||||
_systemB = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0, seedPort: portA));
|
||||
|
||||
await AwaitClusterUpAsync(_systemA, expectedMembers: 2);
|
||||
await AwaitClusterUpAsync(_systemB, expectedMembers: 2);
|
||||
|
||||
IActorRef replicatorA = _systemA.ActorOf(
|
||||
SecretReplicationActor.Props(_storeA, null, AnnounceInterval), "zb-secret-replication");
|
||||
_replicatorB = _systemB.ActorOf(
|
||||
SecretReplicationActor.Props(_storeB, null, AnnounceInterval), "zb-secret-replication");
|
||||
|
||||
_writableA = new ReplicatingSecretStore(
|
||||
_storeA, new AkkaSecretReplicator(replicatorA), NullLogger<ReplicatingSecretStore>.Instance);
|
||||
|
||||
// The mediator gossips subscriptions between nodes; publishing before both sides are
|
||||
// registered would silently drop the message.
|
||||
await AwaitBothSubscribedAsync();
|
||||
}
|
||||
|
||||
private SqliteSecretStore CreateStore()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"zb-akka-{Guid.NewGuid():N}.db");
|
||||
_dbPaths.Add(path);
|
||||
var factory = new SecretsSqliteConnectionFactory(path);
|
||||
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
return new SqliteSecretStore(factory);
|
||||
}
|
||||
|
||||
private static Config ClusterConfig(int port, int? seedPort = null)
|
||||
{
|
||||
string seeds = seedPort is null
|
||||
? "[]"
|
||||
: $"[\"akka.tcp://zb-secrets-cluster@127.0.0.1:{seedPort}\"]";
|
||||
|
||||
return ConfigurationFactory.ParseString($$"""
|
||||
akka {
|
||||
loglevel = WARNING
|
||||
actor.provider = cluster
|
||||
remote.dot-netty.tcp {
|
||||
hostname = "127.0.0.1"
|
||||
public-hostname = "127.0.0.1"
|
||||
port = {{port}}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = {{seeds}}
|
||||
downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider"
|
||||
}
|
||||
}
|
||||
""").WithFallback(AkkaSecretsReplication.SerializationConfig);
|
||||
}
|
||||
|
||||
private static int ClusterPort(ActorSystem system) =>
|
||||
Cluster.Get(system).SelfAddress.Port
|
||||
?? throw new InvalidOperationException("Cluster address has no port.");
|
||||
|
||||
private static async Task AwaitClusterUpAsync(ActorSystem system, int expectedMembers)
|
||||
{
|
||||
Cluster cluster = Cluster.Get(system);
|
||||
DateTime deadline = DateTime.UtcNow + Patience;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expectedMembers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"Cluster did not reach {expectedMembers} Up members within {Patience}.");
|
||||
}
|
||||
|
||||
// Writes a throwaway secret on A and waits for it to land on B. That round trip is the only
|
||||
// reliable signal that pub/sub subscriptions have gossiped across both nodes.
|
||||
private async Task AwaitBothSubscribedAsync()
|
||||
{
|
||||
var probe = new SecretName("warmup/probe");
|
||||
DateTime deadline = DateTime.UtcNow + Patience;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await _writableA.UpsertAsync(Row("warmup/probe", 0x01), CancellationToken.None);
|
||||
|
||||
if (await WaitForAsync(_storeB, probe, _ => true, TimeSpan.FromMilliseconds(500)))
|
||||
{
|
||||
await _storeA.DeleteAsync(probe, "warmup", CancellationToken.None);
|
||||
await _storeB.DeleteAsync(probe, "warmup", CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException("Nodes did not establish pub/sub subscriptions in time.");
|
||||
}
|
||||
|
||||
private static StoredSecret Row(string name, byte marker) => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
ContentType = SecretContentType.Text,
|
||||
Ciphertext = [marker],
|
||||
Nonce = [1],
|
||||
Tag = [2],
|
||||
WrappedDek = [3],
|
||||
WrapNonce = [4],
|
||||
WrapTag = [5],
|
||||
KekId = "sha256:shared-kek",
|
||||
Revision = 0,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
private static async Task<bool> WaitForAsync(
|
||||
ISecretStore store, SecretName name, Func<StoredSecret, bool> predicate, TimeSpan? timeout = null)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + (timeout ?? Patience);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
StoredSecret? row = await store.GetAsync(name, CancellationToken.None);
|
||||
|
||||
if (row is not null && predicate(row))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_secret_written_on_node_A_becomes_resolvable_on_node_B()
|
||||
{
|
||||
var name = new SecretName("db/password");
|
||||
await _writableA.UpsertAsync(Row("db/password", 0xAA), CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xAA),
|
||||
"Node B never received the secret written on node A.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_ciphertext_arrives_byte_identical()
|
||||
{
|
||||
var name = new SecretName("app/exact");
|
||||
await _writableA.UpsertAsync(Row("app/exact", 0x7F), CancellationToken.None);
|
||||
|
||||
Assert.True(await WaitForAsync(_storeB, name, _ => true));
|
||||
|
||||
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
|
||||
StoredSecret onB = (await _storeB.GetAsync(name, CancellationToken.None))!;
|
||||
|
||||
Assert.Equal(onA.Ciphertext, onB.Ciphertext);
|
||||
Assert.Equal(onA.WrappedDek, onB.WrappedDek);
|
||||
Assert.Equal(onA.KekId, onB.KekId);
|
||||
// Verbatim: the peer keeps the originating node's revision and timestamp, or the row would
|
||||
// look newer on B than on A and bounce back.
|
||||
Assert.Equal(onA.Revision, onB.Revision);
|
||||
Assert.Equal(onA.UpdatedUtc, onB.UpdatedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_delete_on_node_A_tombstones_the_row_on_node_B()
|
||||
{
|
||||
var name = new SecretName("app/doomed");
|
||||
await _writableA.UpsertAsync(Row("app/doomed", 0xBB), CancellationToken.None);
|
||||
Assert.True(await WaitForAsync(_storeB, name, _ => true));
|
||||
|
||||
await _writableA.DeleteAsync(name, "operator", CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeB, name, r => r.IsDeleted),
|
||||
"The delete never propagated to node B.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_update_overwrites_the_earlier_value_on_the_peer()
|
||||
{
|
||||
var name = new SecretName("app/rotating");
|
||||
await _writableA.UpsertAsync(Row("app/rotating", 0x01), CancellationToken.None);
|
||||
Assert.True(await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x01));
|
||||
|
||||
await _writableA.UpsertAsync(Row("app/rotating", 0x02), CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x02 && r.Revision == 1),
|
||||
"Node B did not converge on the updated value.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anti_entropy_repairs_a_write_that_never_got_broadcast()
|
||||
{
|
||||
// Simulates a dropped publish — the write goes straight to node A's store, bypassing the
|
||||
// replicator entirely, so the ONLY route to node B is the periodic manifest exchange. This is
|
||||
// the case that makes best-effort publishing safe.
|
||||
var name = new SecretName("app/missed-broadcast");
|
||||
await _storeA.UpsertAsync(Row("app/missed-broadcast", 0xCC), CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xCC),
|
||||
"Anti-entropy failed to repair a write that was never broadcast.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anti_entropy_pulls_in_the_other_direction_too()
|
||||
{
|
||||
// The mirror case: node B holds a row node A has never heard of. A cannot request a name it
|
||||
// does not know exists, so this only converges if the exchange pushes as well as pulls.
|
||||
var name = new SecretName("app/born-on-b");
|
||||
await _storeB.UpsertAsync(Row("app/born-on-b", 0xDD), CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeA, name, r => r.Ciphertext[0] == 0xDD),
|
||||
"Node A never learned about a secret that originated on node B.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_that_joins_late_catches_up_on_everything()
|
||||
{
|
||||
// Partition-heal / cold-start: the whole reason to choose this transport over a shared store.
|
||||
await _writableA.UpsertAsync(Row("bulk/one", 0x11), CancellationToken.None);
|
||||
await _writableA.UpsertAsync(Row("bulk/two", 0x22), CancellationToken.None);
|
||||
await _writableA.UpsertAsync(Row("bulk/three", 0x33), CancellationToken.None);
|
||||
|
||||
SqliteSecretStore lateStore = CreateStore();
|
||||
_systemB.ActorOf(
|
||||
SecretReplicationActor.Props(lateStore, null, AnnounceInterval), "late-joiner");
|
||||
|
||||
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/one"), r => r.Ciphertext[0] == 0x11));
|
||||
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/two"), r => r.Ciphertext[0] == 0x22));
|
||||
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/three"), r => r.Ciphertext[0] == 0x33));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_broadcast_round_trip_does_not_drift_the_originating_nodes_revision()
|
||||
{
|
||||
// Note on what this does and does NOT prove: the revision would also stay put if the self-echo
|
||||
// filter were removed, because re-applying an identical row ties on (updated_utc, revision)
|
||||
// and last-writer-wins rejects a tie. So this covers the drift symptom via defence in depth,
|
||||
// not the filter itself. The filter is what stops the echo being forwarded onward, which
|
||||
// needs three nodes to observe — worth adding if a third node ever joins this rig.
|
||||
var name = new SecretName("app/no-echo");
|
||||
await _writableA.UpsertAsync(Row("app/no-echo", 0xEE), CancellationToken.None);
|
||||
Assert.True(await WaitForAsync(_storeB, name, _ => true));
|
||||
|
||||
await Task.Delay(AnnounceInterval * 4);
|
||||
|
||||
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
|
||||
Assert.Equal(0, onA.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_row_from_a_peer_is_discarded_without_stalling_replication()
|
||||
{
|
||||
// Peer input is untrusted: a path-traversing name must be dropped, and — critically — the
|
||||
// actor must keep serving everything else afterwards.
|
||||
_replicatorB.Tell(new Protocol.SecretRowsMessage(
|
||||
[
|
||||
new Protocol.SecretRowDto
|
||||
{
|
||||
Name = "../../etc/passwd",
|
||||
ContentType = "Text",
|
||||
Ciphertext = [1], Nonce = [1], Tag = [1],
|
||||
WrappedDek = [1], WrapNonce = [1], WrapTag = [1],
|
||||
KekId = "sha256:x",
|
||||
Revision = 0,
|
||||
CreatedUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
UpdatedUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
},
|
||||
]));
|
||||
|
||||
var name = new SecretName("app/after-malformed");
|
||||
await _writableA.UpsertAsync(Row("app/after-malformed", 0x5A), CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x5A),
|
||||
"Replication stalled after a malformed row was received.");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await CoordinatedShutdown.Get(_systemA).Run(CoordinatedShutdown.ClrExitReason.Instance);
|
||||
await CoordinatedShutdown.Get(_systemB).Run(CoordinatedShutdown.ClrExitReason.Instance);
|
||||
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
foreach (string db in _dbPaths)
|
||||
{
|
||||
foreach (string path in new[] { db, db + "-wal", db + "-shm" })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort temp cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="Akka.Remote" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Replicator.AkkaDotNet\ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Container-resolution tests for both SQL-Server topologies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These exist because a code review caught that neither mode could resolve at all: the decorators
|
||||
/// ask for the concrete <see cref="SqliteSecretStore"/>, which the core package only registered
|
||||
/// behind <see cref="ISecretStore"/>. Every unit test passed and both modes were dead on arrival,
|
||||
/// because nothing built a provider. Resolving the graph is the assertion that matters.
|
||||
/// </remarks>
|
||||
public sealed class AddZbSecretsSqlServerTests
|
||||
{
|
||||
private static IConfiguration Config(string sqlitePath) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = sqlitePath,
|
||||
["Secrets:RunMigrationsOnStartup"] = "false",
|
||||
["Secrets:MasterKey:Source"] = "Environment",
|
||||
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_TEST_KEY",
|
||||
["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;",
|
||||
}).Build();
|
||||
|
||||
private static string TempDb() => Path.Combine(Path.GetTempPath(), $"zb-di-{Guid.NewGuid():N}.db");
|
||||
|
||||
[Fact]
|
||||
public void SharedStore_mode_resolves_the_SqlServer_store_as_ISecretStore()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerStore(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// The SQL-Server store displaces SQLite — no decorator, because there is nothing to replicate.
|
||||
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
Assert.IsType<SqlServerSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HubReplication_mode_resolves_a_decorated_local_store()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
Assert.IsType<SqlServerSecretReplicator>(provider.GetRequiredService<ISecretReplicator>());
|
||||
// The local store is still provisioned by the core SQLite migrator, not the hub's.
|
||||
Assert.IsType<SqliteSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HubReplication_mode_registers_both_the_hub_migration_and_the_sync_service()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
IHostedService[] hosted = [.. provider.GetServices<IHostedService>()];
|
||||
|
||||
// The hub schema is nobody's "own" store, so every node must provision it at startup too.
|
||||
Assert.Contains(hosted, h => h is SqlServerSecretSyncService);
|
||||
Assert.Single(hosted, h => h.GetType().Name == "SqlServerHubMigrationHostedService");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_decorator_wraps_the_undecorated_local_store_rather_than_itself()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// Guards against the self-referential registration that would stack-overflow on first use.
|
||||
var decorated = (ReplicatingSecretStore)provider.GetRequiredService<ISecretStore>();
|
||||
Assert.NotNull(decorated);
|
||||
Assert.NotSame(decorated, provider.GetRequiredService<SqliteSecretStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_connection_string_fails_at_registration_not_at_first_resolve()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = TempDb(),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddZbSecretsSqlServerStore(config));
|
||||
|
||||
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ISecretReplicationHub"/> backed by a real SQLite store, standing in for the shared
|
||||
/// SQL-Server hub in offline convergence tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately backed by a real store rather than a dictionary: the behaviour under test is
|
||||
/// last-writer-wins convergence, which lives in <c>ApplyReplicatedAsync</c>. A hand-rolled fake would
|
||||
/// be re-implementing the very logic the tests exist to check, and would happily agree with a broken
|
||||
/// reconciler. The live SQL-Server tests then confirm the T-SQL store behaves the same way.
|
||||
/// </remarks>
|
||||
/// <param name="store">The SQLite store standing in for the hub database.</param>
|
||||
public sealed class SqliteBackedHub(SqliteSecretStore store) : ISecretReplicationHub
|
||||
{
|
||||
/// <summary>Number of times the sweep asked this hub for rows — asserts the fetch was skipped.</summary>
|
||||
public int GetManyCallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
store.GetManifestAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct)
|
||||
{
|
||||
GetManyCallCount++;
|
||||
|
||||
var rows = new List<StoredSecret>(names.Count);
|
||||
|
||||
foreach (SecretName name in names)
|
||||
{
|
||||
StoredSecret? row = await store.GetAsync(name, ct);
|
||||
|
||||
if (row is not null)
|
||||
{
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
store.ApplyReplicatedAsync(row, ct);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Live;
|
||||
|
||||
/// <summary>
|
||||
/// Shared connection details for the env-gated live SQL-Server suite.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Gated on <c>SECRETS_SQLSERVER_CONNSTR</c> and skips cleanly when it is unset, matching the family
|
||||
/// live-test idiom — the offline suite must stay runnable on any machine. Set it to a database the
|
||||
/// test is allowed to create and drop a schema in, for example:
|
||||
/// <c>Server=10.100.0.35,31433;Database=ZbSecretsTest;User Id=sa;Password=...;TrustServerCertificate=True</c>.
|
||||
/// </remarks>
|
||||
public static class LiveSqlServer
|
||||
{
|
||||
/// <summary>Environment variable holding the live connection string.</summary>
|
||||
public const string ConnectionStringVariable = "SECRETS_SQLSERVER_CONNSTR";
|
||||
|
||||
/// <summary>The configured connection string, or <see langword="null"/> when the suite is not enabled.</summary>
|
||||
public static string? ConnectionString =>
|
||||
Environment.GetEnvironmentVariable(ConnectionStringVariable);
|
||||
|
||||
/// <summary>Whether the live suite should run.</summary>
|
||||
public static bool IsEnabled => !string.IsNullOrWhiteSpace(ConnectionString);
|
||||
|
||||
/// <summary>Skip reason shown when the suite is not enabled.</summary>
|
||||
public const string SkipReason =
|
||||
"Live SQL-Server suite disabled; set SECRETS_SQLSERVER_CONNSTR to enable.";
|
||||
|
||||
/// <summary>
|
||||
/// Builds options against a throwaway schema so parallel runs and repeat runs cannot collide,
|
||||
/// and so a failed run leaves no trace in a shared database.
|
||||
/// </summary>
|
||||
/// <param name="schemaName">The unique schema name for this test.</param>
|
||||
/// <returns>Options targeting that schema.</returns>
|
||||
public static SqlServerSecretsOptions OptionsFor(string schemaName) => new()
|
||||
{
|
||||
ConnectionString = ConnectionString!,
|
||||
SchemaName = schemaName,
|
||||
};
|
||||
|
||||
/// <summary>Generates a unique, allow-list-legal schema name for one test class.</summary>
|
||||
/// <returns>A fresh schema name.</returns>
|
||||
public static string NewSchemaName() => $"zbtest_{Guid.NewGuid():N}"[..32];
|
||||
|
||||
/// <summary>Drops the throwaway schema and its tables.</summary>
|
||||
/// <param name="schemaName">The schema to drop.</param>
|
||||
/// <returns>A task that completes when the schema is gone.</returns>
|
||||
public static async Task DropSchemaAsync(string schemaName)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using SqlCommand command = connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
IF OBJECT_ID(N'[{schemaName}].[secret]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[secret];
|
||||
IF OBJECT_ID(N'[{schemaName}].[schema_version]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[schema_version];
|
||||
IF EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') EXEC(N'DROP SCHEMA [{schemaName}]');
|
||||
""";
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Live;
|
||||
|
||||
/// <summary>
|
||||
/// The SQLite store's test suite, ported case-for-case onto the SQL-Server store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ported rather than newly written on purpose. The two stores must be behaviourally identical —
|
||||
/// a cluster can hold both at once — so the strongest available check is that the assertions
|
||||
/// written against SQLite pass unchanged here. Anywhere the T-SQL diverges in observable behaviour,
|
||||
/// one of these fails.
|
||||
/// </remarks>
|
||||
[Collection("live-sqlserver")]
|
||||
public sealed class SqlServerSecretStoreLiveTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _schema = LiveSqlServer.NewSchemaName();
|
||||
private SqlServerSecretStore _store = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (!LiveSqlServer.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var factory = new SecretsSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
|
||||
await new SqlServerSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None);
|
||||
_store = new SqlServerSecretStore(factory);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => LiveSqlServer.DropSchemaAsync(_schema);
|
||||
|
||||
private static StoredSecret MakeSecret(
|
||||
string name,
|
||||
long revision = 0,
|
||||
byte[]? ciphertext = null,
|
||||
DateTimeOffset? updatedUtc = null,
|
||||
DateTimeOffset? createdUtc = null,
|
||||
bool isDeleted = false,
|
||||
DateTimeOffset? deletedUtc = null,
|
||||
string? createdBy = "alice",
|
||||
string? updatedBy = "alice") => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
Description = "desc",
|
||||
ContentType = SecretContentType.ConnectionString,
|
||||
Ciphertext = ciphertext ?? [1, 2, 3],
|
||||
Nonce = [4, 5, 6],
|
||||
Tag = [7, 8, 9],
|
||||
WrappedDek = [10, 11, 12],
|
||||
WrapNonce = [13, 14, 15],
|
||||
WrapTag = [16, 17, 18],
|
||||
KekId = "kek-1",
|
||||
Revision = revision,
|
||||
IsDeleted = isDeleted,
|
||||
DeletedUtc = deletedUtc,
|
||||
CreatedUtc = createdUtc ?? DateTimeOffset.UtcNow,
|
||||
UpdatedUtc = updatedUtc ?? DateTimeOffset.UtcNow,
|
||||
CreatedBy = createdBy,
|
||||
UpdatedBy = updatedBy,
|
||||
};
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Upsert_Then_Get_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("app/db-conn"), CancellationToken.None);
|
||||
|
||||
StoredSecret? got = await _store.GetAsync(new SecretName("app/db-conn"), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(got);
|
||||
Assert.Equal("app/db-conn", got!.Name.Value);
|
||||
Assert.Equal("desc", got.Description);
|
||||
Assert.Equal(SecretContentType.ConnectionString, got.ContentType);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, got.Ciphertext);
|
||||
Assert.Equal(new byte[] { 4, 5, 6 }, got.Nonce);
|
||||
Assert.Equal(new byte[] { 7, 8, 9 }, got.Tag);
|
||||
Assert.Equal(new byte[] { 10, 11, 12 }, got.WrappedDek);
|
||||
Assert.Equal(new byte[] { 13, 14, 15 }, got.WrapNonce);
|
||||
Assert.Equal(new byte[] { 16, 17, 18 }, got.WrapTag);
|
||||
Assert.Equal("kek-1", got.KekId);
|
||||
Assert.Equal(0, got.Revision);
|
||||
Assert.False(got.IsDeleted);
|
||||
Assert.Null(got.DeletedUtc);
|
||||
Assert.Equal("alice", got.CreatedBy);
|
||||
Assert.Equal("alice", got.UpdatedBy);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Get_ReturnsNull_WhenAbsent()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
Assert.Null(await _store.GetAsync(new SecretName("nope"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Upsert_Existing_OverwritesInPlace_BumpsRevision()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(
|
||||
MakeSecret("app/rotating", ciphertext: [1, 1, 1], createdBy: "alice", updatedBy: "alice"),
|
||||
CancellationToken.None);
|
||||
|
||||
StoredSecret afterFirst =
|
||||
(await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
|
||||
Assert.Equal(0, afterFirst.Revision);
|
||||
DateTimeOffset originalCreatedUtc = afterFirst.CreatedUtc;
|
||||
|
||||
await _store.UpsertAsync(
|
||||
MakeSecret("app/rotating", ciphertext: [9, 9, 9], createdBy: "bob", updatedBy: "bob"),
|
||||
CancellationToken.None);
|
||||
|
||||
StoredSecret afterSecond =
|
||||
(await _store.GetAsync(new SecretName("app/rotating"), CancellationToken.None))!;
|
||||
Assert.Equal(1, afterSecond.Revision);
|
||||
Assert.Equal(new byte[] { 9, 9, 9 }, afterSecond.Ciphertext);
|
||||
Assert.Equal(originalCreatedUtc, afterSecond.CreatedUtc);
|
||||
Assert.Equal("alice", afterSecond.CreatedBy);
|
||||
Assert.Equal("bob", afterSecond.UpdatedBy);
|
||||
Assert.False(afterSecond.IsDeleted);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task List_ExcludesTombstoned_ByDefault()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("keep"), CancellationToken.None);
|
||||
await _store.UpsertAsync(MakeSecret("gone"), CancellationToken.None);
|
||||
await _store.DeleteAsync(new SecretName("gone"), "carol", CancellationToken.None);
|
||||
|
||||
IReadOnlyList<SecretMetadata> visible =
|
||||
await _store.ListAsync(includeDeleted: false, CancellationToken.None);
|
||||
Assert.DoesNotContain(visible, m => m.Name.Value == "gone");
|
||||
Assert.Contains(visible, m => m.Name.Value == "keep");
|
||||
|
||||
IReadOnlyList<SecretMetadata> all =
|
||||
await _store.ListAsync(includeDeleted: true, CancellationToken.None);
|
||||
Assert.Contains(all, m => m.Name.Value == "gone");
|
||||
Assert.Contains(all, m => m.Name.Value == "keep");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Delete_SetsTombstone_BumpsRevision_ReturnsFalseWhenAbsent()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("app/secret"), CancellationToken.None);
|
||||
|
||||
Assert.True(await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None));
|
||||
|
||||
StoredSecret tombstoned =
|
||||
(await _store.GetAsync(new SecretName("app/secret"), CancellationToken.None))!;
|
||||
Assert.True(tombstoned.IsDeleted);
|
||||
Assert.NotNull(tombstoned.DeletedUtc);
|
||||
Assert.Equal(1, tombstoned.Revision);
|
||||
Assert.Equal("carol", tombstoned.UpdatedBy);
|
||||
|
||||
Assert.False(await _store.DeleteAsync(new SecretName("app/secret"), "carol", CancellationToken.None));
|
||||
Assert.False(await _store.DeleteAsync(new SecretName("never-existed"), "carol", CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetManifest_ReturnsAllRows()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("a"), CancellationToken.None);
|
||||
await _store.UpsertAsync(MakeSecret("b"), CancellationToken.None);
|
||||
await _store.DeleteAsync(new SecretName("b"), "carol", CancellationToken.None);
|
||||
|
||||
IReadOnlyList<SecretManifestEntry> manifest = await _store.GetManifestAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, manifest.Count);
|
||||
SecretManifestEntry a = manifest.Single(e => e.Name.Value == "a");
|
||||
SecretManifestEntry b = manifest.Single(e => e.Name.Value == "b");
|
||||
Assert.False(a.IsDeleted);
|
||||
Assert.Equal(0, a.Revision);
|
||||
Assert.True(b.IsDeleted);
|
||||
Assert.Equal(1, b.Revision);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ApplyReplicated_AppliesNewer_IgnoresStale()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
DateTimeOffset t1 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
DateTimeOffset t2 = new(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);
|
||||
DateTimeOffset t3 = new(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await _store.ApplyReplicatedAsync(
|
||||
MakeSecret("repl", revision: 5, ciphertext: [5, 5, 5], updatedUtc: t2, createdUtc: t1),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(5, (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!.Revision);
|
||||
|
||||
await _store.ApplyReplicatedAsync(
|
||||
MakeSecret("repl", revision: 6, ciphertext: [6, 6, 6], updatedUtc: t3, createdUtc: t1),
|
||||
CancellationToken.None);
|
||||
|
||||
StoredSecret afterNewer = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
|
||||
Assert.Equal(6, afterNewer.Revision);
|
||||
Assert.Equal(new byte[] { 6, 6, 6 }, afterNewer.Ciphertext);
|
||||
Assert.Equal(t3, afterNewer.UpdatedUtc);
|
||||
|
||||
await _store.ApplyReplicatedAsync(
|
||||
MakeSecret("repl", revision: 4, ciphertext: [4, 4, 4], updatedUtc: t1, createdUtc: t1),
|
||||
CancellationToken.None);
|
||||
|
||||
StoredSecret afterStale = (await _store.GetAsync(new SecretName("repl"), CancellationToken.None))!;
|
||||
Assert.Equal(6, afterStale.Revision);
|
||||
Assert.Equal(new byte[] { 6, 6, 6 }, afterStale.Ciphertext);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ApplyRewrap_ChangesOnlyWrapEnvelopeAndKekId_PreservingRevisionBodyAndTimestamps()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(
|
||||
MakeSecret("app/rewrap", ciphertext: [1, 2, 3], createdUtc: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)),
|
||||
CancellationToken.None);
|
||||
await _store.UpsertAsync(MakeSecret("app/rewrap", ciphertext: [1, 2, 3]), CancellationToken.None);
|
||||
|
||||
StoredSecret before = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
|
||||
Assert.Equal(1, before.Revision);
|
||||
|
||||
bool applied = await _store.ApplyRewrapAsync(
|
||||
before with
|
||||
{
|
||||
WrappedDek = [90, 91, 92],
|
||||
WrapNonce = [93, 94, 95],
|
||||
WrapTag = [96, 97, 98],
|
||||
KekId = "kek-2",
|
||||
},
|
||||
before.WrappedDek,
|
||||
CancellationToken.None);
|
||||
Assert.True(applied);
|
||||
|
||||
StoredSecret after = (await _store.GetAsync(new SecretName("app/rewrap"), CancellationToken.None))!;
|
||||
Assert.Equal(new byte[] { 90, 91, 92 }, after.WrappedDek);
|
||||
Assert.Equal(new byte[] { 93, 94, 95 }, after.WrapNonce);
|
||||
Assert.Equal(new byte[] { 96, 97, 98 }, after.WrapTag);
|
||||
Assert.Equal("kek-2", after.KekId);
|
||||
Assert.Equal(before.Revision, after.Revision);
|
||||
Assert.Equal(before.UpdatedUtc, after.UpdatedUtc);
|
||||
Assert.Equal(before.CreatedUtc, after.CreatedUtc);
|
||||
Assert.Equal(before.UpdatedBy, after.UpdatedBy);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, after.Ciphertext);
|
||||
Assert.Equal(before.Nonce, after.Nonce);
|
||||
Assert.Equal(before.Tag, after.Tag);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ApplyRewrap_RewrapsTombstonedRows()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("app/dead"), CancellationToken.None);
|
||||
await _store.DeleteAsync(new SecretName("app/dead"), "carol", CancellationToken.None);
|
||||
|
||||
StoredSecret tomb = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
|
||||
Assert.True(tomb.IsDeleted);
|
||||
|
||||
Assert.True(await _store.ApplyRewrapAsync(
|
||||
tomb with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
|
||||
tomb.WrappedDek,
|
||||
CancellationToken.None));
|
||||
|
||||
StoredSecret after = (await _store.GetAsync(new SecretName("app/dead"), CancellationToken.None))!;
|
||||
Assert.Equal("kek-2", after.KekId);
|
||||
Assert.True(after.IsDeleted);
|
||||
Assert.Equal(tomb.Revision, after.Revision);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ApplyRewrap_ReturnsFalse_WhenRowAbsent()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
StoredSecret ghost = MakeSecret("never-existed") with { KekId = "kek-2" };
|
||||
|
||||
Assert.False(await _store.ApplyRewrapAsync(ghost, ghost.WrappedDek, CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ApplyRewrap_ReturnsFalse_WhenCurrentWrapChanged_UnderConcurrentWrite()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("app/cas", ciphertext: [1, 2, 3]), CancellationToken.None);
|
||||
StoredSecret before = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
|
||||
|
||||
await _store.UpsertAsync(
|
||||
MakeSecret("app/cas", ciphertext: [9, 9, 9]) with { WrappedDek = [77, 78, 79] },
|
||||
CancellationToken.None);
|
||||
|
||||
bool applied = await _store.ApplyRewrapAsync(
|
||||
before with { WrappedDek = [1], WrapNonce = [2], WrapTag = [3], KekId = "kek-2" },
|
||||
before.WrappedDek,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(applied);
|
||||
StoredSecret after = (await _store.GetAsync(new SecretName("app/cas"), CancellationToken.None))!;
|
||||
Assert.Equal(new byte[] { 77, 78, 79 }, after.WrappedDek);
|
||||
Assert.Equal("kek-1", after.KekId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetMany_returns_only_the_requested_rows()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("a/one"), CancellationToken.None);
|
||||
await _store.UpsertAsync(MakeSecret("b/two"), CancellationToken.None);
|
||||
await _store.UpsertAsync(MakeSecret("c/three"), CancellationToken.None);
|
||||
|
||||
IReadOnlyList<StoredSecret> rows = await _store.GetManyAsync(
|
||||
[new SecretName("a/one"), new SecretName("c/three"), new SecretName("absent/name")],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, r => r.Name.Value == "a/one");
|
||||
Assert.Contains(rows, r => r.Name.Value == "c/three");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetMany_short_circuits_on_an_empty_request()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
// Guards the IN () clause: an empty list would otherwise produce invalid T-SQL.
|
||||
Assert.Empty(await _store.GetManyAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Migrator_is_idempotent()
|
||||
{
|
||||
Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
|
||||
|
||||
var factory = new SecretsSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
|
||||
var migrator = new SqlServerSecretsStoreMigrator(factory);
|
||||
|
||||
// Every node runs this at every startup — re-running must be a no-op, not an error.
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
await _store.UpsertAsync(MakeSecret("survives/migration"), CancellationToken.None);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(await _store.GetAsync(new SecretName("survives/migration"), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the live suite: the tests share one database server and each provisions its own
|
||||
/// schema, so running them in parallel would multiply connection load for no benefit.
|
||||
/// </summary>
|
||||
[CollectionDefinition("live-sqlserver", DisableParallelization = true)]
|
||||
public sealed class LiveSqlServerCollection;
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Fakes;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Convergence behaviour of the bidirectional hub sweep, exercised against real stores on both
|
||||
/// sides so the assertions are about actual persisted state rather than mock interactions.
|
||||
/// </summary>
|
||||
public sealed class SqlServerSecretSyncServiceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _dbPaths = [];
|
||||
private readonly SqliteSecretStore _local;
|
||||
private readonly SqliteSecretStore _hubStore;
|
||||
private readonly SqliteBackedHub _hub;
|
||||
|
||||
public SqlServerSecretSyncServiceTests()
|
||||
{
|
||||
_local = CreateStore();
|
||||
_hubStore = CreateStore();
|
||||
_hub = new SqliteBackedHub(_hubStore);
|
||||
}
|
||||
|
||||
private SqliteSecretStore CreateStore()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"zb-sync-{Guid.NewGuid():N}.db");
|
||||
_dbPaths.Add(path);
|
||||
var factory = new SecretsSqliteConnectionFactory(path);
|
||||
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
return new SqliteSecretStore(factory);
|
||||
}
|
||||
|
||||
private SqlServerSecretSyncService CreateService() => new(
|
||||
_local,
|
||||
_hub,
|
||||
cacheInvalidator: null,
|
||||
Options.Create(new SqlServerSecretsOptions { ConnectionString = "Server=unused;" }),
|
||||
NullLogger<SqlServerSecretSyncService>.Instance);
|
||||
|
||||
private static StoredSecret Row(string name, byte marker) => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
ContentType = SecretContentType.Text,
|
||||
Ciphertext = [marker],
|
||||
Nonce = [1],
|
||||
Tag = [2],
|
||||
WrappedDek = [3],
|
||||
WrapNonce = [4],
|
||||
WrapTag = [5],
|
||||
KekId = "sha256:test",
|
||||
Revision = 0,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_pulls_a_secret_the_node_has_never_seen()
|
||||
{
|
||||
await _hubStore.UpsertAsync(Row("db/password", 0xAA), CancellationToken.None);
|
||||
|
||||
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, pulled);
|
||||
Assert.Equal(0, pushed);
|
||||
|
||||
StoredSecret? local = await _local.GetAsync(new SecretName("db/password"), CancellationToken.None);
|
||||
Assert.NotNull(local);
|
||||
Assert.Equal(0xAA, local!.Ciphertext[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_pushes_a_local_write_the_hub_never_received()
|
||||
{
|
||||
// The case that makes best-effort publishing safe: the publish failed (hub was down), so the
|
||||
// hub has never heard of this name and can never ask for it. Only a push sweep repairs this.
|
||||
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
|
||||
|
||||
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, pulled);
|
||||
Assert.Equal(1, pushed);
|
||||
|
||||
StoredSecret? hub = await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None);
|
||||
Assert.NotNull(hub);
|
||||
Assert.Equal(0xBB, hub!.Ciphertext[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_preserves_the_originating_revision_when_pushing()
|
||||
{
|
||||
// Pushed verbatim, not upserted: if the hub stamped its own revision the row would look newer
|
||||
// than the writer's copy and bounce straight back on the next sweep.
|
||||
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
|
||||
await _local.UpsertAsync(Row("api/key", 0xCC), CancellationToken.None);
|
||||
|
||||
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
|
||||
Assert.Equal(1, localRow.Revision);
|
||||
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
StoredSecret hubRow = (await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
|
||||
Assert.Equal(localRow.Revision, hubRow.Revision);
|
||||
Assert.Equal(localRow.UpdatedUtc, hubRow.UpdatedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_converges_both_directions_in_one_pass()
|
||||
{
|
||||
await _local.UpsertAsync(Row("only/local", 0x11), CancellationToken.None);
|
||||
await _hubStore.UpsertAsync(Row("only/hub", 0x22), CancellationToken.None);
|
||||
|
||||
(int pulled, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, pulled);
|
||||
Assert.Equal(1, pushed);
|
||||
|
||||
Assert.NotNull(await _local.GetAsync(new SecretName("only/hub"), CancellationToken.None));
|
||||
Assert.NotNull(await _hubStore.GetAsync(new SecretName("only/local"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_is_idempotent_so_a_converged_pair_stays_quiet()
|
||||
{
|
||||
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
|
||||
|
||||
SqlServerSecretSyncService service = CreateService();
|
||||
await service.SweepAsync(CancellationToken.None);
|
||||
|
||||
(int pulled, int pushed) = await service.SweepAsync(CancellationToken.None);
|
||||
|
||||
// A steady-state cluster must not write on every tick — that would churn the hub forever.
|
||||
Assert.Equal(0, pulled);
|
||||
Assert.Equal(0, pushed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_propagates_a_delete_as_a_tombstone()
|
||||
{
|
||||
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
await _local.DeleteAsync(new SecretName("api/key"), "tester", CancellationToken.None);
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
StoredSecret hubRow = (await _hubStore.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
|
||||
Assert.True(hubRow.IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_does_not_resurrect_a_secret_deleted_on_the_hub()
|
||||
{
|
||||
await _local.UpsertAsync(Row("api/key", 0xBB), CancellationToken.None);
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
await _hubStore.DeleteAsync(new SecretName("api/key"), "admin", CancellationToken.None);
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
|
||||
Assert.True(localRow.IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_skips_the_hub_fetch_when_nothing_is_newer()
|
||||
{
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, _hub.GetManyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_lets_the_newer_side_win_a_conflicting_write()
|
||||
{
|
||||
await _local.UpsertAsync(Row("api/key", 0x11), CancellationToken.None);
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
// Both sides then write independently; the hub's write is later.
|
||||
await _local.UpsertAsync(Row("api/key", 0x22), CancellationToken.None);
|
||||
await Task.Delay(10);
|
||||
await _hubStore.UpsertAsync(Row("api/key", 0x33), CancellationToken.None);
|
||||
|
||||
await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
StoredSecret localRow = (await _local.GetAsync(new SecretName("api/key"), CancellationToken.None))!;
|
||||
Assert.Equal(0x33, localRow.Ciphertext[0]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
foreach (string db in _dbPaths)
|
||||
{
|
||||
foreach (string path in new[] { db, db + "-wal", db + "-shm" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The schema-name allow-list is the injection boundary for the one value that cannot be
|
||||
/// parameterized (a T-SQL object name), so it gets adversarial coverage rather than a happy path.
|
||||
/// </summary>
|
||||
public sealed class SqlServerSecretsOptionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("zbsecrets")]
|
||||
[InlineData("ZbSecrets")]
|
||||
[InlineData("_private")]
|
||||
[InlineData("schema_1")]
|
||||
public void SchemaName_accepts_plain_identifiers(string value)
|
||||
{
|
||||
var options = new SqlServerSecretsOptions { SchemaName = value };
|
||||
|
||||
Assert.Equal(value, options.SchemaName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// The bracket-escape trick that would otherwise break out of the [quoting] at the use site.
|
||||
[InlineData("evil]]; DROP TABLE secret; --")]
|
||||
[InlineData("dbo.secret")]
|
||||
[InlineData("with space")]
|
||||
[InlineData("quote'name")]
|
||||
[InlineData("semi;colon")]
|
||||
[InlineData("1leading_digit")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void SchemaName_rejects_anything_that_is_not_a_plain_identifier(string value)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new SqlServerSecretsOptions { SchemaName = value });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchemaName_rejects_an_over_long_identifier()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new SqlServerSecretsOptions { SchemaName = new string('a', 129) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_a_missing_connection_string()
|
||||
{
|
||||
InvalidOperationException ex =
|
||||
Assert.Throws<InvalidOperationException>(() => new SqlServerSecretsOptions().Validate());
|
||||
|
||||
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_a_non_positive_sync_interval()
|
||||
{
|
||||
var options = new SqlServerSecretsOptions
|
||||
{
|
||||
ConnectionString = "Server=x;",
|
||||
SyncInterval = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_a_non_positive_command_timeout()
|
||||
{
|
||||
var options = new SqlServerSecretsOptions
|
||||
{
|
||||
ConnectionString = "Server=x;",
|
||||
CommandTimeout = TimeSpan.FromSeconds(-1),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_a_fully_configured_instance()
|
||||
{
|
||||
var options = new SqlServerSecretsOptions { ConnectionString = "Server=x;Database=y;" };
|
||||
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSchemaDdl_quotes_the_schema_name_everywhere_it_appears()
|
||||
{
|
||||
string ddl = SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets");
|
||||
|
||||
Assert.Contains("[zbsecrets].[secret]", ddl, StringComparison.Ordinal);
|
||||
Assert.Contains("[zbsecrets].[schema_version]", ddl, StringComparison.Ordinal);
|
||||
// Every DDL statement is guarded, so re-running the migration on a provisioned database is
|
||||
// a no-op rather than an error.
|
||||
Assert.Contains("IF NOT EXISTS", ddl, StringComparison.Ordinal);
|
||||
Assert.Contains("IS NULL", ddl, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Tests.Fakes;
|
||||
|
||||
/// <summary>Records every eviction so tests can assert the resolver cache was cleared.</summary>
|
||||
public sealed class RecordingCacheInvalidator : ISecretCacheInvalidator
|
||||
{
|
||||
private readonly List<SecretName> _invalidated = [];
|
||||
|
||||
/// <summary>The names evicted, in call order.</summary>
|
||||
public IReadOnlyList<SecretName> Invalidated => _invalidated;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(SecretName name) => _invalidated.Add(name);
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
using ZB.MOM.WW.Secrets.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Tests.Replication;
|
||||
|
||||
/// <summary>
|
||||
/// Convergence rules for the transport-agnostic reconciler. These are the cases both replication
|
||||
/// packages inherit, so they are pinned here once against a real SQLite store rather than a fake —
|
||||
/// a reconciler that agrees with a mock but not with the store would be worthless.
|
||||
/// </summary>
|
||||
public sealed class SecretReplicationReconcilerTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"zb-recon-{Guid.NewGuid():N}.db");
|
||||
private readonly SecretsSqliteConnectionFactory _factory;
|
||||
private readonly SqliteSecretStore _store;
|
||||
|
||||
public SecretReplicationReconcilerTests()
|
||||
{
|
||||
_factory = new SecretsSqliteConnectionFactory(_dbPath);
|
||||
new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
_store = new SqliteSecretStore(_factory);
|
||||
}
|
||||
|
||||
private static readonly DateTimeOffset T0 = new(2026, 7, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static SecretManifestEntry Entry(string name, long revision, DateTimeOffset updated, bool deleted = false) =>
|
||||
new() { Name = new SecretName(name), Revision = revision, UpdatedUtc = updated, IsDeleted = deleted };
|
||||
|
||||
private static StoredSecret Row(string name, long revision, DateTimeOffset updated, bool deleted = false) => new()
|
||||
{
|
||||
Name = new SecretName(name),
|
||||
ContentType = SecretContentType.Text,
|
||||
Ciphertext = [1, 2, 3],
|
||||
Nonce = [4],
|
||||
Tag = [5],
|
||||
WrappedDek = [6],
|
||||
WrapNonce = [7],
|
||||
WrapTag = [8],
|
||||
KekId = "sha256:test",
|
||||
Revision = revision,
|
||||
IsDeleted = deleted,
|
||||
DeletedUtc = deleted ? updated : null,
|
||||
CreatedUtc = T0,
|
||||
UpdatedUtc = updated,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_pulls_names_absent_locally()
|
||||
{
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [],
|
||||
remote: [Entry("db/password", 0, T0)]);
|
||||
|
||||
Assert.Equal(["db/password"], pull.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_pulls_a_newer_remote_row()
|
||||
{
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("api/key", 1, T0)],
|
||||
remote: [Entry("api/key", 2, T0.AddSeconds(1))]);
|
||||
|
||||
Assert.Equal(["api/key"], pull.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_breaks_a_timestamp_tie_on_revision()
|
||||
{
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("api/key", 1, T0)],
|
||||
remote: [Entry("api/key", 2, T0)]);
|
||||
|
||||
Assert.Equal(["api/key"], pull.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_ignores_an_identical_row_so_redelivery_is_free()
|
||||
{
|
||||
// At-least-once transports redeliver. A tie must not be "newer", or two nodes would
|
||||
// ping-pong the same row forever.
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("api/key", 2, T0)],
|
||||
remote: [Entry("api/key", 2, T0)]);
|
||||
|
||||
Assert.Empty(pull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_ignores_a_peer_that_is_behind()
|
||||
{
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("api/key", 5, T0.AddMinutes(1))],
|
||||
remote: [Entry("api/key", 2, T0)]);
|
||||
|
||||
Assert.Empty(pull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_pulls_a_tombstone_so_deletes_propagate()
|
||||
{
|
||||
// A delete is a newer row, not an absence — otherwise a deleted secret would be resurrected
|
||||
// by the next anti-entropy sweep from a node that still has it.
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("api/key", 1, T0)],
|
||||
remote: [Entry("api/key", 2, T0.AddSeconds(1), deleted: true)]);
|
||||
|
||||
Assert.Equal(["api/key"], pull.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputePullSet_does_not_pull_a_name_only_the_local_side_has()
|
||||
{
|
||||
// The local side only ever pulls. Pushing local-only names is the peer's own reconcile.
|
||||
IReadOnlyList<SecretName> pull = SecretReplicationReconciler.ComputePullSet(
|
||||
localManifest: [Entry("local/only", 0, T0)],
|
||||
remote: []);
|
||||
|
||||
Assert.Empty(pull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_applies_only_the_rows_the_peer_holds_newer()
|
||||
{
|
||||
await _store.UpsertAsync(Row("shared/a", 0, T0), CancellationToken.None);
|
||||
|
||||
StoredSecret localA = (await _store.GetAsync(new SecretName("shared/a"), CancellationToken.None))!;
|
||||
|
||||
var reconciler = new SecretReplicationReconciler(_store);
|
||||
List<SecretName>? requested = null;
|
||||
|
||||
int applied = await reconciler.ReconcileAsync(
|
||||
remoteManifest:
|
||||
[
|
||||
// Behind the local copy — must not be fetched.
|
||||
Entry("shared/a", localA.Revision, localA.UpdatedUtc.AddMinutes(-1)),
|
||||
// Absent locally — must be fetched.
|
||||
Entry("shared/b", 3, T0.AddHours(1)),
|
||||
],
|
||||
fetchAsync: (names, _) =>
|
||||
{
|
||||
requested = [.. names];
|
||||
return Task.FromResult<IReadOnlyList<StoredSecret>>([Row("shared/b", 3, T0.AddHours(1))]);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, applied);
|
||||
Assert.Equal(["shared/b"], requested!.Select(n => n.Value));
|
||||
|
||||
StoredSecret? b = await _store.GetAsync(new SecretName("shared/b"), CancellationToken.None);
|
||||
Assert.NotNull(b);
|
||||
// Applied VERBATIM — the peer's revision, not a locally bumped one.
|
||||
Assert.Equal(3, b!.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_skips_the_fetch_entirely_when_nothing_is_newer()
|
||||
{
|
||||
var reconciler = new SecretReplicationReconciler(_store);
|
||||
bool fetched = false;
|
||||
|
||||
int applied = await reconciler.ReconcileAsync(
|
||||
remoteManifest: [],
|
||||
fetchAsync: (_, _) =>
|
||||
{
|
||||
fetched = true;
|
||||
return Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, applied);
|
||||
Assert.False(fetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_evicts_the_resolver_cache_for_every_row()
|
||||
{
|
||||
var invalidator = new RecordingCacheInvalidator();
|
||||
var reconciler = new SecretReplicationReconciler(_store, invalidator);
|
||||
|
||||
await reconciler.ApplyAsync([Row("a/one", 1, T0), Row("b/two", 1, T0)], CancellationToken.None);
|
||||
|
||||
Assert.Equal(["a/one", "b/two"], invalidator.Invalidated.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_evicts_even_when_the_store_rejected_the_row_as_stale()
|
||||
{
|
||||
// Serving a stale plaintext for the rest of the TTL is worse than a redundant re-read.
|
||||
await _store.UpsertAsync(Row("api/key", 0, T0), CancellationToken.None);
|
||||
|
||||
var invalidator = new RecordingCacheInvalidator();
|
||||
var reconciler = new SecretReplicationReconciler(_store, invalidator);
|
||||
|
||||
await reconciler.ApplyAsync([Row("api/key", 0, T0.AddYears(-1))], CancellationToken.None);
|
||||
|
||||
Assert.Equal(["api/key"], invalidator.Invalidated.Select(n => n.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_skips_a_poison_row_and_keeps_applying_the_rest()
|
||||
{
|
||||
// A batch is not a transaction. Without per-row isolation, one row a peer cannot supply
|
||||
// cleanly abandons every row after it — and since the pull set is recomputed identically
|
||||
// each sweep, in the same manifest order, the same row poisons the same batch forever.
|
||||
var failures = new List<SecretName>();
|
||||
var reconciler = new SecretReplicationReconciler(
|
||||
_store, cacheInvalidator: null, onRowFailed: (name, _) => failures.Add(name));
|
||||
|
||||
// A null ciphertext cannot be persisted (NOT NULL column) — stands in for any bad row.
|
||||
StoredSecret poison = Row("bad/row", 1, T0) with { Ciphertext = null! };
|
||||
|
||||
int applied = await reconciler.ApplyAsync(
|
||||
[Row("good/first", 1, T0), poison, Row("good/second", 1, T0)],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, applied);
|
||||
Assert.Equal(["bad/row"], failures.Select(n => n.Value));
|
||||
Assert.NotNull(await _store.GetAsync(new SecretName("good/first"), CancellationToken.None));
|
||||
Assert.NotNull(await _store.GetAsync(new SecretName("good/second"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_fetches_in_bounded_batches()
|
||||
{
|
||||
// An unbounded fetch breaks on real backends: SQL Server caps a command at 2100 parameters,
|
||||
// so a cold-starting node pulling a large hub's entire inventory would fail identically
|
||||
// every interval and never receive a single row.
|
||||
const int total = SecretReplicationReconciler.FetchBatchSize + 20;
|
||||
|
||||
List<SecretManifestEntry> remote = [.. Enumerable.Range(0, total)
|
||||
.Select(i => Entry($"bulk/{i}", 1, T0))];
|
||||
|
||||
var batchSizes = new List<int>();
|
||||
var reconciler = new SecretReplicationReconciler(_store);
|
||||
|
||||
int applied = await reconciler.ReconcileAsync(
|
||||
remote,
|
||||
fetchAsync: (names, _) =>
|
||||
{
|
||||
batchSizes.Add(names.Count);
|
||||
return Task.FromResult<IReadOnlyList<StoredSecret>>(
|
||||
[.. names.Select(n => Row(n.Value, 1, T0))]);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(total, applied);
|
||||
Assert.Equal(2, batchSizes.Count);
|
||||
Assert.All(batchSizes, size => Assert.True(size <= SecretReplicationReconciler.FetchBatchSize));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user