diff --git a/CLAUDE.md b/CLAUDE.md index cb59b809..ff22131d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -801,7 +801,13 @@ Two things to know before relying on any of this: grant deep in the tree is reached by browsing the nodes above it, and the trie inherits downward only, so `PermissionTrieNode.BrowseTransitPrincipals` marks the path. Planting a real `Browse` grant on each ancestor instead would be collected by the additive downward walk and leak `Browse` onto - every sibling subtree beneath that ancestor. + every sibling subtree beneath that ancestor. ⚠️ **The topmost ancestor is the single shared root + folder** (`s=OtOpcUa`, raw namespace) that BOTH realm trees hang under — its identifier is a display + name, not a path, so `DataPlaneAuthorizer.BuildScope` maps it onto the trie's **zero-segment + realm-root scope** (`V3NodeIds.SharedRootFolderId`, realm-qualified: in the UNS namespace that + identifier is an ordinary Area), and its Browse considers transit in **either** realm because it + fronts both trees. Before this every narrow Browse grant was unreachable by navigation — the Browse + gate denies the whole call when its source node is denied, so clients were stopped at the front door. - **Revocation reaches live subscriptions** (stage 6): every other operation re-asks per request, but a subscription is authorized once and then streams — and streaming is how machine consumers actually read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied diff --git a/docs/security.md b/docs/security.md index 1eb6bf42..6872fbaf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -427,12 +427,23 @@ Behaviours worth knowing before enabling it: 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`. +- **…including the shared root folder every browse starts at.** Both realm trees hang under a single + anchor folder (`s=OtOpcUa`, minted in the **raw** namespace by `CreateAddressSpace`; top-level raw + folders and top-level UNS areas alike parent under it). Its identifier is a display name rather than + a path, so splitting it produced a segment no grant path can contain, and the Browse gate refuses the + *whole* call when its source node is denied — every narrow `Browse` grant was therefore unreachable + by navigation, denied at the front door. The gate maps that one identifier onto the trie's + zero-segment **realm-root** scope, and because the anchor fronts both trees its browse considers + transit in **either** realm: a principal whose only grant lives in the UNS tree still enters through + this raw-namespace node. The mapping is realm-qualified — in the UNS namespace `s=OtOpcUa` is an + ordinary Area an operator happened to name that way and is evaluated as the path it is. - **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. +- **The shared root folder remains *listed*** for any admitted session. It is referenced from the ns=0 + `Objects` folder, whose browse the SDK's own node manager serves, so a session with no grants sees the + anchor in that listing — and is refused the moment it tries to browse *into* it (the transit above is + what a granted session enters on). There is one anchor, not one per namespace. #### Cost