docs(acl): record the shared-root-folder browse-transit mapping

CLAUDE.md's "Browse needs ancestor visibility" bullet and docs/security.md's
ancestor-visibility bullet now name the topmost ancestor: the single shared
root folder (s=OtOpcUa, raw namespace) that both realm trees hang under, its
mapping onto the trie's zero-segment realm-root scope, the either-realm transit
(it fronts both trees), and the realm qualification.

Also corrects the adjacent security.md bullet that claimed "the two namespace
root folders remain visible" — there is one anchor, not one per namespace, and
what an ungranted session sees is the ns=0 Objects listing of it rather than a
browse into it. Flagged rather than silently left, since it contradicts the
mechanism the new bullet documents.
This commit is contained in:
Joseph Doherty
2026-08-18 09:45:09 -04:00
parent 89630ee418
commit 22ae18365b
2 changed files with 21 additions and 4 deletions
+7 -1
View File
@@ -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 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 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 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 - **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 subscription is authorized once and then streams — and streaming is how machine consumers actually
read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied
+14 -3
View File
@@ -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 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 to whoever holds that grant. It is conferred only by grants that actually carry `Browse`, and it
confers nothing but `Browse`. 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 - **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 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. 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 shared root folder remains *listed*** for any admitted session. It is referenced from the ns=0
the ns=0 `Objects` folder, whose browse the SDK's own node manager serves; a session with no grants `Objects` folder, whose browse the SDK's own node manager serves, so a session with no grants sees the
sees the two roots and is refused on entry. 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 #### Cost