diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs new file mode 100644 index 0000000..a98fc3b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs @@ -0,0 +1,402 @@ +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; } + } +}