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:
+42
-13
@@ -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"):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user