fix(acl): browse transit reaches the shared root folder (narrow Browse grants were unreachable)
With Security:Acl:Enabled=true, a session holding any grant narrower than
Cluster could not browse the served address space at all — confirmed by the
live gate.
Mechanism. Both realm trees hang under ONE anchor folder minted in
CreateAddressSpace: NodeId("OtOpcUa", <raw ns>). ResolveParentFolder parents
every top-level raw folder AND every top-level UNS area under it, and the
Browse override throws BadUserAccessDenied for the WHOLE call when its source
node is denied. DataPlaneAuthorizer.BuildScope split that identifier like a
path, producing {Kind: Raw, Segments: ["OtOpcUa"]} — but trie grant paths are
["raw", <RawPath segments…>] and "OtOpcUa" is never a path segment, so
PermissionTrie.HasBrowseTransit failed at the realm-child lookup and
CollectMatches yielded only Cluster-scoped grants. Deny, at the front door.
The trie already handled a zero-segment scope as "the realm root, itself an
ancestor of everything granted in that realm" (PermissionTrie.cs:101-118) —
no production path had ever produced that scope. This wires it up.
- V3NodeIds.SharedRootFolderId names the anchor identity; CreateAddressSpace
now mints the NodeId/BrowseName/DisplayName from it (behaviour-identical,
makes the coupling explicit).
- BuildScope maps that identifier (realm-qualified to Raw — in the UNS
namespace s=OtOpcUa is an ordinary Area) onto the zero-segment Raw scope.
- Evaluate adds a second-realm OR-leg for Browse on the anchor only: it fronts
both trees, so a principal whose only Browse grant lives in the UNS tree
still enters through this raw node. Browse only; transit confers navigation,
never read/write/subscribe.
- No trie, evaluator or builder change. No reordering above the decision cache
— key revocation is still re-checked before the lookup — and the cache key
is unchanged (the combined verdict caches under the anchor's own
identifier/realm; a generation advance clears it).
Falsifications actually run (implement-then-revert-the-line, plus one
genuine red-first):
- DataPlaneAuthorizerTests: the 7 new facts were written BEFORE the fix and 4
went red (the 3 deny-only ones passed vacuously, as a deny-only test must).
- Deleting the UNS OR-leg reddens exactly one fact,
The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant (26/27 pass).
- Dropping the realm qualification from IsSharedRootFolder reddens exactly
A_uns_area_named_like_the_anchor_gets_no_anchor_semantics. Its first draft
did NOT catch this — a raw-only grant is refused either way; the
discriminating grant had to be an unrelated UNS one, whose own transit marks
the zero-segment UNS scope the un-qualified mapping would land on.
- Disabling the BuildScope anchor branch reddens the new end-to-end fact
NodeManagerStage6EnforcementTests.The_shared_root_folder_is_browsable_on_a_narrow_grant_and_still_filters
(real booted server, real Browse dispatch: BadUserAccessDenied on the whole
call), which also pins that the reference filter still withholds the
non-granted sibling.
- Core PermissionTrieBrowseTransitTests gains 3 characterization facts pinning
the previously-dead realm-root branch (transit true for the granting realm's
root, false for the other realm's, false without the Browse bit). These were
green on arrival — the trie was always correct; nothing called it.
Tests: Core 351 -> 354 passed; OpcUaServer 482 -> 490 passed. Build clean.
This commit is contained in:
@@ -4219,11 +4219,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
base.CreateAddressSpace(externalReferences);
|
||||
|
||||
// Create one root folder under Objects/ for every variable we mint to hang under.
|
||||
// Its identifier is V3NodeIds.SharedRootFolderId rather than a literal because the ACL gate
|
||||
// keys the trie's realm-root scope off exactly this identity — see
|
||||
// DataPlaneAuthorizer.IsSharedRootFolder. Both realm trees parent here (ResolveParentFolder),
|
||||
// so the anchor is the one node every browse of this server passes through.
|
||||
_root = new FolderState(null)
|
||||
{
|
||||
NodeId = new NodeId("OtOpcUa", NamespaceIndex),
|
||||
BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
|
||||
DisplayName = "OtOpcUa",
|
||||
NodeId = new NodeId(V3NodeIds.SharedRootFolderId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(V3NodeIds.SharedRootFolderId, NamespaceIndex),
|
||||
DisplayName = V3NodeIds.SharedRootFolderId,
|
||||
EventNotifier = EventNotifiers.None,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
};
|
||||
|
||||
@@ -271,6 +271,23 @@ public sealed class DataPlaneAuthorizer
|
||||
var scope = BuildScope(identifier, realm, snapshot.ClusterId);
|
||||
var allowed = Walk(snapshot, identity, operation, scope, now);
|
||||
|
||||
// The single shared anchor fronts BOTH realm trees, so its browse must consider transit in
|
||||
// either: a principal whose only Browse grant lives in the UNS tree still enters through this
|
||||
// node. Browse only — transit confers navigation, never read/write/subscribe, matching the
|
||||
// evaluator's own rule.
|
||||
if (!allowed && operation == OpcUaOperation.Browse && IsSharedRootFolder(identifier, realm))
|
||||
{
|
||||
allowed = Walk(
|
||||
snapshot, identity, operation,
|
||||
new NodeScope
|
||||
{
|
||||
ClusterId = snapshot.ClusterId!,
|
||||
Kind = NodeHierarchyKind.Uns,
|
||||
Segments = [],
|
||||
},
|
||||
now);
|
||||
}
|
||||
|
||||
if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, identifier, allowed);
|
||||
|
||||
return allowed
|
||||
@@ -337,6 +354,23 @@ public sealed class DataPlaneAuthorizer
|
||||
/// string" guard this used to carry moved up into <c>Evaluate</c>, where it has to run before
|
||||
/// the cache lookup anyway — the identifier is the cache key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The one identifier that is not a path is the shared root folder</b>
|
||||
/// (<see cref="V3NodeIds.SharedRootFolderId"/>), which both realm trees hang under. Its
|
||||
/// identifier is its display identity, so splitting it yields a one-segment scope containing a
|
||||
/// name no grant path can ever hold — which denied the anchor for every principal short of a
|
||||
/// cluster grant, and the node manager refuses a whole Browse whose SOURCE is denied. It maps
|
||||
/// instead onto the zero-segment scope: the realm root, which is exactly the question
|
||||
/// <see cref="PermissionTrie.HasBrowseTransit"/> was written to answer ("is this an ancestor of
|
||||
/// anything granted in this realm?") and which no production path had ever asked it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Keying that on the identifier introduces no new ambiguity: a genuine top-level raw folder
|
||||
/// literally named <c>OtOpcUa</c> would already mint the identical NodeId as the anchor in the
|
||||
/// served address space itself (<c>EnsureFolder</c> takes the RawPath as the id), so the
|
||||
/// collision — if an operator ever authors one — predates this mapping and lives in the address
|
||||
/// space rather than in the gate.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="identifier">The node's non-empty <c>s=</c> identifier.</param>
|
||||
/// <param name="realm">The namespace the node was resolved in.</param>
|
||||
@@ -346,9 +380,27 @@ public sealed class DataPlaneAuthorizer
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns,
|
||||
Segments = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
|
||||
Segments = IsSharedRootFolder(identifier, realm)
|
||||
? []
|
||||
: identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node is the single shared root folder both realm trees hang under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Realm-qualified on purpose.</b> The anchor is minted in the RAW namespace; in the UNS
|
||||
/// namespace <c>s=OtOpcUa</c> is an ordinary Area an operator happened to name that way, and giving
|
||||
/// it anchor semantics would collapse a real UNS path onto the realm root — silently widening every
|
||||
/// browse of it.
|
||||
/// </remarks>
|
||||
/// <param name="identifier">The node's <c>s=</c> identifier.</param>
|
||||
/// <param name="realm">The namespace the node was resolved in.</param>
|
||||
/// <returns><see langword="true"/> when the node is the shared anchor folder.</returns>
|
||||
private static bool IsSharedRootFolder(string identifier, AddressSpaceRealm realm) =>
|
||||
realm == AddressSpaceRealm.Raw
|
||||
&& string.Equals(identifier, V3NodeIds.SharedRootFolderId, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Denies with a constant reason — nothing to build, so nothing to defer.</summary>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="nodeId">The target node.</param>
|
||||
|
||||
Reference in New Issue
Block a user