docs(plans): site secrets ride the artifact deploy — design + implementation plan
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
# Site secrets ride the artifact deploy — design
|
||||||
|
|
||||||
|
**Status:** design, 2026-09-28. Plan: `2026-09-28-site-secrets-artifact-deploy.md`.
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
A site resolves secrets at runtime (today: an MxGateway data connection's `ApiKey: "secret:<name>"`,
|
||||||
|
`DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:101`). The only way a secret reaches a site
|
||||||
|
is the pull-only gRPC secrets hub (`Secrets:Replication:Mode=Grpc`), which:
|
||||||
|
|
||||||
|
- copies **every** central secret to **every** participating site node every 30 s — no per-site scope;
|
||||||
|
- is invisible to the deployment model: the Sites page *Artifacts current / out of date* badge
|
||||||
|
(`Site.ArtifactRevisionHash`, 2026-09-14) knows nothing about secrets, so a site can read *Current*
|
||||||
|
while its data connection cannot resolve its key;
|
||||||
|
- logs nothing on a successful in-sync sweep (library `GrpcSecretSyncService` logs only when rows were
|
||||||
|
pulled or on failure) and exposes no status;
|
||||||
|
- is enabled only on the docker rig's site-a; vd03 and QA run with replication OFF, so each site node's
|
||||||
|
`secrets.db` is filled by hand.
|
||||||
|
|
||||||
|
## 2. Decisions (user, 2026-09-28)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| U1 | A site receives **only the secrets it references** — never other sites' keys. |
|
||||||
|
| U2 | The artifact deploy **replaces** the gRPC hub as the way secrets reach sites (hub retired for ScadaBridge sites). |
|
||||||
|
| U3 | Logging = **deploy log lines** (central + site), **audit rows**, and **hub sweep logging** (for the transition until the hub is retired, and for other hub consumers). |
|
||||||
|
| U4 | Central deploys from **one shared SQL-Server secret store** used by both central nodes. |
|
||||||
|
|
||||||
|
## 3. Design
|
||||||
|
|
||||||
|
### 3.1 Which secrets a site needs — derived, not configured
|
||||||
|
|
||||||
|
`SecretReferenceScanner.Scan(IEnumerable<DataConnectionArtifact>)` walks each connection's
|
||||||
|
`PrimaryConfigurationJson` and `BackupConfigurationJson` and collects every JSON **string value**
|
||||||
|
of the form `secret:<name>` (prefix case-insensitive, `<name>` trimmed, must be a valid `SecretName`).
|
||||||
|
It is protocol-agnostic on purpose: a new adapter that adopts `secret:` references is covered with
|
||||||
|
no scanner change. Result: a distinct, ordinal-sorted name set per site, each name carrying the
|
||||||
|
data connections that reference it (for error messages and logs).
|
||||||
|
|
||||||
|
**Deliberately NOT included:** bootstrap secrets — the site's gRPC PSK (`SB-GRPC-PSK-{siteId}`), the
|
||||||
|
LocalDb replication key, the secrets master key. The site needs them *before* it can receive an
|
||||||
|
artifact deploy, and pushing a rotated PSK through the channel it authenticates would sever that
|
||||||
|
channel. They stay out-of-band (service environment / hand-seeded store), exactly as today on vd03/QA.
|
||||||
|
`${secret:X}` references inside a node's own `appsettings` are node-local and invisible to central —
|
||||||
|
also out of scope.
|
||||||
|
|
||||||
|
### 3.2 Central source of truth — the shared SQL store (U4)
|
||||||
|
|
||||||
|
New option `Secrets:Store:Provider` (`Sqlite` default | `SqlServer`), read **only on the Central
|
||||||
|
role**. `SqlServer` registers the library's `AddZbSecretsSqlServerStore` against
|
||||||
|
`Secrets:SqlServer:ConnectionString` (literal/env value — never `${secret:}`, same bootstrap rule as
|
||||||
|
today) so both central nodes read and write one store; the `/admin/secrets` page edits it. This is
|
||||||
|
the store the hub's Grpc-mode central already uses — decoupled from the hub. A clustered Central left
|
||||||
|
on `Sqlite` boots but `StartupValidator` logs a Warning (same shape as the inbound API key-store
|
||||||
|
warning, row 108), because the two nodes would deploy different secret sets depending on which is
|
||||||
|
active.
|
||||||
|
|
||||||
|
### 3.3 The wire: ciphertext rows, never plaintext
|
||||||
|
|
||||||
|
`DeployArtifactsCommand` gains an additive `Secrets` list of `SecretArtifact` — the library's
|
||||||
|
`StoredSecret` fields verbatim (name, content type, ciphertext/nonce/tag, wrapped DEK/nonce/tag,
|
||||||
|
`KekId`, `Revision`, created/updated UTC). Central never decrypts; the site applies the row with
|
||||||
|
`ISecretStore.ApplyReplicatedAsync` (last-writer-wins on `UpdatedUtc`/`Revision`, the same path the
|
||||||
|
hub sweep uses). **Every node must therefore share the KEK** (`ZB_SECRETS_MASTER_KEY` / master-key
|
||||||
|
file) — exactly the hub's existing requirement. A mismatch is detected at deploy time (§3.5), not at
|
||||||
|
the next reconnect. Proto: `DeployArtifactsCommandDto.secrets = 9` (`SecretArtifactListDto`, absent ⇒
|
||||||
|
null) and `ArtifactDeploymentResponseDto` gains `secrets_applied` (wrapper `Int32Value`, absent ⇒ null).
|
||||||
|
|
||||||
|
**Fail-closed on a missing secret.** If a referenced name is absent or tombstoned in the central
|
||||||
|
store, that site's deploy fails before anything is sent: `Secret 'X' referenced by data connection(s)
|
||||||
|
A, B is not in the central secret store`. Shipping the rest would stamp the site *Current* while a
|
||||||
|
connection cannot resolve its key.
|
||||||
|
|
||||||
|
### 3.4 Staleness — secrets are part of the artifact hash
|
||||||
|
|
||||||
|
`ArtifactRevisionHashService` adds `Secrets` → `HashableSecret { ContentDigest, KekId, Missing,
|
||||||
|
Name, Revision }` (alphabetical — the existing ordering test covers it). `ContentDigest` = SHA-256 of
|
||||||
|
ciphertext‖nonce‖tag‖wrappedDek, so a KEK rewrap (new wrapped DEK, same revision) correctly marks
|
||||||
|
sites out of date. `ComputeCurrentArtifactHashesAsync` hashes missing names with `Missing = true`, so
|
||||||
|
a site referencing a not-yet-created secret reads *Out of date*, never *Current*. The list is
|
||||||
|
null-when-empty and the serializer omits nulls, so **sites with no secret references keep their
|
||||||
|
current hash** — only sites that reference secrets flip to *Out of date* once after rollout.
|
||||||
|
|
||||||
|
### 3.5 Site apply — both nodes, via a replicated table
|
||||||
|
|
||||||
|
The artifact apply runs on the active node's `DeploymentManagerActor` singleton, but `secrets.db` is
|
||||||
|
a per-node file outside LocalDb. So:
|
||||||
|
|
||||||
|
1. **`deployed_secrets`** — a new LocalDb table in `SiteStorageSchema` (`name` PK, `row_json` = the
|
||||||
|
`SecretArtifact` ciphertext row as JSON, `revision`, `updated_utc`, `is_removed` 0/1,
|
||||||
|
`deployment_id`, `applied_at`), added to `SiteLocalDbSetup.ReplicatedTables` (becomes the 11th;
|
||||||
|
LocalDb 0.3.x snapshots just that table on an already-replicating pair). No FKs (row 105 guard).
|
||||||
|
Ciphertext only — the no-plaintext rule that kept `smtp_configurations` unregistered holds.
|
||||||
|
`HandleDeployArtifacts` applies it full-set: upsert every shipped row, mark rows absent from the
|
||||||
|
set `is_removed = 1` (kept, so the standby learns about the removal).
|
||||||
|
2. **`DeployedSecretsMirror`** — a per-node service (every site node, not a singleton) that reconciles
|
||||||
|
`deployed_secrets` into the node's own `ISecretStore`: `ApplyReplicatedAsync` for live rows,
|
||||||
|
`DeleteAsync` for removed rows **only if the name was previously mirrored** (a local, unreplicated
|
||||||
|
`mirrored_secrets` table records names this mirror wrote — an out-of-band secret of the same name
|
||||||
|
that was never deployed is never deleted). After applying, it **trial-resolves** each deployed
|
||||||
|
secret through `ISecretResolver`; a decrypt failure (KEK mismatch) is an error naming the secret
|
||||||
|
and the row's `KekId`. It runs at startup, every `SiteRuntime:DeployedSecretsMirrorInterval`
|
||||||
|
(default 15 s — the standby's convergence path), and on demand.
|
||||||
|
3. The active node's apply calls the mirror **synchronously** after writing the table and fails the
|
||||||
|
deploy response if any secret did not apply or verify; on success the response carries
|
||||||
|
`SecretsApplied = n`. The standby converges through LocalDb replication + its own mirror within one
|
||||||
|
interval.
|
||||||
|
|
||||||
|
### 3.6 Mixed versions
|
||||||
|
|
||||||
|
A pre-feature site ignores proto field 9 and answers `Success` with `secrets_applied` absent. Central
|
||||||
|
treats *command carried ≥ 1 secret AND `SecretsApplied` is null* as a **failure** for that site
|
||||||
|
(`site build predates secret deployment — upgrade the site`) and does not stamp the hash. A command
|
||||||
|
with no secrets is unaffected, so an old site with no secret references deploys exactly as before.
|
||||||
|
Upgrade central first, as always.
|
||||||
|
|
||||||
|
### 3.7 Logging and audit (U3)
|
||||||
|
|
||||||
|
- **Central, per site:** Information `Deploying {Count} secret(s) to site {SiteId}: {Name}@r{Revision},…`;
|
||||||
|
Error on a missing secret (names + referencing connections). Never values or ciphertext.
|
||||||
|
- **Site, active apply and every mirror pass that changes something:** Information per secret
|
||||||
|
`Applied deployed secret {Name} r{Revision} (kek {KekId})` / `Removed deployed secret {Name}`;
|
||||||
|
Error on apply/verify failure; one Information summary per pass. An in-sync mirror pass logs at
|
||||||
|
Debug only.
|
||||||
|
- **Audit:** one `IAuditService` row per site deploy that carried secrets — action `DeploySiteSecrets`,
|
||||||
|
entity type `SiteSecrets`, entity id = site identifier, after-state `{ deploymentId, secrets:
|
||||||
|
[{name, revision}], removed: [name], success }`. Same config-change audit as the existing
|
||||||
|
`DeployArtifacts` row.
|
||||||
|
- **Hub sweep (library `ZB.MOM.WW.Secrets` 0.6.3, scadaproj):** every sweep outcome is observable —
|
||||||
|
in-sync sweeps at Debug plus an Information heartbeat at most once per `InSyncLogInterval`
|
||||||
|
(default 1 h), a `Recovered after {Failures} failed sweep(s)` Information line, and a public
|
||||||
|
`ISecretsHubSyncStatus` (`LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError`).
|
||||||
|
Useful for the rig until the hub is retired and for any other hub consumer.
|
||||||
|
|
||||||
|
### 3.8 Hub retirement (U2)
|
||||||
|
|
||||||
|
Once the artifact path is live on the rig, ScadaBridge stops composing the hub: the Site role no
|
||||||
|
longer registers `AddZbSecretsGrpcHubClient`, the Central role no longer maps the hub endpoint, and
|
||||||
|
`StartupValidator` refuses `Secrets:Replication:Enabled=true` with `Mode=Grpc` with a message pointing
|
||||||
|
at the artifact deploy. The library keeps the hub (other consumers); only ScadaBridge stops using it.
|
||||||
|
The rig's site-a moves from the hub to the artifact path; central keeps the shared SQL store through
|
||||||
|
`Secrets:Store:Provider=SqlServer`.
|
||||||
|
|
||||||
|
## 4. Rollout (deployment records, not code)
|
||||||
|
|
||||||
|
vd03 and QA need, per environment: the `zbsecrets` store in the configuration SQL Server + both
|
||||||
|
centrals on `Secrets:Store:Provider=SqlServer`; **one KEK across every central and site node**
|
||||||
|
(verify before enabling — the mirror's verify step reports a mismatch by name); existing hand-seeded
|
||||||
|
site secrets recreated at central; then *Deploy Artifacts*. Tracked as a deferred-work register row.
|
||||||
|
|
||||||
|
## 5. Out of scope
|
||||||
|
|
||||||
|
Per-site explicit secret lists; secrets for external systems / database connections (they ship
|
||||||
|
literal values today); plaintext transport; moving bootstrap secrets through the deploy.
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-09-28-site-secrets-artifact-deploy.md",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"subject": "Task 1: Secrets library 0.6.3 \u2014 hub sweep logging + status",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [],
|
||||||
|
"description": "**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.\n\n**Files:**\n- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs`\n- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/ISecretsHubSyncStatus.cs`\n- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubClientOptions.cs` (add `InSyncLogInterval`, default 1 h, validated > 0)\n- Modify: the Grpc hub client DI extension (register `ISecretsHubSyncStatus` singleton)\n- 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)\n- Test: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/GrpcSecretSyncServiceLoggingTests.cs` (new)\n- Modify: `Directory.Packages.props` (ScadaBridge \u2014 all `ZB.MOM.WW.Secrets*` to 0.6.3)\n\n**Acceptance Criteria:**\n- [ ] 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`.\n- [ ] The first successful sweep after \u2265 1 failure logs EventId 6 `Secrets hub sync recovered after {Failures} failed sweep(s)` at Information.\n- [ ] `ISecretsHubSyncStatus` returns `LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError` (message only, never a secret value) reflecting the last sweep.\n- [ ] Existing EventIds 1\u20133 unchanged; library tests green; package 0.6.3 on the Gitea feed; ScadaBridge builds on 0.6.3.\n\n**Verify:** `cd ~/Desktop/scadaproj/ZB.MOM.WW.Secrets && dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests` \u2192 all pass; `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx` \u2192 0 errors.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"subject": "Task 2: Wire contract \u2014 `SecretArtifact`, `Secrets`, `Secret",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [],
|
||||||
|
"description": "**Goal:** `DeployArtifactsCommand` carries an optional ciphertext secret list and `ArtifactDeploymentResponse` an optional applied count, end to end over gRPC, backward compatible.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs`, `ArtifactDeploymentResponse.cs`\n- 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`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandDtoMapper.cs` (`ToProto`/`FromProto` for command and response)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs` (new)\n\n**Acceptance Criteria:**\n- [ ] Command round-trip preserves every `SecretArtifact` field byte-for-byte; `Secrets == null` round-trips as null (field absent), empty list as empty.\n- [ ] Response round-trip: `SecretsApplied` null \u21c4 absent wrapper, `3` \u21c4 `3`.\n- [ ] A `DeployArtifactsCommandDto` serialized WITHOUT field 9 (old central) maps to `Secrets == null`.\n- [ ] No `<Protobuf>` item left active in the csproj.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter FullyQualifiedName~SiteCommandDtoMapperSecretsTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"subject": "Task 3: `SecretReferenceScanner`",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [],
|
||||||
|
"description": "**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.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs`\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs`\n\n**Acceptance Criteria:**\n- [ ] Finds `secret:` string values at any depth (objects and arrays), prefix case-insensitive, name trimmed.\n- [ ] Ignores non-string values, strings merely containing `secret:` mid-text, `${secret:X}` (node-local config form), empty names, and unparseable JSON (no throw).\n- [ ] Output ordinal-sorted by name; each entry lists referencing connections ordinal-sorted and de-duplicated.\n- [ ] Bootstrap names (`SB-GRPC-PSK-` prefix) are excluded.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~SecretReferenceScannerTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"subject": "Task 4: Secrets in the artifact revision hash",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"description": "**Goal:** `ArtifactRevisionHashService` folds the command's secrets (and missing-secret markers) into the hash; sites with no secret refs keep their existing hash.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs` (append `IReadOnlyList<string>? MissingSecrets = null` \u2014 central-only, never mapped to proto; document that on the parameter)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs`\n\n**Acceptance Criteria:**\n- [ ] 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).\n- [ ] Changing a secret's revision, ciphertext, wrapped DEK or KekId changes the hash; reordering secrets does not.\n- [ ] A missing name hashes differently from the same name present.\n- [ ] `HashableRecords_PropertiesDeclaredAlphabetically` covers `HashableSecret` and passes.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactRevisionHashServiceTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"subject": "Task 5: Central shared secret store \u2014 `Secrets:Store:Provide",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [],
|
||||||
|
"description": "**Goal:** A Central node can use the shared SQL-Server secret store without the hub; a clustered Central on `Sqlite` warns.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (`\"Store\": { \"Provider\": \"Sqlite\" }` under `Secrets`, with an `_comment`)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs` (extend), `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs` (extend)\n\n**Acceptance Criteria:**\n- [ ] Central + `Provider=SqlServer` + connection string \u2192 `ISecretStore` resolves to the library's SQL-Server store; no hub endpoint mapped.\n- [ ] Central + `Provider=SqlServer` + blank connection string \u2192 startup failure naming `Secrets:SqlServer:ConnectionString`; a `${secret:` value is rejected (bootstrap rule, same message style as the existing Grpc-mode check).\n- [ ] Site role ignores `Secrets:Store:Provider` (always local SQLite).\n- [ ] Clustered Central (seed-nodes > 1) with `Provider=Sqlite` logs one Warning naming the per-node divergence risk; unknown provider value fails validation.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter \"FullyQualifiedName~SecretsReplicationWiringTests|FullyQualifiedName~StartupValidatorTests\"` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"subject": "Task 6: Central \u2014 collect a site's secrets, fail closed, log",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"description": "**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.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` (`BuildDeployArtifactsCommandAsync`, `DeployCoreAsync`, `RetryForSiteAsync`, `ComputeCurrentArtifactHashesAsync`)\n- 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)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs` (new), `ArtifactDeploymentServiceTests.cs` (extend)\n\n**Acceptance Criteria:**\n- [ ] 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.\n- [ ] A referenced name that is absent or `IsDeleted` \u2192 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.\n- [ ] `ComputeCurrentArtifactHashesAsync` puts missing names in `MissingSecrets` instead of failing, so such a site reads *Out of date*.\n- [ ] 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.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter \"FullyQualifiedName~SiteSecretsCollectorTests|FullyQualifiedName~ArtifactDeploymentServiceTests\"` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"subject": "Task 7: Central \u2014 stamp gating for old sites + audit row",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"description": "**Goal:** Central never stamps a site *Current* unless the site confirmed applying every shipped secret, and every secret deployment writes one audit row.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs`\n- Modify: `SiteArtifactResult` record (same file or its Commons location) \u2014 append `int? SecretsDeployed = null`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DeployCommands.cs` only if its table output enumerates columns (JSON output carries the new field automatically)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs`\n\n**Acceptance Criteria:**\n- [ ] Command with \u2265 1 secret + response `Success=true, SecretsApplied=null` \u2192 site result is a failure `Site build predates secret deployment \u2014 upgrade the site before deploying secrets`; hash NOT stamped.\n- [ ] `SecretsApplied` \u2260 shipped count \u2192 failure `Site applied {n} of {m} secrets`; not stamped.\n- [ ] Command with no secrets \u2192 old-site response (`SecretsApplied=null`) still succeeds and stamps (unchanged behaviour).\n- [ ] 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.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactDeploymentServiceTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"subject": "Task 8: Site storage \u2014 `deployed_secrets` (replicated) + `mi",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"description": "**Goal:** The site database has a replicated ciphertext table for deployed secrets and a node-local ledger of mirrored names, with full-set accessors.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs`\n- 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)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs` (new); existing `SiteLocalDbReplicatedTableFkGuardTests` must still pass\n\n**Acceptance Criteria:**\n- [ ] `UpsertDeployedSecretsAsync(IReadOnlyList<SecretArtifact>, deploymentId)` upserts rows (`is_removed = 0`) and marks every other row `is_removed = 1`; returns `(applied names, newly removed names)`.\n- [ ] `GetDeployedSecretsAsync()` returns all rows incl. removed with their `SecretArtifact` round-tripped from `row_json`.\n- [ ] `mirrored_secrets` get/add/remove work; it is not in `ReplicatedTables`.\n- [ ] A security test asserts `row_json` holds only base64 ciphertext fields (deserializes to `SecretArtifact`, no extra properties).\n- [ ] FK guard test green (no FKs on either table).\n\n**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` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"subject": "Task 9: `DeployedSecretsMirror` \u2014 per-node apply + verify",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"description": "**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.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs` (service + `IHostedService` loop)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs` (+ `DeployedSecretsMirrorInterval`, default 15 s) and `SiteRuntimeOptionsValidator.cs` (> 0)\n- 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>())`)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `ReconcileAsync()` applies each live row with `ISecretStore.ApplyReplicatedAsync` (mapped back to `StoredSecret`), records it in `mirrored_secrets`, and returns a `MirrorResult(Applied, Removed, Failed)`.\n- [ ] 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.\n- [ ] 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 \u2014 the node's master key differs from central's` and is logged at Error.\n- [ ] Logging: Information per applied/removed secret and one summary when anything changed; Debug when in sync; never values.\n- [ ] The hosted loop runs once at start and every `DeployedSecretsMirrorInterval`; a reconcile exception is logged and the loop continues.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"subject": "Task 10: Site apply \u2014 secrets in `HandleDeployArtifacts`",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"description": "**Goal:** The active site node writes shipped secrets to `deployed_secrets`, runs the mirror synchronously, and answers with `SecretsApplied` or a failure.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`HandleDeployArtifacts`, constructor gets `DeployedSecretsMirror?`)\n- Modify: the actor's Props/DI construction site to pass the mirror\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs` (new; follow the existing artifact-apply actor tests)\n\n**Acceptance Criteria:**\n- [ ] `Secrets == null` \u2192 `deployed_secrets` untouched, response `SecretsApplied = null` (old-central compatible).\n- [ ] `Secrets` present (incl. empty) \u2192 full-set write, synchronous `ReconcileAsync`, response `Success=true, SecretsApplied = shipped count` when the mirror reports no failures.\n- [ ] Any mirror failure \u2192 `Success=false`, `ErrorMessage` = the failure lines joined with `; `, `SecretsApplied` = count applied without failure.\n- [ ] Site Information log `Applying {Count} deployed secret(s), deploymentId={DeploymentId}` + the mirror's per-secret lines.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeploymentManagerActorArtifactSecretsTests` \u2192 all pass.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"subject": "Task 11: Retire the gRPC hub for ScadaBridge; switch the rig",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
7,
|
||||||
|
10,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"description": "**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.\n\n**Files:**\n- 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 \u2014 sites receive secrets with Deploy Artifacts; central uses Secrets:Store:Provider=SqlServer`)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (remove `Replication`/`GrpcHub` blocks; keep `SqlServer.ConnectionString`/`SchemaName`; update comments)\n- 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\n- Modify: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs`, `MappedGrpcAuthenticationCoverageTests.cs` (hub route gone)\n- 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 \u2014 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)\n\n**Acceptance Criteria:**\n- [ ] No ScadaBridge code references `AddZbSecretsGrpcHub`, `AddZbSecretsGrpcHubClient` or `MapScadaBridgeSecretsHub`.\n- [ ] `Secrets:Replication:Enabled=true` on either role \u2192 startup failure with the message above.\n- [ ] Wiring tests cover: Central Sqlite, Central SqlServer store, Site local \u2014 all green; hub-route auth coverage test updated.\n- [ ] `docker compose config` renders; central-a/b carry `Secrets__Store__Provider=SqlServer`; no node carries `Secrets__Replication__*` or `Secrets__GrpcHub__*`.\n\n**Verify:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` \u2192 0 errors; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` \u2192 all pass; `docker compose -f docker/docker-compose.yml config | grep -c Secrets__Replication` \u2192 `0`.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"subject": "Task 12: Documentation sync",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
11
|
||||||
|
],
|
||||||
|
"description": "**Goal:** The spec docs, CLAUDE.md, the deferred-work register and the umbrella index describe secrets-via-artifacts and the hub retirement.\n\n**Files:**\n- 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)\n- Modify: `docs/deployment/topology-guide.md` (KEK must match on every central + site node; standby convergence interval)\n- Modify: `CLAUDE.md` (replace the secrets/hub facts with one bullet under *Templates & Deployment*; update the LocalDb bullet \"ten tables\" \u2192 eleven incl. `deployed_secrets`)\n- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (new row: vd03 + QA rollout \u2014 zbsecrets store, `Secrets:Store:Provider=SqlServer` on both centrals, KEK alignment check, recreate hand-seeded site secrets centrally, Deploy Artifacts)\n- 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)\n\n**Acceptance Criteria:**\n- [ ] `grep -rn \"GrpcHub\\|secrets hub\" docs/requirements CLAUDE.md` returns only historical/retired mentions.\n- [ ] Register row lists the four rollout prerequisites and the trigger (\"user asks for the vd03/QA secrets window\").\n- [ ] Umbrella index row stays a few lines.\n\n**Verify:** `grep -rn \"deployed_secrets\" docs/requirements/Component-SiteRuntime.md CLAUDE.md` \u2192 matches in both; `git diff --stat` shows only doc files.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"subject": "Task 13: Full test run, rig rebuild, live gate",
|
||||||
|
"status": "pending",
|
||||||
|
"blockedBy": [
|
||||||
|
12
|
||||||
|
],
|
||||||
|
"description": "**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.\n\n**Files:**\n- Create: `docker/verify-site-secrets.sh` (idempotent gate script, prints PASS/FAIL per check, exits non-zero on any FAIL)\n- Create: `docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md` (results record)\n\n**Acceptance Criteria:**\n- [ ] `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green except the known env-only Playwright residue (list any failure explicitly).\n- [ ] `bash docker/deploy.sh` succeeds; all 8 nodes `/health/ready` 200.\n- [ ] Gate checks (each PASS):\n 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\"` \u2192 Sites page/`site list` shows site-a **Out of date**, site-b/site-c unchanged.\n 2. `deploy artifacts --site-id <site-a>` \u2192 success, `secretsDeployed: 1`; site-a **Current**.\n 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) \u2014 the standby within 30 s.\n 4. Rotate the secret at central \u2192 site-a **Out of date** \u2192 deploy \u2192 both nodes at the new revision.\n 5. Remove the `secret:` reference \u2192 deploy \u2192 both nodes no longer hold the secret; an out-of-band secret seeded on site-a-a beforehand is still present.\n 6. Reference a non-existent `sb-gate-missing` \u2192 deploy site-a fails with the named message; site-a stays **Out of date**; nothing changed on the site.\n 7. Same connection on site-b (different dev KEK) \u2192 deploy fails with the KEK-mismatch message naming the KekId.\n 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 \u2192 0 hits.\n- [ ] Gate script and results record committed; gate objects cleaned up by the script's teardown.\n\n**Verify:** `bash docker/verify-site-secrets.sh` \u2192 `PASS 8/8`.\n\n```json:metadata\n{\"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\"}\n```"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-09-28T20:58:11.605203Z"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user