990 lines
70 KiB
Markdown
990 lines
70 KiB
Markdown
# Site Secrets via Artifact Deploy — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship each site exactly the secrets its data connections reference as part of the on-demand artifact deploy, count them in the site's artifact staleness hash, log and audit every secret deployment, and retire the gRPC secrets hub for ScadaBridge sites.
|
||
|
||
**Architecture:** Central scans the site's data-connection configs for `secret:<name>` references, reads those rows (ciphertext, never plaintext) from one shared SQL-Server secret store, and adds them to `DeployArtifactsCommand` and to `ArtifactRevisionHashService`. The site's active node writes them into a new LocalDb-replicated `deployed_secrets` table. A per-node `DeployedSecretsMirror` then applies them into that node's own `secrets.db` with `ApplyReplicatedAsync` and checks each one decrypts. The standby converges through LocalDb replication plus its own mirror.
|
||
|
||
**Tech Stack:** .NET 10, Akka.NET, gRPC (vendored generated code), SQLite via ZB.MOM.WW.LocalDb 0.3.4, ZB.MOM.WW.Secrets 0.6.2 → 0.6.3, xUnit, docker rig.
|
||
|
||
**Spec:** `docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md` (read it first — every section number cited below refers to it).
|
||
|
||
## Global Constraints
|
||
|
||
- **Never plaintext on the wire, in LocalDb, in logs or in audit rows.** Only `StoredSecret` ciphertext fields cross central→site; logs/audit carry names, revisions, KekIds, counts.
|
||
- **Additive contracts only:** new proto fields `DeployArtifactsCommandDto.secrets = 9`, `ArtifactDeploymentResponseDto.secrets_applied` (next free field number, `google.protobuf.Int32Value`); new record parameters appended with `= null` defaults. Vendored generated C# regenerated with `bash docker/regen-proto.sh sitecommand`; an active `<Protobuf>` item must never be committed.
|
||
- **Hash determinism:** every `Hashable*` record declares properties alphabetically (guarded by `ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically`); lists sorted by name ordinal; null-when-empty.
|
||
- **Bootstrap secrets are never deployed** (gRPC PSK `SB-GRPC-PSK-*`, LocalDb replication key, master key) — design §3.1.
|
||
- **Fail closed:** a referenced secret missing/tombstoned at central fails that site's deploy before sending; a secret that fails to apply or decrypt on the active site node fails the deploy response.
|
||
- **Never `await` an audit write on a script path** (not touched here, but do not introduce one).
|
||
- **Do not use `git stash`** (shared across worktrees). Commit with pathspecs.
|
||
- Build `dotnet build ZB.MOM.WW.ScadaBridge.slnx`; per-task run the filtered tests named in each task; full solution test + rig rebuild only in Task 13.
|
||
|
||
**User decisions (already made):**
|
||
- "Which secrets should a site receive with its artifacts?" → **Only referenced** (derived from the site's data-connection `secret:` refs).
|
||
- "How should this relate to the existing gRPC hub pull?" → **Replace it.**
|
||
- "What should the logging cover?" → **Deploy log lines, Audit rows, Hub sweep logging.**
|
||
- "Central needs one authoritative secret store to deploy from" → **Shared SQL store.**
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs` (+ new `ISecretsHubSyncStatus.cs`, `SecretsHubSyncStatus.cs`) | Library 0.6.3: sweep outcome logging + public status |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs` (new) | Wire DTO for one ciphertext secret row |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs`, `ArtifactDeploymentResponse.cs` | Additive `Secrets` / `SecretsApplied` |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/site_command.proto`, `Communication/SiteCommandGrpc/*` (vendored), `Communication/Grpc/SiteCommandDtoMapper.cs` | Wire mapping |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs` (new) | Derive referenced secret names from data-connection JSON |
|
||
| `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs` | Secrets in the staleness hash |
|
||
| `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs` (new) | Central: scan + load rows from `ISecretStore` + map to `SecretArtifact` |
|
||
| `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` | Build/fail-closed/stamp gating/audit/logging |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs`, `StartupValidator.cs` | `Secrets:Store:Provider`, hub retirement, warnings |
|
||
| `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs`, `SiteStorageService.cs` | `deployed_secrets` + `mirrored_secrets` tables and accessors |
|
||
| `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs` | Register `deployed_secrets` as replicated table 11 |
|
||
| `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs` (new) | Per-node reconcile `deployed_secrets` → `secrets.db` + verify |
|
||
| `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` | Apply secrets in `HandleDeployArtifacts` |
|
||
| `docker/docker-compose.yml`, `docker/verify-site-secrets.sh` (new) | Rig switch-over + live gate |
|
||
| docs (`Component-*.md`, CLAUDE.md, register, umbrella index) | Spec sync |
|
||
|
||
---
|
||
|
||
### Task 1: Secrets library 0.6.3 — hub sweep logging + status
|
||
|
||
**Goal:** Every hub sweep outcome is observable (Debug in-sync + hourly Information heartbeat + recovery line) and a public `ISecretsHubSyncStatus` exposes last sweep/success/failures; published as 0.6.3 and pinned in ScadaBridge.
|
||
|
||
**Files:**
|
||
- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs`
|
||
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/ISecretsHubSyncStatus.cs`
|
||
- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubClientOptions.cs` (add `InSyncLogInterval`, default 1 h, validated > 0)
|
||
- Modify: the Grpc hub client DI extension (register `ISecretsHubSyncStatus` singleton)
|
||
- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/Directory.Build.props` (`<Version>0.6.3</Version>`), `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/CLAUDE.md` (version narrative)
|
||
- Test: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/GrpcSecretSyncServiceLoggingTests.cs` (new)
|
||
- Modify: `Directory.Packages.props` (ScadaBridge — all `ZB.MOM.WW.Secrets*` to 0.6.3)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] An in-sync sweep (0 pulled) logs EventId 4 at Debug every time and EventId 5 `Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}` at Information at most once per `InSyncLogInterval`.
|
||
- [ ] The first successful sweep after ≥ 1 failure logs EventId 6 `Secrets hub sync recovered after {Failures} failed sweep(s)` at Information.
|
||
- [ ] `ISecretsHubSyncStatus` returns `LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError` (message only, never a secret value) reflecting the last sweep.
|
||
- [ ] Existing EventIds 1–3 unchanged; library tests green; package 0.6.3 on the Gitea feed; ScadaBridge builds on 0.6.3.
|
||
|
||
**Verify:** `cd ~/Desktop/scadaproj/ZB.MOM.WW.Secrets && dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests` → all pass; `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 errors.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests** (fake hub reader returning an unchanged manifest; `FakeTimeProvider`; capture logs with a list logger):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
|
||
{
|
||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
|
||
var (svc, logs, _) = Harness.Create(time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
|
||
await svc.SweepForTestAsync(CancellationToken.None);
|
||
time.Advance(TimeSpan.FromMinutes(30));
|
||
await svc.SweepForTestAsync(CancellationToken.None);
|
||
time.Advance(TimeSpan.FromMinutes(31));
|
||
await svc.SweepForTestAsync(CancellationToken.None);
|
||
Assert.Equal(3, logs.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
|
||
Assert.Equal(2, logs.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
|
||
{
|
||
var time = new FakeTimeProvider();
|
||
var (svc, logs, status) = Harness.Create(time, failFirst: 2);
|
||
await svc.SweepForTestAsync(default); await svc.SweepForTestAsync(default);
|
||
Assert.Equal(2, status.ConsecutiveFailures);
|
||
await svc.SweepForTestAsync(default);
|
||
Assert.Contains(logs, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
|
||
Assert.Equal(0, status.ConsecutiveFailures);
|
||
Assert.NotNull(status.LastSuccessUtc);
|
||
}
|
||
```
|
||
|
||
(`SweepForTestAsync` is an `internal` wrapper over the existing `SweepAsync`; add `InternalsVisibleTo` for the test project if missing.)
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — `dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests` → FAIL (EventIds 4–6 / status type missing).
|
||
|
||
- [ ] **Step 3: Implement.** Add the status type:
|
||
|
||
```csharp
|
||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||
|
||
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
|
||
public interface ISecretsHubSyncStatus
|
||
{
|
||
DateTimeOffset? LastSweepUtc { get; }
|
||
DateTimeOffset? LastSuccessUtc { get; }
|
||
int ConsecutiveFailures { get; }
|
||
string? LastError { get; }
|
||
}
|
||
|
||
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
|
||
{
|
||
private readonly object _gate = new();
|
||
public DateTimeOffset? LastSweepUtc { get; private set; }
|
||
public DateTimeOffset? LastSuccessUtc { get; private set; }
|
||
public int ConsecutiveFailures { get; private set; }
|
||
public string? LastError { get; private set; }
|
||
|
||
internal int RecordSuccess(DateTimeOffset now)
|
||
{
|
||
lock (_gate) { var prior = ConsecutiveFailures; LastSweepUtc = LastSuccessUtc = now; ConsecutiveFailures = 0; LastError = null; return prior; }
|
||
}
|
||
|
||
internal void RecordFailure(DateTimeOffset now, Exception ex)
|
||
{
|
||
lock (_gate) { LastSweepUtc = now; ConsecutiveFailures++; LastError = ex.GetType().Name + ": " + ex.Message; }
|
||
}
|
||
}
|
||
```
|
||
|
||
In `GrpcSecretSyncService.SweepAsync`, after the reconcile succeeds: `var prior = _status.RecordSuccess(now)`; if `prior > 0` → `LogSweepRecovered(prior)`; if `pulled > 0` → existing `LogSweepConverged(pulled)`, record `_lastPullUtc = now`; else `LogSweepInSync(count)` (Debug) and, when `now - _lastInSyncInfo >= _options.InSyncLogInterval` or it has never logged, `LogSweepInSyncHeartbeat(count, _lastPullUtc)` and set `_lastInSyncInfo = now`. In the catch: `_status.RecordFailure(now, ex)` then the existing `LogSweepFailed(ex)`. New `[LoggerMessage]` EventIds 4 (Debug), 5 (Information), 6 (Information). Register `SecretsHubSyncStatus` as a singleton for both the concrete type and `ISecretsHubSyncStatus` in the hub-client DI extension, and inject it into the service.
|
||
|
||
- [ ] **Step 4: Run tests** → PASS; run the whole library suite `dotnet test` → PASS.
|
||
|
||
- [ ] **Step 5: Version, pack, push, pin.** Bump `<Version>` to 0.6.3, add the 0.6.3 entry to `ZB.MOM.WW.Secrets/CLAUDE.md`, run the repo's existing pack/push script for Secrets (see `ZB.MOM.WW.Secrets/CLAUDE.md` → publishing), commit in scadaproj. In ScadaBridge set every `ZB.MOM.WW.Secrets*` `PackageVersion` in `Directory.Packages.props` to `0.6.3`, `dotnet build ZB.MOM.WW.ScadaBridge.slnx`.
|
||
|
||
```bash
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Secrets && git -C ~/Desktop/scadaproj commit -m "feat(secrets): 0.6.3 — hub sweep outcome logging (in-sync debug + hourly heartbeat + recovered) and ISecretsHubSyncStatus"
|
||
git add Directory.Packages.props && git commit -m "build: pin ZB.MOM.WW.Secrets 0.6.3 (hub sweep logging)"
|
||
```
|
||
|
||
```json:metadata
|
||
{"files": ["~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs", "~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/ISecretsHubSyncStatus.cs", "Directory.Packages.props"], "verifyCommand": "cd ~/Desktop/scadaproj/ZB.MOM.WW.Secrets && dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests", "acceptanceCriteria": ["EventId 4 Debug per in-sync sweep, EventId 5 Information at most once per InSyncLogInterval", "EventId 6 recovered line after failures", "ISecretsHubSyncStatus reflects last sweep", "0.6.3 published and pinned; ScadaBridge builds"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Wire contract — `SecretArtifact`, `Secrets`, `SecretsApplied`
|
||
|
||
**Goal:** `DeployArtifactsCommand` carries an optional ciphertext secret list and `ArtifactDeploymentResponse` an optional applied count, end to end over gRPC, backward compatible.
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs`, `ArtifactDeploymentResponse.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/site_command.proto` (fields per Global Constraints), vendored `src/ZB.MOM.WW.ScadaBridge.Communication/SiteCommandGrpc/*` via `docker/regen-proto.sh sitecommand`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandDtoMapper.cs` (`ToProto`/`FromProto` for command and response)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs` (new)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Command round-trip preserves every `SecretArtifact` field byte-for-byte; `Secrets == null` round-trips as null (field absent), empty list as empty.
|
||
- [ ] Response round-trip: `SecretsApplied` null ⇄ absent wrapper, `3` ⇄ `3`.
|
||
- [ ] A `DeployArtifactsCommandDto` serialized WITHOUT field 9 (old central) maps to `Secrets == null`.
|
||
- [ ] No `<Protobuf>` item left active in the csproj.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter FullyQualifiedName~SiteCommandDtoMapperSecretsTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Contracts.**
|
||
|
||
```csharp
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||
|
||
/// <summary>
|
||
/// One secret shipped to a site with its artifacts: the ZB.MOM.WW.Secrets StoredSecret row
|
||
/// VERBATIM — ciphertext under the fleet's shared KEK, never plaintext. The site applies it
|
||
/// with ISecretStore.ApplyReplicatedAsync. Design: docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md §3.3.
|
||
/// </summary>
|
||
public sealed record SecretArtifact(
|
||
string Name,
|
||
string ContentType,
|
||
byte[] Ciphertext,
|
||
byte[] Nonce,
|
||
byte[] Tag,
|
||
byte[] WrappedDek,
|
||
byte[] WrapNonce,
|
||
byte[] WrapTag,
|
||
string KekId,
|
||
long Revision,
|
||
DateTimeOffset CreatedUtc,
|
||
DateTimeOffset UpdatedUtc,
|
||
string? Description = null);
|
||
```
|
||
|
||
Append to `DeployArtifactsCommand`: `IReadOnlyList<SecretArtifact>? Secrets = null` (after `Timestamp`). Append to `ArtifactDeploymentResponse`: `int? SecretsApplied = null`.
|
||
|
||
- [ ] **Step 2: Proto.** Add `message SecretArtifactDto { string name = 1; string content_type = 2; bytes ciphertext = 3; bytes nonce = 4; bytes tag = 5; bytes wrapped_dek = 6; bytes wrap_nonce = 7; bytes wrap_tag = 8; string kek_id = 9; int64 revision = 10; google.protobuf.Timestamp created_utc = 11; google.protobuf.Timestamp updated_utc = 12; google.protobuf.StringValue description = 13; }`, `message SecretArtifactListDto { repeated SecretArtifactDto items = 1; }`, `SecretArtifactListDto secrets = 9; // absent => null` on `DeployArtifactsCommandDto`, and `google.protobuf.Int32Value secrets_applied = <next free>;` on `ArtifactDeploymentResponseDto`. Run `bash docker/regen-proto.sh sitecommand`; confirm `git diff --stat` shows only the vendored files + proto and the csproj unchanged.
|
||
|
||
- [ ] **Step 3: Failing tests** in `SiteCommandDtoMapperSecretsTests`:
|
||
|
||
```csharp
|
||
private static SecretArtifact Sample(string name) => new(name, "text/plain",
|
||
[1, 2, 3], [4], [5], [6, 7], [8], [9], "kek-A", 7,
|
||
DateTimeOffset.Parse("2026-09-01T00:00:00Z"), DateTimeOffset.Parse("2026-09-02T00:00:00Z"), "d");
|
||
|
||
[Fact]
|
||
public void DeployArtifacts_Secrets_RoundTrip_Verbatim()
|
||
{
|
||
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null,
|
||
DateTimeOffset.UtcNow, [Sample("A"), Sample("B")]);
|
||
var back = (DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(cmd));
|
||
Assert.Equal(2, back.Secrets!.Count);
|
||
Assert.Equal(cmd.Secrets![0].Ciphertext, back.Secrets[0].Ciphertext);
|
||
Assert.Equal("kek-A", back.Secrets[1].KekId);
|
||
Assert.Equal(7, back.Secrets[1].Revision);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeployArtifacts_NullSecrets_StaysNull()
|
||
{
|
||
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null, DateTimeOffset.UtcNow);
|
||
var dto = SiteCommandDtoMapper.ToProto(cmd);
|
||
Assert.Null(dto.Secrets);
|
||
Assert.Null(((DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(dto)).Secrets);
|
||
}
|
||
|
||
[Theory, InlineData(null), InlineData(3)]
|
||
public void ArtifactResponse_SecretsApplied_RoundTrips(int? applied)
|
||
{
|
||
var r = new ArtifactDeploymentResponse("dep", "site-a", true, null, DateTimeOffset.UtcNow, applied);
|
||
Assert.Equal(applied, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsApplied);
|
||
}
|
||
```
|
||
|
||
(Adapt the `FromProto`/`ToProto` call shapes to the mapper's actual overloads for the lifecycle request and the response DTO — the existing `DeployArtifacts` mapping at `SiteCommandDtoMapper.cs:395` is the pattern.)
|
||
|
||
- [ ] **Step 4: Run → FAIL, implement mapper branches, run → PASS.** Byte fields via `ByteString.CopyFrom`/`.ToByteArray()`, timestamps via `Timestamp.FromDateTimeOffset`/`.ToDateTimeOffset()`, null list ⇒ leave field unset.
|
||
|
||
- [ ] **Step 5: Commit** — `git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts src/ZB.MOM.WW.ScadaBridge.Communication tests/ZB.MOM.WW.ScadaBridge.Communication.Tests && git commit -m "feat(artifacts): additive Secrets list + SecretsApplied on the artifact deploy contract (proto field 9)"`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs", "src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs", "src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/ArtifactDeploymentResponse.cs", "src/ZB.MOM.WW.ScadaBridge.Communication/Protos/site_command.proto", "src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandDtoMapper.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter FullyQualifiedName~SiteCommandDtoMapperSecretsTests", "acceptanceCriteria": ["SecretArtifact round-trips byte-for-byte", "null Secrets stays null / absent", "SecretsApplied null and 3 round-trip", "no active Protobuf item committed"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `SecretReferenceScanner`
|
||
|
||
**Goal:** A pure function returning, per site, the distinct `secret:<name>` references found anywhere in its data connections' primary/backup JSON, each with the referencing connection names.
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Finds `secret:` string values at any depth (objects and arrays), prefix case-insensitive, name trimmed.
|
||
- [ ] Ignores non-string values, strings merely containing `secret:` mid-text, `${secret:X}` (node-local config form), empty names, and unparseable JSON (no throw).
|
||
- [ ] Output ordinal-sorted by name; each entry lists referencing connections ordinal-sorted and de-duplicated.
|
||
- [ ] Bootstrap names (`SB-GRPC-PSK-` prefix) are excluded.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~SecretReferenceScannerTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests.**
|
||
|
||
```csharp
|
||
private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null)
|
||
=> new(name, "MxGateway", primary, backup, 3);
|
||
|
||
[Fact]
|
||
public void FindsNestedRefs_CaseInsensitive_AcrossPrimaryAndBackup()
|
||
{
|
||
var refs = SecretReferenceScanner.Scan([
|
||
Dc("GW1", """{"Endpoint":"http://gw:5000","ApiKey":"SECRET: mxgw-key "}"""),
|
||
Dc("GW2", """{"a":{"b":["x","secret:mxgw-key"]}}""", """{"ApiKey":"secret:backup-key"}"""),
|
||
]);
|
||
Assert.Equal(["backup-key", "mxgw-key"], refs.Select(r => r.Name));
|
||
Assert.Equal(["GW1", "GW2"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("""{"ApiKey":"${secret:node-local}"}""")]
|
||
[InlineData("""{"Note":"see secret:x in docs"}""")]
|
||
[InlineData("""{"ApiKey":"secret:"}""")]
|
||
[InlineData("""{"Port":5000}""")]
|
||
[InlineData("not json")]
|
||
[InlineData("""{"Psk":"secret:SB-GRPC-PSK-site-a"}""")]
|
||
public void IgnoresNonReferences(string json)
|
||
=> Assert.Empty(SecretReferenceScanner.Scan([Dc("C", json)]));
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement.**
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
|
||
|
||
/// <summary>One secret a site needs, and which of its data connections reference it.</summary>
|
||
public sealed record SecretReference(string Name, IReadOnlyList<string> ReferencedBy);
|
||
|
||
/// <summary>
|
||
/// Derives the secrets a site needs from its data-connection configs: every JSON string VALUE of the
|
||
/// exact form <c>secret:<name></c>, at any depth, in primary or backup configuration. Protocol-agnostic
|
||
/// so a new adapter adopting <c>secret:</c> refs needs no change here. Bootstrap secrets are excluded
|
||
/// (design §3.1). Unparseable JSON yields nothing rather than throwing — the deploy gate owns config validity.
|
||
/// </summary>
|
||
public static class SecretReferenceScanner
|
||
{
|
||
public const string Prefix = "secret:";
|
||
private static readonly string[] BootstrapPrefixes = ["SB-GRPC-PSK-"];
|
||
|
||
public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections)
|
||
{
|
||
var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||
foreach (var dc in connections)
|
||
foreach (var json in new[] { dc.PrimaryConfigurationJson, dc.BackupConfigurationJson })
|
||
foreach (var name in NamesIn(json))
|
||
{
|
||
if (!found.TryGetValue(name, out var by)) found[name] = by = new SortedSet<string>(StringComparer.Ordinal);
|
||
by.Add(dc.Name);
|
||
}
|
||
return found.Select(kv => new SecretReference(kv.Key, kv.Value.ToList())).ToList();
|
||
}
|
||
|
||
private static IEnumerable<string> NamesIn(string? json)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json)) yield break;
|
||
JsonDocument doc;
|
||
try { doc = JsonDocument.Parse(json); } catch (JsonException) { yield break; }
|
||
using (doc)
|
||
{
|
||
var stack = new Stack<JsonElement>(); stack.Push(doc.RootElement);
|
||
while (stack.Count > 0)
|
||
{
|
||
var e = stack.Pop();
|
||
switch (e.ValueKind)
|
||
{
|
||
case JsonValueKind.Object: foreach (var p in e.EnumerateObject()) stack.Push(p.Value); break;
|
||
case JsonValueKind.Array: foreach (var i in e.EnumerateArray()) stack.Push(i); break;
|
||
case JsonValueKind.String:
|
||
var s = e.GetString()!.Trim();
|
||
if (!s.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) break;
|
||
var name = s[Prefix.Length..].Trim();
|
||
if (name.Length == 0 || BootstrapPrefixes.Any(b => name.StartsWith(b, StringComparison.OrdinalIgnoreCase))) break;
|
||
yield return name;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
(`DataConnectionArtifact` property names: confirm `PrimaryConfigurationJson`/`BackupConfigurationJson` in `Commons/Messages/Artifacts/DataConnectionArtifact.cs` and adjust.)
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets && git commit -m "feat(secrets): SecretReferenceScanner derives a site's secret refs from its data-connection configs"`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs", "tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~SecretReferenceScannerTests", "acceptanceCriteria": ["nested/case-insensitive refs found in primary+backup", "non-refs, ${secret:}, bad JSON, bootstrap names ignored", "sorted, de-duplicated output"], "modelTier": "mechanical"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Secrets in the artifact revision hash
|
||
|
||
**Goal:** `ArtifactRevisionHashService` folds the command's secrets (and missing-secret markers) into the hash; sites with no secret refs keep their existing hash.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs` (append `IReadOnlyList<string>? MissingSecrets = null` — central-only, never mapped to proto; document that on the parameter)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] A command with `Secrets == null` and `MissingSecrets == null` hashes identically to the pre-change golden value (add a golden-hash test computed on `main` BEFORE the change).
|
||
- [ ] Changing a secret's revision, ciphertext, wrapped DEK or KekId changes the hash; reordering secrets does not.
|
||
- [ ] A missing name hashes differently from the same name present.
|
||
- [ ] `HashableRecords_PropertiesDeclaredAlphabetically` covers `HashableSecret` and passes.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactRevisionHashServiceTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Golden test first (on unchanged code):** build a fixed command with one of each existing artifact kind, `Assert.Equal("<value printed on first run>", svc.ComputeHash(cmd))`; run once to capture the value, paste it in, commit the test alone (`test: pin pre-secrets artifact hash`).
|
||
|
||
- [ ] **Step 2: Failing tests.**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void SecretChanges_MoveTheHash_ButOrderDoesNot()
|
||
{
|
||
var svc = new ArtifactRevisionHashService();
|
||
var a = Secret("A", rev: 1); var b = Secret("B", rev: 1);
|
||
var baseline = svc.ComputeHash(Cmd(secrets: [a, b]));
|
||
Assert.Equal(baseline, svc.ComputeHash(Cmd(secrets: [b, a])));
|
||
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { Revision = 2 }, b])));
|
||
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { WrappedDek = [42] }, b])));
|
||
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { KekId = "kek-B" }, b])));
|
||
}
|
||
|
||
[Fact]
|
||
public void MissingSecret_HashesDifferentlyFromPresent()
|
||
{
|
||
var svc = new ArtifactRevisionHashService();
|
||
Assert.NotEqual(svc.ComputeHash(Cmd(secrets: [Secret("A", 1)])), svc.ComputeHash(Cmd(missing: ["A"])));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement.** Add to `HashableArtifactSet` (keep alphabetical: `DataConnections, DatabaseConnections, ExternalSystems, Secrets, SharedScripts`):
|
||
|
||
```csharp
|
||
Secrets = BuildHashableSecrets(command),
|
||
...
|
||
private static List<HashableSecret>? BuildHashableSecrets(DeployArtifactsCommand c)
|
||
{
|
||
var list = new List<HashableSecret>();
|
||
foreach (var s in c.Secrets ?? [])
|
||
list.Add(new HashableSecret
|
||
{
|
||
ContentDigest = Convert.ToHexStringLower(SHA256.HashData([.. s.Ciphertext, .. s.Nonce, .. s.Tag, .. s.WrappedDek, .. s.WrapNonce, .. s.WrapTag])),
|
||
KekId = s.KekId, Missing = null, Name = s.Name, Revision = s.Revision
|
||
});
|
||
foreach (var m in c.MissingSecrets ?? [])
|
||
list.Add(new HashableSecret { Missing = true, Name = m });
|
||
return list.Count == 0 ? null : list.OrderBy(x => x.Name, StringComparer.Ordinal).ThenBy(x => x.Missing == true).ToList();
|
||
}
|
||
|
||
private sealed record HashableSecret
|
||
{
|
||
public string? ContentDigest { get; init; }
|
||
public string? KekId { get; init; }
|
||
public bool? Missing { get; init; }
|
||
public required string Name { get; init; }
|
||
public long? Revision { get; init; }
|
||
}
|
||
```
|
||
|
||
Update the class doc's EXCLUDED/INCLUDED paragraphs (secrets included as a ciphertext digest; `MissingSecrets` included as markers).
|
||
|
||
- [ ] **Step 4: Run → PASS (incl. golden).** **Step 5: Commit** — `git commit -m "feat(artifacts): secrets and missing-secret markers in the artifact revision hash" -- src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs", "tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactRevisionHashServiceTests", "acceptanceCriteria": ["golden pre-change hash unchanged when no secrets", "revision/ciphertext/wrapped DEK/KekId changes move the hash; order does not", "missing marker differs from present", "alphabetical-properties test passes"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Central shared secret store — `Secrets:Store:Provider`
|
||
|
||
**Goal:** A Central node can use the shared SQL-Server secret store without the hub; a clustered Central on `Sqlite` warns.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (`"Store": { "Provider": "Sqlite" }` under `Secrets`, with an `_comment`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs` (extend), `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs` (extend)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Central + `Provider=SqlServer` + connection string → `ISecretStore` resolves to the library's SQL-Server store; no hub endpoint mapped.
|
||
- [ ] Central + `Provider=SqlServer` + blank connection string → startup failure naming `Secrets:SqlServer:ConnectionString`; a `${secret:` value is rejected (bootstrap rule, same message style as the existing Grpc-mode check).
|
||
- [ ] Site role ignores `Secrets:Store:Provider` (always local SQLite).
|
||
- [ ] Clustered Central (seed-nodes > 1) with `Provider=Sqlite` logs one Warning naming the per-node divergence risk; unknown provider value fails validation.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~SecretsReplicationWiringTests|FullyQualifiedName~StartupValidatorTests"` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests** modelled on the existing Grpc-mode central tests in `SecretsReplicationWiringTests` (they already build a service collection with `AddScadaBridgeSecrets(config, SecretsNodeRole.Central)` and assert the resolved `ISecretStore` type) — add `Central_StoreProviderSqlServer_UsesSharedSqlStore_WithoutHub`, `Central_StoreProviderSqlServer_BlankConnectionString_Throws`, `Central_StoreProviderSqlServer_SecretReferenceConnectionString_Throws`, `Site_IgnoresStoreProvider`. In `StartupValidatorTests` add `ClusteredCentral_SqliteSecretStore_Warns` and `UnknownSecretStoreProvider_Fails`.
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement.** In `SecretsRegistration`: add `public const string StoreProviderKey = "Secrets:Store:Provider";` and, in `AddScadaBridgeSecrets`, BEFORE the replication branch: when `role == SecretsNodeRole.Central` and the provider equals `SqlServer` (ordinal-ignore-case) and replication is not enabled, validate the connection string with the same helper the Grpc-central branch uses (blank / `${secret:` rejection), then call `services.AddZbSecretsSqlServerStore(...)` with the same arguments the Grpc-central branch passes, then `services.AddZbSecrets(...)` exactly as that branch does, and return. Keep the Grpc branch untouched here (Task 11 retires it). In `StartupValidator`: validate the provider is `Sqlite|SqlServer`; for a Central with > 1 seed node and `Sqlite`, log the Warning `Secrets use a per-node SQLite store on a clustered central — each node would deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer`.
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git commit -m "feat(secrets): Secrets:Store:Provider=SqlServer — central pair shares one SQL secret store without the hub" -- src/ZB.MOM.WW.ScadaBridge.Host tests/ZB.MOM.WW.ScadaBridge.Host.Tests`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs", "src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs", "src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter \"FullyQualifiedName~SecretsReplicationWiringTests|FullyQualifiedName~StartupValidatorTests\"", "acceptanceCriteria": ["Central+SqlServer resolves shared SQL store, no hub", "blank or ${secret:} connection string fails naming the key", "Site ignores provider", "clustered Central on Sqlite warns; unknown provider fails"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Central — collect a site's secrets, fail closed, log
|
||
|
||
**Goal:** `ArtifactDeploymentService` adds each site's referenced secrets (ciphertext rows from `ISecretStore`) to its command, fails that site closed on a missing secret, logs names/revisions, and `ComputeCurrentArtifactHashesAsync` includes missing markers.
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` (`BuildDeployArtifactsCommandAsync`, `DeployCoreAsync`, `RetryForSiteAsync`, `ComputeCurrentArtifactHashesAsync`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj` (`<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />`), DeploymentManager DI registration (`SiteSecretsCollector` singleton)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs` (new), `ArtifactDeploymentServiceTests.cs` (extend)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] A site whose data connections reference `A` and `B` gets a command whose `Secrets` are exactly A and B's `StoredSecret` rows mapped field-for-field; other central secrets are not included.
|
||
- [ ] A referenced name that is absent or `IsDeleted` → that site's `SiteArtifactResult` fails with `Secret 'X' referenced by data connection(s) C1, C2 is not in the central secret store`, nothing is sent to that site, other sites in the batch still deploy.
|
||
- [ ] `ComputeCurrentArtifactHashesAsync` puts missing names in `MissingSecrets` instead of failing, so such a site reads *Out of date*.
|
||
- [ ] Central logs Information `Deploying {Count} secret(s) to site {SiteId}: {Secrets}` (formatted `name@rN`) and Error for missing; a test asserts no log/exception text contains the secret's plaintext or base64 ciphertext.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FullyQualifiedName~SiteSecretsCollectorTests|FullyQualifiedName~ArtifactDeploymentServiceTests"` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests** for the collector with an in-memory fake `ISecretStore` (implement only `GetAsync`; other members throw `NotSupportedException`):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Collect_ReturnsOnlyReferencedRows_AndNamesMissing()
|
||
{
|
||
var store = new FakeSecretStore(Row("A", 3), Row("B", 1, deleted: true), Row("OTHER", 1));
|
||
var result = await new SiteSecretsCollector(store).CollectAsync(
|
||
[Dc("GW1", """{"ApiKey":"secret:A"}"""), Dc("GW2", """{"ApiKey":"secret:B"}""")], default);
|
||
Assert.Equal(["A"], result.Secrets.Select(s => s.Name));
|
||
Assert.Equal(3, result.Secrets[0].Revision);
|
||
Assert.Equal(["B"], result.Missing.Select(m => m.Name));
|
||
Assert.Equal(["GW2"], result.Missing[0].ReferencedBy);
|
||
}
|
||
```
|
||
|
||
And in `ArtifactDeploymentServiceTests`: `DeployToAllSites_SiteWithMissingSecret_FailsThatSiteOnly_AndSendsNothingToIt` (assert the communication fake received no command for that site and the other site succeeded), `ComputeCurrentHashes_MissingSecret_UsesMissingMarker` (hash differs from the one computed once the secret exists), `DeployLogs_NeverContainSecretMaterial`.
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement the collector.**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
|
||
using ZB.MOM.WW.Secrets.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||
|
||
public sealed record SiteSecretsCollection(IReadOnlyList<SecretArtifact> Secrets, IReadOnlyList<SecretReference> Missing);
|
||
|
||
/// <summary>Central half of design §3.1/§3.3: which secrets a site needs and their ciphertext rows. Never decrypts.</summary>
|
||
public sealed class SiteSecretsCollector(ISecretStore store)
|
||
{
|
||
public async Task<SiteSecretsCollection> CollectAsync(IEnumerable<DataConnectionArtifact> connections, CancellationToken ct)
|
||
{
|
||
var secrets = new List<SecretArtifact>(); var missing = new List<SecretReference>();
|
||
foreach (var reference in SecretReferenceScanner.Scan(connections))
|
||
{
|
||
var row = await store.GetAsync(new SecretName(reference.Name), ct);
|
||
if (row is null || row.IsDeleted) { missing.Add(reference); continue; }
|
||
secrets.Add(new SecretArtifact(row.Name.Value, row.ContentType.ToString(), row.Ciphertext, row.Nonce, row.Tag,
|
||
row.WrappedDek, row.WrapNonce, row.WrapTag, row.KekId, row.Revision, row.CreatedUtc, row.UpdatedUtc, row.Description));
|
||
}
|
||
return new SiteSecretsCollection(secrets, missing);
|
||
}
|
||
}
|
||
```
|
||
|
||
(`SecretName` is a `readonly record struct` that normalizes its value at construction — always ship `row.Name.Value`, the normalized form. `SecretContentType` is an enum; ship `ToString()`, parsed back in Task 9.)
|
||
|
||
In `BuildDeployArtifactsCommandAsync`, after mapping data connections: `var collected = await _secretsCollector.CollectAsync(dataConnectionArtifacts, ct);` and pass `Secrets: collected.Secrets.Count > 0 ? collected.Secrets : null, MissingSecrets: collected.Missing.Count > 0 ? collected.Missing.Select(m => m.Name).ToList() : null`. Return the missing list alongside the command (change the private method to return `(DeployArtifactsCommand Command, IReadOnlyList<SecretReference> Missing)`). In `DeployCoreAsync` and `RetryForSiteAsync`: if `Missing.Count > 0`, log Error and produce a failed `SiteArtifactResult` with the message from the acceptance criteria WITHOUT calling `_communicationService`; else log the Information line (only when secrets were present) and send. `ComputeCurrentArtifactHashesAsync` keeps using the command (missing markers included).
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git commit -m "feat(artifacts): central ships each site's referenced secrets (ciphertext) and fails closed on a missing one" -- src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs", "src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter \"FullyQualifiedName~SiteSecretsCollectorTests|FullyQualifiedName~ArtifactDeploymentServiceTests\"", "acceptanceCriteria": ["only referenced rows shipped, field-for-field", "missing/tombstoned ref fails only that site with the named message, nothing sent", "current-hash uses missing markers", "logs name@revision, never material"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Central — stamp gating for old sites + audit row
|
||
|
||
**Goal:** Central never stamps a site *Current* unless the site confirmed applying every shipped secret, and every secret deployment writes one audit row.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs`
|
||
- Modify: `SiteArtifactResult` record (same file or its Commons location) — append `int? SecretsDeployed = null`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DeployCommands.cs` only if its table output enumerates columns (JSON output carries the new field automatically)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Command with ≥ 1 secret + response `Success=true, SecretsApplied=null` → site result is a failure `Site build predates secret deployment — upgrade the site before deploying secrets`; hash NOT stamped.
|
||
- [ ] `SecretsApplied` ≠ shipped count → failure `Site applied {n} of {m} secrets`; not stamped.
|
||
- [ ] Command with no secrets → old-site response (`SecretsApplied=null`) still succeeds and stamps (unchanged behaviour).
|
||
- [ ] One `IAuditService.LogAsync(user, "DeploySiteSecrets", "SiteSecrets", siteIdentifier, siteName, { deploymentId, secrets:[{name,revision}], missing:[name], success, error })` per site whose command carried or would have carried secrets (incl. the fail-closed case); none for sites with no refs. Audit has no material.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactDeploymentServiceTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests** — `OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp`, `PartialApply_FailsAndDoesNotStamp`, `OldSite_NoSecrets_StillStamps`, `SecretDeploy_WritesOneAuditRowPerSite_WithoutMaterial` (capture `IAuditService` calls in the existing test fake; serialize `afterState` and assert it contains the names/revisions and not the base64 ciphertext).
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement** a private `static SiteArtifactResult ReconcileSecretsOutcome(DeployArtifactsCommand cmd, ArtifactDeploymentResponse r, Site site)` applied right after each `DeployArtifactsAsync` response in both paths:
|
||
|
||
```csharp
|
||
var shipped = cmd.Secrets?.Count ?? 0;
|
||
if (r.Success && shipped > 0 && r.SecretsApplied is null)
|
||
return new(site.SiteIdentifier, site.Name, false, "Site build predates secret deployment — upgrade the site before deploying secrets", null);
|
||
if (r.Success && shipped > 0 && r.SecretsApplied != shipped)
|
||
return new(site.SiteIdentifier, site.Name, false, $"Site applied {r.SecretsApplied} of {shipped} secrets", r.SecretsApplied);
|
||
return new(site.SiteIdentifier, site.Name, r.Success, r.ErrorMessage, shipped > 0 ? r.SecretsApplied : null);
|
||
```
|
||
|
||
Stamping already keys off `Success`, so a reconciled failure is not stamped. Add the audit call after the per-site results are known (after the existing `DeployArtifacts` audit row, same ordering rule: audit first, best-effort stamp after).
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git commit -m "feat(artifacts): never stamp a site current unless it applied every secret; DeploySiteSecrets audit row" -- src/ZB.MOM.WW.ScadaBridge.DeploymentManager src/ZB.MOM.WW.ScadaBridge.CLI tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactDeploymentServiceTests", "acceptanceCriteria": ["old site + secrets -> failure, not stamped", "partial apply -> failure, not stamped", "no secrets -> unchanged behaviour", "one DeploySiteSecrets audit row per secret-bearing site, no material"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Site storage — `deployed_secrets` (replicated) + `mirrored_secrets` (local)
|
||
|
||
**Goal:** The site database has a replicated ciphertext table for deployed secrets and a node-local ledger of mirrored names, with full-set accessors.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs` (`"deployed_secrets"` appended to `ReplicatedTables`; update the "ten tables" comments to eleven; `mirrored_secrets` deliberately NOT registered, with a comment why)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs` (new); existing `SiteLocalDbReplicatedTableFkGuardTests` must still pass
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `UpsertDeployedSecretsAsync(IReadOnlyList<SecretArtifact>, deploymentId)` upserts rows (`is_removed = 0`) and marks every other row `is_removed = 1`; returns `(applied names, newly removed names)`.
|
||
- [ ] `GetDeployedSecretsAsync()` returns all rows incl. removed with their `SecretArtifact` round-tripped from `row_json`.
|
||
- [ ] `mirrored_secrets` get/add/remove work; it is not in `ReplicatedTables`.
|
||
- [ ] A security test asserts `row_json` holds only base64 ciphertext fields (deserializes to `SecretArtifact`, no extra properties).
|
||
- [ ] FK guard test green (no FKs on either table).
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsStorageTests` and `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter FullyQualifiedName~SiteLocalDbReplicatedTableFkGuardTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Schema** (append to the `CREATE TABLE IF NOT EXISTS` script):
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS deployed_secrets (
|
||
name TEXT PRIMARY KEY,
|
||
row_json TEXT NOT NULL,
|
||
revision INTEGER NOT NULL,
|
||
updated_utc TEXT NOT NULL,
|
||
is_removed INTEGER NOT NULL DEFAULT 0,
|
||
deployment_id TEXT NOT NULL,
|
||
applied_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS mirrored_secrets (
|
||
name TEXT PRIMARY KEY,
|
||
revision INTEGER NOT NULL,
|
||
mirrored_at TEXT NOT NULL
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Failing tests** against a temp-file `SiteStorageService` (follow the existing SiteStorageService test fixture in `SiteRuntime.Tests/Persistence`):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Upsert_IsFullSet_MarksAbsentRowsRemoved()
|
||
{
|
||
var s = await Fixture.CreateAsync();
|
||
await s.UpsertDeployedSecretsAsync([Art("A", 1), Art("B", 1)], "d1");
|
||
var (applied, removed) = await s.UpsertDeployedSecretsAsync([Art("A", 2)], "d2");
|
||
Assert.Equal(["A"], applied); Assert.Equal(["B"], removed);
|
||
var rows = await s.GetDeployedSecretsAsync();
|
||
Assert.Equal(2, rows.Single(r => r.Name == "A").Secret.Revision);
|
||
Assert.True(rows.Single(r => r.Name == "B").IsRemoved);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement** `DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId)` and the accessors (single transaction; `row_json` via `JsonSerializer.Serialize(secretArtifact)` — byte[] serializes as base64). Add `GetMirroredSecretsAsync`, `SetMirroredSecretAsync(name, revision)`, `RemoveMirroredSecretAsync(name)`. Register `deployed_secrets` in `ReplicatedTables`.
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git commit -m "feat(site): deployed_secrets (LocalDb-replicated ciphertext) + node-local mirrored_secrets ledger" -- src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs", "src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs", "src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsStorageTests", "acceptanceCriteria": ["full-set upsert marks absent rows removed", "rows round-trip SecretArtifact", "mirrored_secrets local only", "row_json ciphertext-only security test", "FK guard green"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: `DeployedSecretsMirror` — per-node apply + verify
|
||
|
||
**Goal:** Every site node reconciles `deployed_secrets` into its own `secrets.db` (apply live rows, delete removed rows it previously mirrored), verifies each deployed secret decrypts, logs changes, and can be invoked synchronously.
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs` (service + `IHostedService` loop)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs` (+ `DeployedSecretsMirrorInterval`, default 15 s) and `SiteRuntimeOptionsValidator.cs` (> 0)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj` (`ZB.MOM.WW.Secrets.Abstractions`), SiteRuntime DI (`AddSingleton<DeployedSecretsMirror>()` + `AddHostedService(sp => sp.GetRequiredService<DeployedSecretsMirror>())`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `ReconcileAsync()` applies each live row with `ISecretStore.ApplyReplicatedAsync` (mapped back to `StoredSecret`), records it in `mirrored_secrets`, and returns a `MirrorResult(Applied, Removed, Failed)`.
|
||
- [ ] A removed row is deleted from the store only if its name is in `mirrored_secrets`; a same-named out-of-band secret never mirrored is left alone.
|
||
- [ ] Each live secret is trial-resolved via `ISecretResolver`; a failure (null or exception) is a `Failed` entry `Secret '{Name}' (kek {KekId}) did not decrypt on this node — the node's master key differs from central's` and is logged at Error.
|
||
- [ ] Logging: Information per applied/removed secret and one summary when anything changed; Debug when in sync; never values.
|
||
- [ ] The hosted loop runs once at start and every `DeployedSecretsMirrorInterval`; a reconcile exception is logged and the loop continues.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests** using a real temp `SiteStorageService`, an in-memory fake `ISecretStore` recording `ApplyReplicatedAsync`/`DeleteAsync`, and a fake `ISecretResolver`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Reconcile_Applies_Verifies_AndOnlyDeletesWhatItMirrored()
|
||
{
|
||
var (mirror, storage, store, resolver) = Harness.Create();
|
||
store.Seed("OUT-OF-BAND");
|
||
await storage.UpsertDeployedSecretsAsync([Art("A", 1), Art("OUT-OF-BAND", 1)], "d1");
|
||
var r1 = await mirror.ReconcileAsync(default);
|
||
Assert.Equal(["A", "OUT-OF-BAND"], r1.Applied.OrderBy(x => x));
|
||
await storage.UpsertDeployedSecretsAsync([], "d2");
|
||
await storage.RemoveMirroredSecretAsync("OUT-OF-BAND"); // simulate: never mirrored by us
|
||
var r2 = await mirror.ReconcileAsync(default);
|
||
Assert.Equal(["A"], r2.Removed);
|
||
Assert.True(store.Contains("OUT-OF-BAND"));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Reconcile_DecryptFailure_IsReportedWithKekId()
|
||
{
|
||
var (mirror, storage, _, resolver) = Harness.Create();
|
||
resolver.FailFor("A");
|
||
await storage.UpsertDeployedSecretsAsync([Art("A", 1, kek: "kek-central")], "d1");
|
||
var r = await mirror.ReconcileAsync(default);
|
||
Assert.Contains("kek-central", Assert.Single(r.Failed));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement.**
|
||
|
||
```csharp
|
||
public sealed record MirrorResult(IReadOnlyList<string> Applied, IReadOnlyList<string> Removed, IReadOnlyList<string> Failed)
|
||
{
|
||
public bool Changed => Applied.Count > 0 || Removed.Count > 0;
|
||
}
|
||
|
||
/// <summary>Design §3.5 step 2: per-node reconcile of the replicated deployed_secrets table into this node's secret store.</summary>
|
||
public sealed class DeployedSecretsMirror(
|
||
SiteStorageService storage, ISecretStore store, ISecretResolver resolver,
|
||
IOptions<SiteRuntimeOptions> options, ILogger<DeployedSecretsMirror> logger, TimeProvider time) : BackgroundService
|
||
{
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
|
||
public async Task<MirrorResult> ReconcileAsync(CancellationToken ct)
|
||
{
|
||
await _gate.WaitAsync(ct);
|
||
try
|
||
{
|
||
var rows = await storage.GetDeployedSecretsAsync();
|
||
var mirrored = (await storage.GetMirroredSecretsAsync()).ToDictionary(m => m.Name, m => m.Revision, StringComparer.Ordinal);
|
||
List<string> applied = [], removed = [], failed = [];
|
||
foreach (var row in rows)
|
||
{
|
||
if (row.IsRemoved)
|
||
{
|
||
if (!mirrored.ContainsKey(row.Name)) continue;
|
||
await store.DeleteAsync(new SecretName(row.Name), "artifact-deploy", ct);
|
||
await storage.RemoveMirroredSecretAsync(row.Name);
|
||
removed.Add(row.Name);
|
||
logger.LogInformation("Removed deployed secret {SecretName} (no longer referenced by this site)", row.Name);
|
||
continue;
|
||
}
|
||
if (!mirrored.TryGetValue(row.Name, out var rev) || rev != row.Secret.Revision)
|
||
{
|
||
await store.ApplyReplicatedAsync(ToStored(row.Secret), ct);
|
||
await storage.SetMirroredSecretAsync(row.Name, row.Secret.Revision);
|
||
applied.Add(row.Name);
|
||
logger.LogInformation("Applied deployed secret {SecretName} r{Revision} (kek {KekId})", row.Name, row.Secret.Revision, row.Secret.KekId);
|
||
}
|
||
if (!await Resolves(row.Name, ct))
|
||
{
|
||
var msg = $"Secret '{row.Name}' (kek {row.Secret.KekId}) did not decrypt on this node — the node's master key differs from central's";
|
||
failed.Add(msg);
|
||
logger.LogError("{Failure}", msg);
|
||
}
|
||
}
|
||
var result = new MirrorResult(applied, removed, failed);
|
||
if (result.Changed) logger.LogInformation("Deployed secrets mirror: {Applied} applied, {Removed} removed, {Failed} failed", applied.Count, removed.Count, failed.Count);
|
||
else logger.LogDebug("Deployed secrets mirror in sync ({Count} secret(s))", rows.Count(r => !r.IsRemoved));
|
||
return result;
|
||
}
|
||
finally { _gate.Release(); }
|
||
}
|
||
|
||
private async Task<bool> Resolves(string name, CancellationToken ct)
|
||
{
|
||
try { return await resolver.GetAsync(new SecretName(name), ct) is not null; }
|
||
catch (Exception) { return false; }
|
||
}
|
||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
using var timer = new PeriodicTimer(options.Value.DeployedSecretsMirrorInterval, time);
|
||
do
|
||
{
|
||
try { await ReconcileAsync(stoppingToken); }
|
||
catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Deployed secrets mirror pass failed; retrying next interval"); }
|
||
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||
}
|
||
|
||
private static StoredSecret ToStored(SecretArtifact s) => new()
|
||
{
|
||
Name = new SecretName(s.Name), Description = s.Description, ContentType = Enum.Parse<SecretContentType>(s.ContentType, ignoreCase: false),
|
||
Ciphertext = s.Ciphertext, Nonce = s.Nonce, Tag = s.Tag, WrappedDek = s.WrappedDek, WrapNonce = s.WrapNonce,
|
||
WrapTag = s.WrapTag, KekId = s.KekId, Revision = s.Revision, CreatedUtc = s.CreatedUtc, UpdatedUtc = s.UpdatedUtc
|
||
};
|
||
}
|
||
```
|
||
|
||
`ContentType` is the `SecretContentType` enum (`Text|ConnectionString|Json|BinaryBase64`): Task 6 writes `row.ContentType.ToString()`, this method parses it back with `Enum.Parse<SecretContentType>` — add a round-trip unit test over all four values. Note: the resolver caches (`ResolveCacheTtl` 30 s); if a trial-resolve after a rotation returns the cached old value that is still a successful decrypt of *a* value — acceptable for the verify's purpose (KEK check), document it in the class remarks.
|
||
|
||
- [ ] **Step 4: Run → PASS.** **Step 5: Commit** — `git commit -m "feat(site): DeployedSecretsMirror — per-node apply + decrypt-verify of deployed secrets" -- src/ZB.MOM.WW.ScadaBridge.SiteRuntime tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs", "src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs", "src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests", "acceptanceCriteria": ["live rows applied via ApplyReplicatedAsync and ledgered", "removed rows deleted only if previously mirrored", "decrypt failure reported with KekId at Error", "change/in-sync logging without values", "hosted loop at start + interval, survives exceptions"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Site apply — secrets in `HandleDeployArtifacts`
|
||
|
||
**Goal:** The active site node writes shipped secrets to `deployed_secrets`, runs the mirror synchronously, and answers with `SecretsApplied` or a failure.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`HandleDeployArtifacts`, constructor gets `DeployedSecretsMirror?`)
|
||
- Modify: the actor's Props/DI construction site to pass the mirror
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs` (new; follow the existing artifact-apply actor tests)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `Secrets == null` → `deployed_secrets` untouched, response `SecretsApplied = null` (old-central compatible).
|
||
- [ ] `Secrets` present (incl. empty) → full-set write, synchronous `ReconcileAsync`, response `Success=true, SecretsApplied = shipped count` when the mirror reports no failures.
|
||
- [ ] Any mirror failure → `Success=false`, `ErrorMessage` = the failure lines joined with `; `, `SecretsApplied` = count applied without failure.
|
||
- [ ] Site Information log `Applying {Count} deployed secret(s), deploymentId={DeploymentId}` + the mirror's per-secret lines.
|
||
|
||
**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeploymentManagerActorArtifactSecretsTests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Failing tests** (TestKit; send `DeployArtifactsCommand` with 2 secrets, expect `ArtifactDeploymentResponse` with `SecretsApplied == 2`; a resolver fake failing one → `Success == false` and the KekId in the message; `Secrets == null` → `SecretsApplied == null` and table empty).
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement** inside the existing `Task.Run` block, AFTER data connections are stored (so the DCL can resolve by the time it reconnects) and BEFORE the success reply:
|
||
|
||
```csharp
|
||
int? secretsApplied = null;
|
||
string? secretError = null;
|
||
if (command.Secrets != null && _secretsMirror != null)
|
||
{
|
||
_logger.LogInformation("Applying {Count} deployed secret(s), deploymentId={DeploymentId}", command.Secrets.Count, command.DeploymentId);
|
||
await _storage.UpsertDeployedSecretsAsync(command.Secrets, command.DeploymentId);
|
||
var mirror = await _secretsMirror.ReconcileAsync(CancellationToken.None);
|
||
var failedNames = mirror.Failed.Count;
|
||
secretsApplied = command.Secrets.Count - failedNames;
|
||
if (failedNames > 0) secretError = string.Join("; ", mirror.Failed);
|
||
}
|
||
```
|
||
|
||
and build the reply with `Success = secretError is null && <existing success>`, `ErrorMessage = secretError ?? <existing>`, `SecretsApplied = secretsApplied`. If the mirror is not registered (null) but secrets were shipped, reply failure `Secret deployment is not enabled on this site node`.
|
||
|
||
- [ ] **Step 4: Run → PASS**, plus the existing artifact-apply tests. **Step 5: Commit** — `git commit -m "feat(site): artifact apply writes deployed secrets and answers SecretsApplied" -- src/ZB.MOM.WW.ScadaBridge.SiteRuntime tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeploymentManagerActorArtifactSecretsTests", "acceptanceCriteria": ["null Secrets leaves table and SecretsApplied null", "secrets -> full-set write + sync mirror + SecretsApplied=count", "mirror failure -> Success=false with KekId message", "apply logging"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Retire the gRPC hub for ScadaBridge; switch the rig
|
||
|
||
**Goal:** ScadaBridge no longer composes the secrets hub (client or server); config asking for it fails fast; the docker rig's central pair uses `Secrets:Store:Provider=SqlServer` and site-a gets secrets only via artifact deploy.
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs` (delete the Grpc hub/hub-client and SqlServer-replication branches; keep local SQLite + Task 5's shared store), `Program.cs` (remove `MapScadaBridgeSecretsHub`), `StartupValidator.cs` (reject `Secrets:Replication:Enabled=true`: `Secrets replication (the gRPC hub) is retired in ScadaBridge — sites receive secrets with Deploy Artifacts; central uses Secrets:Store:Provider=SqlServer`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (remove `Replication`/`GrpcHub` blocks; keep `SqlServer.ConnectionString`/`SchemaName`; update comments)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` (drop `ZB.MOM.WW.Secrets.Replicator.Grpc` reference) and `Directory.Packages.props` if nothing else uses it
|
||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs`, `MappedGrpcAuthenticationCoverageTests.cs` (hub route gone)
|
||
- Modify: `docker/docker-compose.yml` (delete `x-secrets-hub-env` + `x-secrets-hub-site-env` anchors and their merges; central-a/b get `Secrets__Store__Provider: "SqlServer"` + the existing `x-secrets-hub-central-env` connection string renamed `x-secrets-central-store-env`; the rig KEK `ZB_SECRETS_MASTER_KEY` goes on central-a/b AND site-a-a/b — every node that handles deployed secrets must share it; site-b/site-c keep a DIFFERENT dev KEK so the Task 13 mismatch negative is real)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] No ScadaBridge code references `AddZbSecretsGrpcHub`, `AddZbSecretsGrpcHubClient` or `MapScadaBridgeSecretsHub`.
|
||
- [ ] `Secrets:Replication:Enabled=true` on either role → startup failure with the message above.
|
||
- [ ] Wiring tests cover: Central Sqlite, Central SqlServer store, Site local — all green; hub-route auth coverage test updated.
|
||
- [ ] `docker compose config` renders; central-a/b carry `Secrets__Store__Provider=SqlServer`; no node carries `Secrets__Replication__*` or `Secrets__GrpcHub__*`.
|
||
|
||
**Verify:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 errors; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` → all pass; `docker compose -f docker/docker-compose.yml config | grep -c Secrets__Replication` → `0`.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1:** Update tests first (replace hub-mode wiring tests with `ReplicationEnabled_IsRejected_BothRoles`), run → FAIL.
|
||
- [ ] **Step 2:** Remove the hub branches/mapping/package ref, add the validator rule, run → PASS.
|
||
- [ ] **Step 3:** Edit `docker-compose.yml` as listed; `docker compose -f docker/docker-compose.yml config > /dev/null` → exit 0.
|
||
- [ ] **Step 4: Commit** — `git commit -m "feat(secrets)!: retire the gRPC secrets hub in ScadaBridge — sites get secrets via Deploy Artifacts; rig central on the shared SQL store" -- src/ZB.MOM.WW.ScadaBridge.Host tests/ZB.MOM.WW.ScadaBridge.Host.Tests docker/docker-compose.yml Directory.Packages.props`
|
||
|
||
```json:metadata
|
||
{"files": ["src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs", "src/ZB.MOM.WW.ScadaBridge.Host/Program.cs", "src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs", "docker/docker-compose.yml"], "verifyCommand": "dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests", "acceptanceCriteria": ["no hub wiring left in ScadaBridge", "Replication:Enabled=true fails startup with the retirement message", "wiring tests green", "compose renders; centrals SqlServer store; no hub env anywhere"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Documentation sync
|
||
|
||
**Goal:** The spec docs, CLAUDE.md, the deferred-work register and the umbrella index describe secrets-via-artifacts and the hub retirement.
|
||
|
||
**Files:**
|
||
- Modify: `docs/requirements/Component-DeploymentManager.md` (artifact set + secrets, fail-closed, hash, audit), `Component-SiteRuntime.md` (deployed_secrets, mirror, options), `Component-Host.md` (Secrets section: providers, hub retired, bootstrap secrets out-of-band), `Component-CentralUI.md` (Sites page: secrets make a site out of date; failure messages)
|
||
- Modify: `docs/deployment/topology-guide.md` (KEK must match on every central + site node; standby convergence interval)
|
||
- Modify: `CLAUDE.md` (replace the secrets/hub facts with one bullet under *Templates & Deployment*; update the LocalDb bullet "ten tables" → eleven incl. `deployed_secrets`)
|
||
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (new row: vd03 + QA rollout — zbsecrets store, `Secrets:Store:Provider=SqlServer` on both centrals, KEK alignment check, recreate hand-seeded site secrets centrally, Deploy Artifacts)
|
||
- Modify: `../scadaproj/CLAUDE.md` (ScadaBridge row: short line; Secrets 0.6.3 note in the libraries table) and `../scadaproj/ZB.MOM.WW.Secrets/CLAUDE.md` (ScadaBridge no longer a hub consumer)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `grep -rn "GrpcHub\|secrets hub" docs/requirements CLAUDE.md` returns only historical/retired mentions.
|
||
- [ ] Register row lists the four rollout prerequisites and the trigger ("user asks for the vd03/QA secrets window").
|
||
- [ ] Umbrella index row stays a few lines.
|
||
|
||
**Verify:** `grep -rn "deployed_secrets" docs/requirements/Component-SiteRuntime.md CLAUDE.md` → matches in both; `git diff --stat` shows only doc files.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1:** Edit the docs listed (quote the design doc sections rather than restating detail).
|
||
- [ ] **Step 2:** `git diff` review. **Step 3: Commit** in each repo — `git commit -m "docs: site secrets ride the artifact deploy; gRPC hub retired in ScadaBridge" -- docs CLAUDE.md` and `git -C ../scadaproj commit -m "index: ScadaBridge secrets via artifact deploy; Secrets 0.6.3" -- CLAUDE.md ZB.MOM.WW.Secrets/CLAUDE.md`
|
||
|
||
```json:metadata
|
||
{"files": ["docs/requirements/Component-DeploymentManager.md", "docs/requirements/Component-SiteRuntime.md", "docs/requirements/Component-Host.md", "CLAUDE.md", "docs/plans/2026-07-08-deferred-work-register.md"], "verifyCommand": "grep -rn deployed_secrets docs/requirements/Component-SiteRuntime.md CLAUDE.md", "acceptanceCriteria": ["component docs + topology guide updated", "CLAUDE.md bullet + LocalDb eleven tables", "register rollout row with 4 prerequisites", "umbrella + Secrets CLAUDE.md updated"], "modelTier": "mechanical"}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Full test run, rig rebuild, live gate
|
||
|
||
**Goal:** The whole solution is green and the rebuilt docker rig demonstrates secrets deploying with artifacts end to end, including standby convergence, staleness, removal, fail-closed and KEK mismatch.
|
||
|
||
**Files:**
|
||
- Create: `docker/verify-site-secrets.sh` (idempotent gate script, prints PASS/FAIL per check, exits non-zero on any FAIL)
|
||
- Create: `docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md` (results record)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green except the known env-only Playwright residue (list any failure explicitly).
|
||
- [ ] `bash docker/deploy.sh` succeeds; all 8 nodes `/health/ready` 200.
|
||
- [ ] Gate checks (each PASS):
|
||
1. Create secret `sb-gate-mxgw` at central (Secrets CLI or `/admin/secrets`) and an unbound MxGateway data connection on site-a with `"ApiKey":"secret:sb-gate-mxgw"` → Sites page/`site list` shows site-a **Out of date**, site-b/site-c unchanged.
|
||
2. `deploy artifacts --site-id <site-a>` → success, `secretsDeployed: 1`; site-a **Current**.
|
||
3. Both site-a nodes' `secrets.db` contain `sb-gate-mxgw` at the central revision (`docker exec` + `sqlite3` on the library table, ciphertext compare by SHA-256) — the standby within 30 s.
|
||
4. Rotate the secret at central → site-a **Out of date** → deploy → both nodes at the new revision.
|
||
5. Remove the `secret:` reference → deploy → both nodes no longer hold the secret; an out-of-band secret seeded on site-a-a beforehand is still present.
|
||
6. Reference a non-existent `sb-gate-missing` → deploy site-a fails with the named message; site-a stays **Out of date**; nothing changed on the site.
|
||
7. Same connection on site-b (different dev KEK) → deploy fails with the KEK-mismatch message naming the KekId.
|
||
8. Audit log has one `DeploySiteSecrets` row per secret-bearing deploy; `grep` of all 8 nodes' logs for the secret's plaintext and base64 ciphertext → 0 hits.
|
||
- [ ] Gate script and results record committed; gate objects cleaned up by the script's teardown.
|
||
|
||
**Verify:** `bash docker/verify-site-secrets.sh` → `PASS 8/8`.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx` — record counts.
|
||
- [ ] **Step 2:** `bash docker/deploy.sh` (from the primary checkout, not a worktree — see memory: deploy.sh from a worktree rehomes mounts); wait for readiness.
|
||
- [ ] **Step 3:** Write `docker/verify-site-secrets.sh` implementing checks 1–8 with the CLI (`src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge --url http://localhost:9000 --username multi-role --password password`), `docker exec`, and a trap-based teardown; run it until PASS 8/8, fixing product defects in their owning task's files (commit separately with the task tag).
|
||
- [ ] **Step 4:** Write the results record (method, per-check evidence, timings incl. standby convergence), commit — `git commit -m "test(rig): site secrets artifact-deploy live gate PASS 8/8" -- docker/verify-site-secrets.sh docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md`
|
||
|
||
```json:metadata
|
||
{"files": ["docker/verify-site-secrets.sh", "docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md"], "verifyCommand": "bash docker/verify-site-secrets.sh", "acceptanceCriteria": ["solution tests green (env-only residue listed)", "rig rebuilt, 8 nodes ready", "gate checks 1-8 PASS incl. standby convergence, removal, fail-closed, KEK mismatch, audit + no-material grep", "gate script + results record committed"], "modelTier": "standard"}
|
||
```
|
||
|
||
---
|
||
|
||
## Dependencies
|
||
|
||
- Task 1 is independent (library) — run first so the rig transition in Task 11/13 is observable.
|
||
- Task 2 → Tasks 4, 6, 7, 9, 10. Task 3 → Task 6. Task 4 → Task 6. Task 5 → Task 6 (central store DI) and Task 11.
|
||
- Task 6 → Task 7. Task 8 → Task 9 → Task 10.
|
||
- Tasks 7 and 10 → Task 11 → Task 12 → Task 13.
|