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.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Task #12 hardening tests for the Phase 6.2 deferred authorization gates — /// Browse, Subscribe (CreateMonitoredItems), Alarm-acknowledge, and Call. /// /// Fills the compliance-checklist gaps not covered by the existing per-gate unit /// tests (, , /// ): /// /// Lax-mode fall-through for all four deferred gates /// Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only /// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag") /// AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine /// per-instance NodeId limitation noted in the MapCallOperation implementation) /// Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops /// /// [Trait("Category", "Unit")] public sealed class DeferredGateHardeningTests { private const string Cluster = "c1"; // ====================================================================== // 1. Lax-mode fall-through — deferred gates // ====================================================================== [Fact] public void Subscribe_gate_lax_mode_null_identity_keeps_items() { // In lax mode a session without LDAP groups must NOT be denied — // the pre-Phase-6.2 default path runs unchanged. var items = new List { NewMonitorRequest("c1/area/line/eq/tag1") }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: false, rows: []); // lax, no grants DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster)); errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour — no denial for unauthenticated sessions"); } [Fact] public void Subscribe_gate_lax_mode_identity_without_ldap_groups_keeps_items() { var items = new List { NewMonitorRequest("c1/area/line/eq/tag1") }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: false, rows: []); // UserIdentity with no LDAP groups — lax gate should not deny DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster)); errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups"); } [Fact] public void Call_gate_lax_mode_null_identity_keeps_calls() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: false, rows: []); DriverNodeManager.GateCallMethodRequests(calls, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster)); errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour for null identity"); } [Fact] public void Call_gate_lax_mode_identity_without_ldap_groups_keeps_calls() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: false, rows: []); DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster)); errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups"); } // ====================================================================== // 2. Flag isolation — Subscribe vs Read // ====================================================================== [Fact] public void Subscribe_grant_does_not_imply_Read() { // Phase 6.2 compliance: Subscribe and Read are independent flags. A session // granted only Subscribe should NOT be able to read the current value. var gate = MakeGate(strict: true, rows: [ Row("grp-subs", NodePermissions.Subscribe), ]); var identity = NewIdentity("alice", "grp-subs"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeTrue("Subscribe grant allows CreateMonitoredItems"); gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Subscribe grant alone does NOT allow Read"); } [Fact] public void Read_grant_does_not_imply_Subscribe() { // Read-only sessions can read current values but must not be allowed to subscribe. // This is a deliberate restriction: a data-centre operator monitoring a dashboard // via an OPC UA subscription is a different grant tier than "read once on demand". var gate = MakeGate(strict: true, rows: [ Row("grp-readonly", NodePermissions.Read), ]); var identity = NewIdentity("alice", "grp-readonly"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read grant allows Read"); gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeFalse("Read grant alone does NOT allow Subscribe"); } // ====================================================================== // 3. Flag isolation — HistoryRead vs Read // "HistoryRead uses its own flag" from Phase 6.2 Compliance Checklist // ====================================================================== [Fact] public void Read_grant_without_HistoryRead_denies_history_access() { // Phase 6.2 Compliance Checklist: "user with Read but not HistoryRead can read live // values but gets BadUserAccessDenied on HistoryRead." var gate = MakeGate(strict: true, rows: [ Row("grp-read", NodePermissions.Read), // no HistoryRead bit ]); var identity = NewIdentity("bob", "grp-read"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read granted for current values"); gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeFalse("HistoryRead NOT granted — own flag required"); } [Fact] public void HistoryRead_grant_without_Read_denies_current_value_read() { // Verify flag isolation in the other direction too — history archivers that can // pull history should not implicitly get live-read access. var gate = MakeGate(strict: true, rows: [ Row("grp-hist", NodePermissions.HistoryRead), // no Read bit ]); var identity = NewIdentity("carol", "grp-hist"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeTrue("HistoryRead granted for historical values"); gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read NOT granted — own flag required"); } // ====================================================================== // 4. Flag isolation — Alarm bits // ====================================================================== [Fact] public void AlarmAcknowledge_grant_does_not_imply_AlarmConfirm() { // Each alarm-action bit is distinct — operators can acknowledge without also // having confirm authority. var gate = MakeGate(strict: true, rows: [ Row("grp-ack", NodePermissions.AlarmAcknowledge), ]); var identity = NewIdentity("dave", "grp-ack"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue(); gate.IsAllowed(identity, OpcUaOperation.AlarmConfirm, scope).ShouldBeFalse("Confirm requires its own flag"); gate.IsAllowed(identity, OpcUaOperation.AlarmShelve, scope).ShouldBeFalse("Shelve requires its own flag"); } [Fact] public void Browse_grant_does_not_grant_AlarmAcknowledge() { // Browse is granted for hierarchy navigation; it must not cascade to alarm actions. var gate = MakeGate(strict: true, rows: [ Row("grp-browse", NodePermissions.Browse), ]); var identity = NewIdentity("eve", "grp-browse"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue(); gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeFalse(); } // ====================================================================== // 5. AlarmShelve falls through to Call in MapCallOperation // Documents the ShelvedStateMachine per-instance NodeId limitation. // ====================================================================== [Fact] public void MapCallOperation_AlarmShelve_falls_through_to_Call() { // AlarmShelve methods on ShelvedStateMachine arrive with per-instance NodeIds // (not well-known type NodeIds), so they can't be reliably constant-matched. // MapCallOperation returns OpcUaOperation.Call for any unrecognised method NodeId; // operators who can Shelve must therefore have NodePermissions.MethodCall granted. // (This is an intentional design decision documented in the MapCallOperation // implementation remarks — finer-grained AlarmShelve gating is deferred until // the method-invocation path also carries a "method-role" annotation.) var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0); DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call); } [Fact] public void MethodCall_grant_allows_generic_Call_including_shelve_path() { // Users with MethodCall permission can invoke shelve methods because the gate // maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call). var gate = MakeGate(strict: true, rows: [ Row("grp-eng", NodePermissions.MethodCall), ]); var identity = NewIdentity("frank", "grp-eng"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeTrue("MethodCall grant covers generic Call"); } // ====================================================================== // 6. OpcUaOperation → NodePermissions mapping completeness (deferred ops) // Ensures the TriePermissionEvaluator maps all deferred operations correctly. // ====================================================================== [Theory] [InlineData(OpcUaOperation.Browse, NodePermissions.Browse)] [InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Subscribe)] [InlineData(OpcUaOperation.TransferSubscriptions,NodePermissions.Subscribe)] [InlineData(OpcUaOperation.Call, NodePermissions.MethodCall)] [InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.AlarmAcknowledge)] [InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.AlarmConfirm)] [InlineData(OpcUaOperation.AlarmShelve, NodePermissions.AlarmShelve)] public void Deferred_operation_maps_to_expected_permission_bit(OpcUaOperation op, NodePermissions required) { // Phase 6.2 Stream C compliance — every deferred gate operation must map to the // correct NodePermissions bit in TriePermissionEvaluator. Verifies the full // round-trip: grant exactly the required bit → IsAllowed returns true; no grant // → false. var gate = MakeGate(strict: true, rows: [Row("grp-test", required)]); var identity = NewIdentity("tester", "grp-test"); var scope = Scope(); gate.IsAllowed(identity, op, scope).ShouldBeTrue( $"operation {op} should be allowed when {required} bit is granted"); } [Theory] [InlineData(OpcUaOperation.Browse, NodePermissions.Read)] // wrong bit [InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Read)] // wrong bit [InlineData(OpcUaOperation.Call, NodePermissions.Browse)] // wrong bit [InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.Browse)] // wrong bit [InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.Browse)] // wrong bit [InlineData(OpcUaOperation.AlarmShelve, NodePermissions.Browse)] // wrong bit public void Deferred_operation_denied_when_wrong_permission_bit_granted(OpcUaOperation op, NodePermissions wrongBit) { var gate = MakeGate(strict: true, rows: [Row("grp-wrong", wrongBit)]); var identity = NewIdentity("tester", "grp-wrong"); var scope = Scope(); gate.IsAllowed(identity, op, scope).ShouldBeFalse( $"operation {op} must NOT be allowed by the {wrongBit} bit"); } // ====================================================================== // 7. Mixed multi-group union for deferred gates // ====================================================================== [Fact] public void Multi_group_union_for_deferred_gates() { // A session belonging to both grp-browse (Browse only) and grp-ack (AlarmAck only) // should be allowed both Browse and AlarmAcknowledge but not Read or Call. var gate = MakeGate(strict: true, rows: [ Row("grp-browse", NodePermissions.Browse), Row("grp-ack", NodePermissions.AlarmAcknowledge), ]); var identity = NewIdentity("grace", "grp-browse", "grp-ack"); var scope = Scope(); gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue("Browse from first group"); gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue("AlarmAcknowledge from second group"); gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read not granted by either group"); gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeFalse("Call not granted by either group"); } // ====================================================================== // 8. Strict vs lax for Browse gate (parity with existing BrowseGatingTests) // ====================================================================== [Fact] public void Browse_gate_strict_mode_denies_identity_with_ldap_groups_but_no_grant() { var refs = new List { NewRef("c1/area/line/eq/tag1") }; // Identity has groups but no Browse ACL → strict mode must deny var gate = MakeGate(strict: true, rows: [Row("grp-other", NodePermissions.Read)]); var resolver = new NodeScopeResolver(Cluster); DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver); refs.Count.ShouldBe(0, "strict mode: no Browse grant → reference removed"); } [Fact] public void Browse_gate_strict_mode_allows_with_Browse_grant() { var refs = new List { NewRef("c1/area/line/eq/tag1"), NewRef("c1/area/line/eq/tag2"), }; var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Browse)]); var resolver = new NodeScopeResolver(Cluster); DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver); refs.Count.ShouldBe(2, "strict mode: Browse grant → both references pass through"); } // ---- helpers ----------------------------------------------------------- private static NodeScope Scope() => new() { ClusterId = Cluster, NamespaceId = "ns", UnsAreaId = "area", UnsLineId = "line", EquipmentId = "eq", TagId = "tag1", Kind = NodeHierarchyKind.Equipment, }; private static NodeAcl Row(string group, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = Guid.NewGuid().ToString(), GenerationId = 1, ClusterId = Cluster, LdapGroup = group, ScopeKind = NodeAclScopeKind.Cluster, ScopeId = null, PermissionFlags = flags, }; private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows) { var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(Cluster, 1, rows)); var evaluator = new TriePermissionEvaluator(cache); return new AuthorizationGate(evaluator, strictMode: strict); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); private static MonitoredItemCreateRequest NewMonitorRequest(string fullRef) => new() { ItemToMonitor = new ReadValueId { NodeId = new NodeId(fullRef, 2) }, }; private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new() { ObjectId = new NodeId(objectFullRef, 2), MethodId = methodId, }; private static ReferenceDescription NewRef(string fullRef) => new() { NodeId = new NodeId(fullRef, 2), BrowseName = new QualifiedName("browse"), DisplayName = new LocalizedText("display"), }; 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; } } }