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; /// /// 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). /// [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 ----- /// /// 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 , and a ScopePathIndex /// populated via ScopePathIndexBuilder from the same Config-DB row set the /// EquipmentNodeWalker would consume at address-space build. /// 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 { [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) 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 groups) { DisplayName = name; LdapGroups = groups; } public new string DisplayName { get; } public IReadOnlyList LdapGroups { get; } } }