89630ee418
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.
121 lines
6.3 KiB
C#
121 lines
6.3 KiB
C#
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
/// <summary>
|
|
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
|
|
/// underlying values under two identity schemes:
|
|
/// <list type="bullet">
|
|
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
|
|
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
|
|
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
|
|
/// on its own RawPath prefix).</item>
|
|
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
|
|
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
|
|
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
|
|
/// </list>
|
|
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
|
|
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
|
|
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
|
|
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
|
|
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
|
|
/// </summary>
|
|
public static class V3NodeIds
|
|
{
|
|
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
|
|
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
|
|
|
|
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
|
|
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
|
|
|
|
/// <summary>
|
|
/// The s= identifier of the single shared root folder both realm trees hang under
|
|
/// (minted in the RAW namespace by the node manager's CreateAddressSpace; top-level
|
|
/// Raw folders AND top-level UNS areas alike parent under it). The ACL gate needs this
|
|
/// identity to map the anchor onto the trie's zero-segment realm-root scope — the
|
|
/// identifier is a display name, never a RawPath/UNS-path segment, so without the
|
|
/// mapping no grant path can ever match it.
|
|
/// </summary>
|
|
public const string SharedRootFolderId = "OtOpcUa";
|
|
|
|
/// <summary>The namespace URI for a realm.</summary>
|
|
/// <param name="realm">The address-space realm.</param>
|
|
/// <returns>The realm's namespace URI.</returns>
|
|
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
|
|
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
|
|
{
|
|
AddressSpaceRealm.Raw => RawNamespaceUri,
|
|
AddressSpaceRealm.Uns => UnsNamespaceUri,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
|
|
};
|
|
|
|
// ----- Raw realm -----
|
|
|
|
/// <summary>
|
|
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
|
|
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
|
|
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
|
|
/// string; the argument is expected to already be a RawPaths-built path.
|
|
/// </summary>
|
|
/// <param name="rawPath">The (already-built) RawPath.</param>
|
|
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
|
|
public static string Raw(string rawPath)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(rawPath);
|
|
return rawPath;
|
|
}
|
|
|
|
// ----- Uns realm -----
|
|
|
|
/// <summary>
|
|
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
|
|
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
|
|
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
|
|
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
|
|
/// </summary>
|
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
|
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
|
|
public static string Uns(params string[] segments) => UnsFromSegments(segments);
|
|
|
|
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
|
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
|
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
|
|
|
|
/// <summary>
|
|
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
|
|
/// EffectiveName under an Equipment) to an already-built UNS path.
|
|
/// </summary>
|
|
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
|
|
/// <param name="childSegment">The child segment to append.</param>
|
|
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
|
|
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
|
|
public static string UnsChild(string parentUnsPath, string childSegment)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
|
|
var error = RawPaths.ValidateSegment(childSegment);
|
|
if (error is not null)
|
|
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
|
|
|
|
return parentUnsPath + RawPaths.Separator + childSegment;
|
|
}
|
|
|
|
private static string UnsFromSegments(IEnumerable<string> segments)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(segments);
|
|
var list = segments as IReadOnlyList<string> ?? segments.ToList();
|
|
if (list.Count == 0)
|
|
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
|
|
|
|
for (var i = 0; i < list.Count; i++)
|
|
{
|
|
var error = RawPaths.ValidateSegment(list[i]);
|
|
if (error is not null)
|
|
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
|
|
}
|
|
|
|
return string.Join(RawPaths.Separator, list);
|
|
}
|
|
}
|