From c4f537a60c0c350333eb8fd5804de3fdb15eb1bc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 09:52:42 -0400 Subject: [PATCH] =?UTF-8?q?docs(security):=20record=20the=20stage=206=20li?= =?UTF-8?q?ve=20gate=20=E2=80=94=20PASSED=20on=20docker-dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit site-a-1 (driver-only, no ConfigDb) with Security:Acl:Enabled=true; central-1 on the same artifact as the A/B control. 11 legs. The two that only a live rig could settle, as a transcript: 13:49:39 SiteAOnly = 53 (0x00000000) streaming ~1/s 13:49:40 SiteAOnly = (0x801F0000) revoking deploy lands; client TOLD … 25 seconds of silence, ~25 updates that never arrived … 13:50:05 SiteAOnly = 157 (0x00000000) restoring deploy; feed resumes 53 -> 157 across the gap: the device counter ran throughout, so delivery stopped, not the source. The 0x801F0000 frame is exactly what the MonitoringMode.Disabled implementation would have swallowed. Also recorded: the leg that distinguishes working enforcement from a trie that denies everything is resolvedScopes=1 in the install log, not any denial. Stated plainly as NOT covered: a UNS-realm grant (the rig's SITE-A has no UNS equipment) and alarm-command scoping (no alarms there). Both covered offline, neither seen live. Two rig facts worth keeping: :9200 round-robins central-1/central-2, and a stale central-2 rejected the deploy outright because its image predated the stage-3 scope kinds — image parity must be asserted per container, not assumed from one build. And SITEA-modbus had an empty DriverConfig, so it ran as a stub serving nothing; until that was fixed every read returned BadWaitingForInitialData and no allow leg could be told from a denial. Rig left as found. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- ...026-07-31-apikey-acl-enforcement-design.md | 59 ++++++++++++++++--- docs/security.md | 6 ++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md index 37de7222..20a9742d 100644 --- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -296,16 +296,59 @@ checked against something other than itself. Rig left clean (0 keys, 0 ACL rows, redeployed, all six nodes healthy). -## Live gate (stage 8) +## Live gate — PASSED (docker-dev, 2026-08-01) -On docker-dev, with `Security:Acl:Enabled=true`: +Subject: **site-a-1** (`driver,cluster-SITE-A`, no ConfigDb, `ConfigSource:Mode=FetchAndCache`) with +`Security:Acl:Enabled=true`. Control: **central-1**, same artifact, flag unset. All six nodes were +rebuilt onto the gate commit first and image parity asserted per container, because `:9200` +round-robins central-1/central-2 and a stale central-2 rejected the deploy outright (`Cannot convert +string value 'RawDevice' … enum` — its image predated the stage-3 scope kinds). -1. A key granted `ReadOnly` on one equipment reads its tags and is denied a tag outside the grant. -2. The same key is denied a write; a key granted `Operator` writes successfully. -3. Anonymous with the setting off cannot activate a session; with it on and a `ReadOnly` grant, it - reads and is denied writes. -4. Revoking a key denies the next request **after a deploy** — see the caveat below. -5. A raw-realm grant (`NodeHierarchyKind.Raw`) and a UNS grant both evaluate, proving blocker #1. +The key was minted with an **independent Python HMAC**, so the verifier was checked against something +other than itself. One grant throughout: `RawDevice` → `SITEA-dev`, i.e. `raw/sa-modbus2/plc`. + +| # | Leg | Result | +|---|---|---| +| 0 | The grant is *placed*, not dropped | `generation 2 installed (keys=1, grants=1, resolvedScopes=1)`, no dropped-rule warning. This is the leg that distinguishes working enforcement from a trie that silently denies everything | +| 1 | Read INSIDE the grant | `Value: 16100, Status: 0x00000000` | +| 2 | Read OUTSIDE it (sibling device `plc2`) | `BadUserAccessDenied` | +| 3 | Browse the driver, gated | `plc` only — `plc2` filtered out | +| 4 | Browse the same driver on the **control** | `plc` **and** `plc2` — the A/B that shows the filtering is the ACL and not the address space | +| 5 | Browse the ungranted device directly | `BadUserAccessDenied` (the throw path) | +| 6 | Anonymous on the gated node | `BadUserAccessDenied` — principal `anonymous` holds no grant | +| 7 | Write with a grant lacking `WriteOperate` | `0x801F0000` | +| 8 | Same write after granting `WriteOperate` (+redeploy) | `Write successful` | +| 9 | Wrong secret | `BadIdentityTokenRejected` at activation | +| 10 | **Revoke mid-subscription** | see below | +| 11 | **Restore the grant** | see below | + +Legs 10–11 are the ones worth reading as a transcript, because they are what stage 6 added and what +no offline test could settle: + +``` +13:49:39 SiteAOnly = 53 (0x00000000) ← streaming, ~1/s +13:49:40 SiteAOnly = (0x801F0000) ← the revoking deployment lands; the client is TOLD + … 25 seconds of complete silence — ~25 updates that did not arrive … +13:50:05 SiteAOnly = 157 (0x00000000) ← the restoring deployment lands; the feed resumes +``` + +Note `53 → 157`: the device counter ran throughout, so it was **delivery** that stopped, not the +source. The `0x801F0000` frame is the half that the `MonitoringMode.Disabled` implementation would +have swallowed. + +**Not proven by this gate:** a UNS-realm grant (the rig's SITE-A cluster has no UNS equipment), and +alarm-command scoping (no alarms configured on SITE-A). Both are covered offline; neither has been +seen live. Reads on the *control* node returned `BadNodeIdUnknown` rather than a value — central does +not materialise another cluster's tag variables — so leg 4's browse is the meaningful A/B, not a read. + +**Found en route, unrelated to ACLs:** the rig's `SITEA-modbus` driver had an empty `DriverConfig`, so +its factory rejected the config and the driver ran as a stub serving nothing. Fixed on the rig +(`Host`/`Port` added) — without it every read returns `BadWaitingForInitialData` and the allow legs +cannot be told from a denial. + +Rig left as found: key, ACL row, the second device and its tag all deleted and redeployed; site-a +returned to `Security:Acl:Enabled` unset, confirmed by the absence of the startup banner and an +anonymous read succeeding again. ## Known consequence to design around diff --git a/docs/security.md b/docs/security.md index c7038c70..d5920caa 100644 --- a/docs/security.md +++ b/docs/security.md @@ -320,6 +320,12 @@ takes no realm and no node. |---|---|---| | `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide **every** data-plane operation — read, browse, subscribe, write, alarm commands and HistoryRead | +**Live-gated on docker-dev 2026-08-01** (site-a-1 with the flag on, central-1 as the same-artifact +control): scoped read/browse/write behave as described below, and revoking a grant stopped a running +subscription mid-stream — the client received `BadUserAccessDenied`, then 25 seconds of silence, then +the feed resumed by itself when the grant was restored. Evidence + what the gate did *not* cover (UNS +grants, alarm scoping) in `docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. + **It is a config change, not a redeploy.** Both paths are wired in both modes — the node always builds its permission trie on every deploy and always constructs the gate; the flag only chooses which decision the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource` and `Telemetry`.