Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs
Joseph Doherty 1bf3938cdf ADR-001 Task B — NodeScopeResolver full-path + ScopePathIndexBuilder + evaluator-level ACL test closing #195. Two production additions + one end-to-end authz regression test proving the Identification ACL contract the IdentificationFolderBuilder docstring promises. Task A (PR #153) shipped the walker as a pure function that materializes the UNS → Equipment → Tag browse tree + IdentificationFolderBuilder.Build per Equipment. This PR lands the authz half of the walker's story — the resolver side that turns a driver-side full reference into a full NodeScope path (NamespaceId + UnsAreaId + UnsLineId + EquipmentId + TagId) so the permission trie can walk the UNS hierarchy + apply Equipment-scope grants correctly at dispatch time. The actual in-server wiring (load snapshot → call walker during BuildAddressSpaceAsync → swap in the full-path resolver) is split into follow-up task #212 because it's a bigger surface (Server bootstrap + DriverNodeManager override + real OPC UA client-browse integration test). NodeScopeResolver extended with a second constructor taking IReadOnlyDictionary<string, NodeScope> pathIndex — when supplied, Resolve looks up the full reference in the index + returns the indexed scope with every UNS level populated; when absent or on miss, falls back to the pre-ADR-001 cluster-only scope so driver-discovered tags that haven't been indexed yet (between a DiscoverAsync result + the next generation publish) stay addressable without crashing the resolver. Index is frozen into a FrozenDictionary<string, NodeScope> under Ordinal comparer for O(1) hot-path lookups. Thread-safety by immutability — callers swap atomically on generation change via the server's publish pipeline. New ScopePathIndexBuilder.Build in Server.Security takes (clusterId, namespaceId, EquipmentNamespaceContent) + produces the fullReference → NodeScope dictionary by joining Tag → Equipment → UnsLine → UnsArea through up-front dictionaries keyed Ordinal-ignoring-case. Tag rows with null EquipmentId (SystemPlatform-namespace Galaxy tags per decision #120) are excluded from the index; cluster-only fallback path covers them. Broken FKs (Tag references missing Equipment row, or Equipment references missing UnsLine) are skipped rather than crashing — sp_ValidateDraft should have caught these at publish, any drift here is unexpected but non-fatal. Duplicate keys throw InvalidOperationException at bootstrap so corrupt-data drift surfaces up-front instead of producing silently-last-wins scopes at dispatch. End-to-end authz regression test in EquipmentIdentificationAuthzTests walks the full dispatch flow against a Config-DB-style fixture: ScopePathIndexBuilder.Build from the same EquipmentNamespaceContent the EquipmentNodeWalker consumes → NodeScopeResolver with that index → AuthorizationGate + TriePermissionEvaluator → PermissionTrieBuilder with one Equipment-scope NodeAcl grant + a NodeAclPath resolving Equipment ScopeId to (namespace, area, line, equipment). Four tests prove the contract: (a) authorized group Read granted on Identification property; (b) unauthorized group Read denied on Identification property — the #195 contract the IdentificationFolderBuilder docstring promises (the BadUserAccessDenied surfacing happens at the DriverNodeManager dispatch layer which is already wired to AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied in PR #94); (c) Equipment-scope grant cascades to both the Equipment's tag + its Identification properties because they share the Equipment ScopeId — no new scope level for Identification per the builder's Remarks section; (d) grant on oven-3 does NOT leak to press-7 (different equipment under the same UnsLine) proving per-Equipment isolation at dispatch when the resolver populates the full path. NodeScopeResolverTests extended with two new tests covering the indexed-lookup path + fallback-on-miss path; renamed the existing "_For_Phase1" test to "_When_NoIndexSupplied" to match the current framing. Server project builds 0 errors; Server.Tests 179/179 (was 173, +6 new across the two test files). Task #212 captures the remaining in-server wiring work — Server.SealedBootstrap load of EquipmentNamespaceContent, DriverNodeManager override that calls EquipmentNodeWalker during BuildAddressSpaceAsync for Equipment-kind namespaces, and a real OPC UA client-browse integration test. With that wiring + this PR's authz-layer proof, #195's "ACL integration test" line is satisfied at two layers (evaluator + live endpoint) which is stronger than the task originally asked for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:50:27 -04:00

181 lines
8.1 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
/// Walks the full dispatch flow for a read against an Equipment / Identification
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
/// same way they would be denied on the Equipment node itself, because they share the
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
/// section).
/// </summary>
[Trait("Category", "Unit")]
public sealed class EquipmentIdentificationAuthzTests
{
private const string Cluster = "c-warsaw";
private const string Namespace = "ns-plc";
[Fact]
public void Authorized_Group_Read_Granted_On_Identification_Property()
{
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var scope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
}
[Fact]
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
{
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
// without the grant gets BadUserAccessDenied on both the Equipment node + its
// Identification variables." This test proves the evaluator side of that contract;
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var scope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("bob", ["cn=other-team"]);
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
}
[Fact]
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
{
// Identification properties share their parent Equipment's ScopeId (no new scope
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
// AND its Identification properties — via the same evaluator call path.
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
var tagScope = resolver.Resolve("plcaddr-temperature");
var identityScope = resolver.Resolve("plcaddr-manufacturer");
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
}
[Fact]
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
{
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
// motivation for populating the full UNS path at resolve time.
var (gate, resolver) = BuildEvaluator(
equipmentGrantGroup: "cn=oven-3-operators",
equipmentIdForGrant: "eq-oven-3");
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
}
// ----- harness -----
/// <summary>
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
/// EquipmentNodeWalker would consume at address-space build.
/// </summary>
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
string equipmentGrantGroup,
string equipmentIdForGrant = "eq-oven-3")
{
var (content, scopeIndex) = BuildFixture();
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
var aclRow = new NodeAcl
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = Guid.NewGuid().ToString(),
GenerationId = 1,
ClusterId = Cluster,
LdapGroup = equipmentGrantGroup,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = equipmentIdForGrant,
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
};
var paths = new Dictionary<string, NodeAclPath>
{
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
};
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
var evaluator = new TriePermissionEvaluator(cache);
var gate = new AuthorizationGate(evaluator, strictMode: true);
_ = content;
return (gate, resolver);
}
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
{
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
var oven = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
};
var press = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
MachineCode = "MC-press-7",
};
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
var content = new EquipmentNamespaceContent(
Areas: [area],
Lines: [line],
Equipment: [oven, press],
Tags: [tempTag, mfgTag, pressTempTag]);
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
return (content, index);
}
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
{
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
};
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
{
public FakeIdentity(string name, IReadOnlyList<string> groups)
{
DisplayName = name;
LdapGroups = groups;
}
public new string DisplayName { get; }
public IReadOnlyList<string> LdapGroups { get; }
}
}