chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 — fail-closed store-path validation
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 15m19s

Four-minor jump 0.2.3 → 0.6.2. The release line exists because a deploy
renaming the app directory took the MxGateway credential store with it
(2026-08-09, two silent days): Secrets:SqlitePath must now be absolute AND
outside the content root, rejected at boot rather than relocated. 0.6.2 over
0.6.1 is message-only (the rejection text now teaches the mounted-volume
case and forecloses the override hunt) — the live rig evidence below was
gathered at 0.6.1 and the validator behavior is identical.

Adoption changes beyond the pins:

- appsettings.json drops the relative "otopcua-secrets.db" (now boot-fatal);
  dev gets the library's absolute per-user default, deployments supply their
  own (wonder already pins an absolute out-of-root path in the service env).
- Program.cs's pre-host ${secret:} expander now calls the FOUR-argument
  AddZbSecrets with builder.Environment.ContentRootPath. This call site is
  the actual fix, not the pin: the throwaway container has no
  IHostEnvironment, and without the explicit root the under-content-root
  rule is skipped at exactly the moment the migrator creates the store file
  (the 0.6.0 defect). Do NOT simplify back to the 3-argument overload — it
  compiles and silently reopens the gap. Pinned by a Program.cs source-scan
  test AND a runtime falsification control that RUNS the migration and
  demonstrably creates the store at the rejected path under the 3-arg form.
- docker-dev: Secrets__SqlitePath moves /app/data → /data via a SECOND mount
  of the same per-node volume (same file at the volume root, so seeded
  stores carry over) because /app is the container's content root and the
  new rule correctly rejects it. LocalDb stays on /app/data (secrets-only
  rule).
- SecretsStorePathValidationTests: positive-control probes, artifact-first
  (relative and in-content-root paths must FAIL leaving no file; outside-
  root passes; the no-explicit-root control proves the file assertions can
  fail) — a clean boot is equally consistent with the validator never being
  wired.

Live-verified on the six-node docker-dev rig (at 0.6.1): all nodes boot
under the new validation with stores at /data; an AdminUI-written secret
converged into BOTH central nodes' independent stores; site-a held only its
own pair's secrets (Phase 6 pair-local isolation intact). Replicator wire
behavior unchanged 0.3.0–0.6.x per CHANGELOG; registration-graph pins and
the full suite pass.
This commit is contained in:
Joseph Doherty
2026-08-11 08:52:21 -04:00
parent 8ab520ef57
commit b037cc7ac4
6 changed files with 233 additions and 17 deletions
+23 -11
View File
@@ -118,16 +118,22 @@ name: otopcua-dev
# observable quickly during dev exercise. Each node keeps its OWN local SQLite store;
# replication syncs them.
#
# SqlitePath is overridden onto the node's existing per-node `/app/data` volume (the one
# LocalDb already uses) instead of the appsettings default `otopcua-secrets.db`, which
# resolves to /app — inside the image's writable container layer. Two things that costs,
# both of which bit the 2026-08-07 pair-local gate: the store is DESTROYED by any
# `up -d --build` container recreate (so a seeded row cannot outlive a rig rebuild), and
# it is unreachable from the host, so seeding or inspecting a node's store meant stopping
# the container and `docker cp`-ing the file out. On the volume, each node still has a
# private store (one volume per node — this is NOT shared storage, and replication is
# still what syncs them), but it survives recreate and a helper container can mount it
# read/write while the node keeps running.
# SqlitePath is overridden onto the node's existing per-node volume instead of the
# in-container default. Two things a container-layer path costs, both of which bit the
# 2026-08-07 pair-local gate: the store is DESTROYED by any `up -d --build` container
# recreate (so a seeded row cannot outlive a rig rebuild), and it is unreachable from the
# host, so seeding or inspecting a node's store meant stopping the container and
# `docker cp`-ing the file out. On the volume, each node still has a private store (one
# volume per node — this is NOT shared storage, and replication is still what syncs
# them), but it survives recreate and a helper container can mount it read/write while
# the node keeps running.
#
# The path is /data (a SECOND mount of the same per-node volume), not the volume's
# /app/data mount: Secrets 0.6.0 fail-closed validation rejects a store inside the
# application content root (/app) — the rule that exists because a deploy renaming the
# app directory took the MxGateway credential store with it (2026-08-09). Same volume,
# same file at the volume root, different mount point — existing seeded stores carry
# over untouched. LocalDb deliberately stays on /app/data (the rule is secrets-only).
#
# Per-cluster mesh Phase 6: this DPS topic now rides each pair's OWN 2-node mesh, not one
# shared six-node mesh — so secret replication is PAIR-LOCAL. central-1/central-2 replicate
@@ -138,7 +144,7 @@ name: otopcua-dev
# was needed for this — Enabled=true + the same KEK on every node is still correct; it just now
# converges within three independent 2-node groups instead of one six-node group.
x-secrets-env: &secrets-env
Secrets__SqlitePath: "/app/data/otopcua-secrets.db"
Secrets__SqlitePath: "/data/otopcua-secrets.db"
Secrets__Replication__Enabled: "true"
Secrets__Replication__AnnounceInterval: "00:00:05"
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
@@ -429,6 +435,7 @@ services:
- "4840:4840"
volumes:
- otopcua-localdb-central-1:/app/data
- otopcua-localdb-central-1:/data
central-2:
<<: *otopcua-host
@@ -538,6 +545,7 @@ services:
- "4841:4840"
volumes:
- otopcua-localdb-central-2:/app/data
- otopcua-localdb-central-2:/data
# ── Site A cluster (2-node driver-only) ─────────────────────────────────────
# Per-cluster mesh Phase 6: site-a-1 and site-a-2 form their OWN 2-node "SITE-A" Akka mesh,
@@ -659,6 +667,7 @@ services:
- "4842:4840"
volumes:
- otopcua-localdb-site-a-1:/app/data
- otopcua-localdb-site-a-1:/data
site-a-2:
<<: *otopcua-host
@@ -753,6 +762,7 @@ services:
- "4843:4840"
volumes:
- otopcua-localdb-site-a-2:/app/data
- otopcua-localdb-site-a-2:/data
# ── Site B cluster (2-node driver-only) ─────────────────────────────────────
# Per-cluster mesh Phase 6: site-b-1 and site-b-2 form their OWN 2-node "SITE-B" Akka mesh,
@@ -824,6 +834,7 @@ services:
- "4844:4840"
volumes:
- otopcua-localdb-site-b-1:/app/data
- otopcua-localdb-site-b-1:/data
site-b-2:
<<: *otopcua-host
@@ -891,6 +902,7 @@ services:
- "4845:4840"
volumes:
- otopcua-localdb-site-b-2:/app/data
- otopcua-localdb-site-b-2:/data
traefik:
image: traefik:v3.1