docs(security): record the stage 6 live gate — PASSED on docker-dev

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
This commit is contained in:
Joseph Doherty
2026-08-01 09:52:42 -04:00
parent 2aa42f90eb
commit c4f537a60c
2 changed files with 57 additions and 8 deletions
@@ -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
+6
View File
@@ -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`.