diff --git a/docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md b/docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md index 20bb84fe..4870a360 100644 --- a/docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md +++ b/docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md @@ -22,7 +22,7 @@ posture). | Phase | Scope | Branch | Status | PR / merge SHA | Live gate | |---|---|---|---|---|---| -| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **IN PROGRESS** — Batch A (0.1 + 0.3) done @ `aa21f61f` + close-out `09db9e47`; 0.2 (mapper + 27 wire-level goldens) done @ `545413a8`; all reviews spec ✅ + code ✅, 0 critical/important; Batch B (0.4 + 0.5) next | — | PENDING | +| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **IN PROGRESS** — Batch A (0.1 + 0.3) @ `aa21f61f`; 0.2 (mapper + 27 wire-level goldens) @ `545413a8`; Batch B (0.4 + 0.5) @ `fbda015c`/`68a416e4` + review fixes `24e55d33`/`a4631410`; all reviews spec ✅ + code ✅ (Batch B code review on Opus, fix loop closed); only 0.6 remains | — | PENDING | | 1A | Central→node command plane (`NodeCommandService` on the Telemetry listener; `MeshGrpcCommandDialer`; third mode branch in `CentralCommunicationActor`) — **worktree A** | `feat/grpc-node-command` | NOT STARTED | — | PENDING | | 1B | Node→central ack plane (`CentralCommandService` on the ConfigServe listener; `GrpcApplyAckSender`; third branch in `NodeCommunicationActor`) — **worktree B**, rebases on 1A | `feat/grpc-central-ack` | NOT STARTED | — | PENDING | | 2 | Baseline reproduction on pre-flip build, then full-rig cutover (`OTOPCUA_MESH_MODE` default → `Grpc`) + soak + kill drills | (rig commit) | NOT STARTED | — | PENDING | @@ -48,6 +48,39 @@ phase DoD — PR + merge are user-owned.** | 2026-08-06 | `AlarmCommandMessage.operation` is a proto `string`, NOT an enum — the CLR `AlarmCommand.Operation` is an unconstrained string, and an enum would silently decode unknown operations to `UNSPECIFIED` (the drop class this migration removes). Plan T0.1's "AlarmCommand verb" enum wording was a plan defect; only `ApplyOutcome` carries the enum-0 rule. T0.2's goldens must pin pass-through of an unrecognized operation string | proto comment + this log (T0.1 review) | | 2026-08-06 | `correlation_id` lives on the envelope AND as inner fields on the three commands whose CLR record carries one — each command round-trips independently in the T0.2 mapper; the envelope covers `AlarmCommand` (no CLR correlation member). For the three, the inner field is authoritative on decode | proto comment + this log (T0.1 review) | +## Phase 1A security register (from the Batch B Opus code review — HARD GATE: all four close BEFORE `CentralCommandService` is mapped) + +1. **Known-cluster-set membership gate in `TryGetPsk`** — central rebuilds its cluster set from + `ClusterNode` rows every `ContactRefreshSeconds`; refuse a cluster id outside that set in + O(1) with no store touch. Closes BOTH open review findings at once: (a) the timing oracle + that currently defeats the uniform-denial anti-enumeration text (unknown cluster pays a + store read on every probe because negatives are deliberately never cached; known cluster is + cached — a stable remotely-measurable distinguisher), and (b) the pre-auth resource + exhaustion / audit-flood vector (each distinct forged `x-otopcua-cluster` header costs a + secret-store read + a `secret.resolve.not-found` audit row into `ConfigAuditLog` via the + audit singleton, attacker-chosen `Target` values, before any credential check). Namespace + confinement (`OT-MESH-PSK-` prefix + `SecretName` allow-list) means no disclosure risk — + this is flood/oracle only. +2. **`bool Matches(string clusterId, string presented)` on `IMeshPskProvider`** — keep + `FixedTimeEquals` inside the provider so a future verify-side caller cannot write `==` and + have it compile. Do before 1B adds the dial-side caller. +3. **DI-shape pin test** — `IMeshPskProvider` is `hasAdmin`-only and its pairing with the + central interceptor is by convention; a driver-only node injecting it throws on first call, + not at boot. Add a Host.IntegrationTests check (admin host resolves it; driver-only host + does NOT) when Phase 1 maps the services. +4. **Integration gate over real Kestrel h2c** — both new interceptors have unit coverage only; + the one-public-ctor reflection test approximates but cannot prove the interceptor is + actually invoked. Wire-probe in the 1A/1B live gates (already in the live-gate tables). + +Non-gating notes from the same review: central-side unseeded-PSK is invisible until a deploy +stalls (consider a "PSK seeded for every known cluster?" `/hosts`/health surface — the +#521/#541 shape); `SecretName` lowercases + bans `_`, so a cluster id like `Site_A` can never +resolve from the store and must use `ClusterPsks` — MUST be documented in T0.6's +`docs/Configuration.md` stub (rig ids `MAIN`/`SITE-A`/`SITE-B` are fine); sibling interceptors +still use `IsNullOrEmpty` (whitespace key opens them) — family-wide backport is out of this +program's scope, file alongside the FleetStatus issue in T0.6; consider a non-throwing counter +metric if the poison-guard silent catch ever fires in production. + ## Phase 1 dispatch notes (from Task 0.2 reviews — fold into the 1A/1B implementer prompts) - The alarm arm of `FromEnvelope` necessarily discards the envelope correlation @@ -84,6 +117,7 @@ Checks a gate could not drive, named + carried to the next gate (proportionate-g | Issue | What | Status | |---|---|---| | *(Gitea # TBD in Phase 0, T0.6)* | `FleetStatusBroadcaster` derives "fleet" from central's own gossip ring — silently central-pair-blind since mesh Phase 6; pre-existing, transport-independent, out of this program's scope | to file | +| *(Gitea # TBD in Phase 0, T0.6)* | The three pre-existing auth interceptors (`TelemetryStream`/`ConfigServe`/`LocalDbSync`) treat a whitespace-only key as SET (`IsNullOrEmpty`), so a whitespace `Telemetry:ApiKey` etc. opens the endpoint to a whitespace bearer; the two new mesh interceptors use `IsNullOrWhiteSpace`. Backport is a behaviour change to shipped endpoints — own issue, out of this program's scope | to file | ## Known constraints carried from the research (do not rediscover) diff --git a/docs/plans/2026-08-06-clusterclient-to-grpc.md.tasks.json b/docs/plans/2026-08-06-clusterclient-to-grpc.md.tasks.json index ff6b445b..e74930d9 100644 --- a/docs/plans/2026-08-06-clusterclient-to-grpc.md.tasks.json +++ b/docs/plans/2026-08-06-clusterclient-to-grpc.md.tasks.json @@ -6,8 +6,8 @@ {"id": 0, "subject": "Task 0.1: mesh_transport.proto + contract lock", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.3 (batch, same agent)"], "status": "completed", "commit": "c0ccfa27", "review": "spec ✅ + code ✅"}, {"id": 1, "subject": "Task 0.2: DTO mapper + round-trip goldens", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": [], "status": "completed", "blockedBy": [0], "commit": "545413a8", "review": "spec ✅ + code ✅ (serial)"}, {"id": 2, "subject": "Task 0.3: MeshTransportOptions + validators + SplitTopology relaxation", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.1 (batch, same agent)"], "status": "completed", "commit": "aa21f61f", "review": "spec ✅ + code ✅"}, - {"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "pending", "blockedBy": [2]}, - {"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "pending", "blockedBy": [2]}, + {"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "fbda015c + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed)"}, + {"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "68a416e4 + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed); 4 items gated on Phase 1A — see tracking §Phase 1A security register"}, {"id": 5, "subject": "Task 0.6: rig config + FleetStatus issue + docs stub + Phase 0 gate", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "small", "parallelizableWith": [], "status": "pending", "blockedBy": [0, 1, 2, 3, 4]}, {"id": 6, "subject": "Task 1A.1: NodeCommandGrpcService on the telemetry listener", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "standard", "parallelizableWith": ["1B.1 (other worktree)"], "status": "pending", "blockedBy": [5]}, {"id": 7, "subject": "Task 1A.2: MeshGrpcCommandDialer + third branch in CentralCommunicationActor", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "high-risk", "parallelizableWith": ["1B.2 (other worktree)"], "status": "pending", "blockedBy": [6]},