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`.