feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)

Completes data-plane enforcement behind Security:Acl:Enabled. Read (every
attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems,
SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants.
With the flag off nothing changes.

Four things went beyond the stage as written, each because leaving it out
would have made the feature look like it worked:

- Browse needed ancestor visibility. The trie inherits downward, and a client
  reaches a deep node by browsing the nodes above it, so without this every
  grant narrower than the cluster was authored, shipped and unreachable. It is
  a separate non-inheriting marker, NOT a synthesized Browse grant on each
  ancestor: the additive downward walk would carry such a grant from Plant onto
  Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that
  goes through the evaluator — probing the trie helper directly passes against
  the broken design, because a sibling is absent from the trie either way.

- Revocation had to reach LIVE subscriptions. Every other operation re-asks per
  request; a subscription is authorized once and then streams, and streaming is
  how machine consumers actually read this server — so stopping only the
  re-asking operations would have made revocation close to cosmetic.
  IDataPlaneAuthoritySource gained a Changed event.

  A denied item is queued BadUserAccessDenied and then DETACHED from its
  MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards
  the item's queued notifications, so the client is silenced without ever being
  told why. The over-the-wire test caught this; the unit-level path reports the
  item as stood down either way. Detaching is also reversible, so a restored
  grant resumes the feed with no client action.

- The decision cache holds the grant WALK, not the decision. Grants and the
  trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so
  caching the whole decision would let an expired key keep working until the
  next deploy — the stage-5 open-session hole in a subtler form.

- TranslateBrowsePath is gated, without which browse filtering is decorative: a
  client refused a browse asks TranslateBrowsePathsToNodeIds for the same path.

Found en route: UserAuthorizationState's staleness ceiling couples two clocks.
MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while
IsStale is evaluated against TriePermissionEvaluator's; they are the same
instance in production and diverge the moment anything injects a test clock, at
which point the evaluator denies EVERYTHING with a verdict indistinguishable
from "no grant matched". Stage 5's claim that these predicates cannot fire was
accidentally true; they are now disabled explicitly.

Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a
NodeState. The declared type is object, so the wrong cast compiled cleanly and
silently yielded null — skipping the browse source gate entirely.

Falsified: disabling the transit marking reddens exactly the three tests that
assert transit works; implementing transit as an ancestor grant reddens the
sibling-leak test; skipping the fan-out detach reddens the revocation test.
Full solution suite green.

The stage 8 live gate has NOT run yet.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 09:22:12 -04:00
parent 1ccc74005a
commit 00aa486677
16 changed files with 2157 additions and 70 deletions
+42 -13
View File
@@ -265,13 +265,13 @@ the one request making it. One key remains:
## Data-Plane Authorization
> ⚠️ **Read this first (updated 2026-08-01, #520 stage 5).** Whether the `NodeAcl` / `PermissionTrie`
> ⚠️ **Read this first (updated 2026-08-01, #520 stages 5–6).** Whether the `NodeAcl` / `PermissionTrie`
> subsystem runs depends on one flag. With **`Security:Acl:Enabled` false — the default** — it does not:
> the only authorization in the server is the coarse role checks in
> [What is enforced with the flag off](#what-is-enforced-with-the-flag-off). With it **true**, those role
> checks are *replaced* by scoped ACL decisions for write, alarm commands and HistoryRead — see
> [Node ACL enforcement](#node-acl-enforcement-securityaclenabled). **Read, Browse and
> CreateMonitoredItems are ungated in both modes.**
> [What is enforced with the flag off](#what-is-enforced-with-the-flag-off), and read, browse, subscribe
> and HistoryRead are ungated for any admitted session including Anonymous. With it **true**, those role
> checks are *replaced* by scoped ACL decisions and **every** data-plane operation is gated — see
> [Node ACL enforcement](#node-acl-enforcement-securityaclenabled).
### What is enforced with the flag off
@@ -318,7 +318,7 @@ takes no realm and no node.
| Key | Default | Effect |
|---|---|---|
| `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide write, alarm-command and HistoryRead authorization |
| `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide **every** data-plane operation — read, browse, subscribe, write, alarm commands and HistoryRead |
**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
@@ -333,14 +333,16 @@ the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource
| Alarm Confirm | role `AlarmAck` | `AlarmConfirm` |
| Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` |
| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** |
| Read, Browse, CreateMonitoredItems | nothing | **still nothing** — stage 6 |
| Read (every attribute, not just Value) | nothing | `Read` on that node's scope, decided per node in the batch |
| Browse / TranslateBrowsePath | nothing | `Browse` on the source node, **and** on each returned reference |
| CreateMonitoredItems / SubscribeToEvents | nothing | `Subscribe` on that node's scope, decided per item |
⚠️ **The ACL decision REPLACES the role check; the two are not chained.** Chaining them would require a
grant to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped grants
confer a role (`ApiKeyInterimRoleMapper`) — so every narrow grant would remain inert and the flag would
look like it did nothing. Narrow grants working is the whole point of the stage.
Two behaviours worth knowing before enabling it:
Behaviours worth knowing before enabling it:
- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the
current authority generation rather than the one the session activated under, so an ACL edit lands
@@ -349,6 +351,35 @@ Two behaviours worth knowing before enabling it:
the current snapshot, so a key disabled or expired by a later deployment stops working immediately
rather than at the client's next reconnect. On a driver-only node the artifact is the only revocation
channel there is, so without this a revoked credential could keep its authority indefinitely.
- **Revocation reaches a live subscription too** (#520 stage 6). Every other operation re-asks on each
request; a subscription is authorized once and then streams, so it needed a push. A deployment
re-authorizes every live monitored item: one whose grant has gone is queued a `BadUserAccessDenied`
and then detached from its node's value fan-out, and a later deployment that restores the grant
re-attaches it. This matters more than it sounds — subscriptions are how machine consumers actually
read this server, so without it revoking a grant would close the operations nobody uses and leave the
feed running.
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
to whoever holds that grant. It is conferred only by grants that actually carry `Browse`, and it
confers nothing but `Browse`.
- **A denied node reports `BadUserAccessDenied` rather than `BadNodeIdUnknown`**, because the gate runs
before node resolution. That is the information-hiding direction — error codes can no longer be used
to enumerate the address space — but it does mean "access denied" is not evidence the node exists.
- **The two namespace root folders remain visible** to any admitted session. They are referenced from
the ns=0 `Objects` folder, whose browse the SDK's own node manager serves; a session with no grants
sees the two roots and is refused on entry.
#### Cost
Read, Browse and subscription creation ask the same question many times per request — one Browse of a
device folder asks once per child. Those seams go through a per-`(session, node, operation, realm)`
decision cache, flushed wholesale whenever the authority generation advances.
⚠️ **The cache holds the grant walk, not the decision.** Grants, principals and the trie are all fixed
for the life of a generation, so memoizing the walk is sound. A key's `ExpiresUtc` passes with the
*clock*, not with a deployment — so revocation and expiry are re-checked on every call, outside the
cache. Caching them would have let an expired key keep working until the next deploy.
#### Everything denies when something is missing
@@ -380,11 +411,9 @@ What is real about it:
generation as the snapshot. Deliberately one call rather than two wirings: an uninstalled trie denies
everything, which is indistinguishable from working enforcement until someone checks that an ALLOW is
possible at all.
- `DataPlaneAuthorizer` **evaluates** them at the three seams above, when the flag is on.
So an operator can still author a grant, deploy it green, and have it change nothing at the node — but
the grant now reaches the node rather than being dropped at its door. That is the behaviour to expect
until the tracking issue below is closed.
- `DataPlaneAuthorizer` **evaluates** them at every data-plane seam (#520 stages 5–6), when the flag is
on. With the flag off an operator can still author a grant, deploy it green, and have it change nothing
beyond the two coarse role gates — that is the documented default, not an unfinished state.
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):