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:
@@ -235,7 +235,42 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
|
||||
trie's matching key was being dropped at the identity boundary, and the audit `ClientUserId` on a
|
||||
rejected write recorded `apikey` for every consuming system. `Principal` and `ApiKeyId` are now
|
||||
carried explicitly.
|
||||
6. **Enforce read/browse/subscribe** — the new seam, with per-session decision caching.
|
||||
6. **Enforce read/browse/subscribe.** ✅ **Done.** `Read`, `Browse`, `TranslateBrowsePath`,
|
||||
`CreateMonitoredItems`, `SubscribeToEvents` and `SetMonitoringMode` are gated at the node manager;
|
||||
`SessionAclDecisionCache` memoizes the grant walk per `(session, node, operation, realm)` and flushes
|
||||
on a generation change.
|
||||
|
||||
Four things went differently from this line, all deliberate:
|
||||
|
||||
- **Browse needed a new trie concept: 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, stored, shipped and *unreachable*. ⚠️ The obvious implementation, planting a
|
||||
synthetic `Browse` grant on each ancestor, is a silent widening: the additive downward walk carries
|
||||
it from `Plant` onto `Plant/Siemens/dev9` and every other sibling subtree. It is a separate,
|
||||
non-inheriting marker (`BrowseTransitPrincipals`) checked at the exact node only, set only by grants
|
||||
that actually carry `Browse`, and conferring only `Browse`. The consequence to accept: the *names*
|
||||
of the folders on the path to a granted node are visible to whoever holds that grant.
|
||||
- **The 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. Caching the whole decision would have let an
|
||||
expired key keep working until the next deploy — the stage-5 open-session hole in a subtler form.
|
||||
- **Revocation had to reach LIVE subscriptions, which needed a push.** Every other operation re-asks
|
||||
per request; a subscription is authorized once and streams. Since subscriptions are how machine
|
||||
consumers actually read this server, stopping only the re-asking operations would have made
|
||||
revocation close to cosmetic. `IDataPlaneAuthoritySource` gained a `Changed` event and the node
|
||||
manager re-authorizes every live item on it. ⚠️ A denied item is queued `BadUserAccessDenied` and
|
||||
then **detached from its node's fan-out, not set `MonitoringMode.Disabled`** — disabling *discards
|
||||
queued notifications*, silencing the client without telling it why. Caught by the over-the-wire test
|
||||
rather than by reasoning; 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.
|
||||
- **`TranslateBrowsePath` is gated too**, without which browse filtering is decorative: a client
|
||||
refused a browse would ask `TranslateBrowsePathsToNodeIds` for the same path and get the NodeId.
|
||||
|
||||
Also found and fixed 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 "can never
|
||||
fire" was accidentally true, not true by construction; they are now disabled explicitly.
|
||||
7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and
|
||||
**remove the two "not enforced" warning banners** — which is the honest signal that this is done.
|
||||
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev
|
||||
|
||||
Reference in New Issue
Block a user