Add DeferredGateHardeningTests (28 unit tests) covering the Phase 6.2 compliance-checklist gaps left by the per-gate unit suites that shipped with the gate implementations: - Lax-mode fall-through for CreateMonitoredItems and Call gates (null identity and identity-without-LDAP-groups both skip denial in lax mode, consistent with BrowseGatingTests.Lax_mode_null_identity) - Flag isolation: Subscribe-only grant does NOT imply Read; Read-only grant does NOT imply Subscribe; HistoryRead-only grant does NOT imply Read and vice versa (Phase 6.2 compliance: "HistoryRead uses its own flag") - Alarm-bit isolation: AlarmAcknowledge alone does not grant AlarmConfirm or AlarmShelve; Browse alone does not grant AlarmAcknowledge - AlarmShelve falls through to OpcUaOperation.Call in MapCallOperation (documents the ShelvedStateMachine per-instance NodeId limitation noted in the implementation, with the follow-up path: MethodCall grant covers it) - Complete OpcUaOperation→NodePermissions mapping coverage for all deferred operations (Browse, CreateMonitoredItems, TransferSubscriptions, Call, AlarmAcknowledge, AlarmConfirm, AlarmShelve) — both positive and wrong-bit negative cases - Multi-group union for deferred gates (grp-browse ∪ grp-ack gives both Browse and AlarmAcknowledge without leaking Read or Call) Build: 0 errors on Server.csproj (verified against main repo build which carries the gRPC-generated Galaxy driver artifacts the isolated worktree lacks — that pre-existing gap is unrelated to these changes). Test count: 247 → 275 (+28 unit, 0 failures). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
403 lines
18 KiB
C#
403 lines
18 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.Server.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// 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 (<see cref="BrowseGatingTests"/>, <see cref="MonitoredItemGatingTests"/>,
|
|
/// <see cref="CallGatingTests"/>):
|
|
/// <list type="bullet">
|
|
/// <item>Lax-mode fall-through for all four deferred gates</item>
|
|
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
|
|
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
|
|
/// <item>AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine
|
|
/// per-instance NodeId limitation noted in the MapCallOperation implementation)</item>
|
|
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
|
|
/// </list>
|
|
/// </summary>
|
|
[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<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
|
|
var errors = new List<ServiceResult> { (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<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
|
|
var errors = new List<ServiceResult> { (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<CallMethodRequest>
|
|
{
|
|
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
|
};
|
|
var errors = new List<ServiceResult> { (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<CallMethodRequest>
|
|
{
|
|
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
|
};
|
|
var errors = new List<ServiceResult> { (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<ReferenceDescription> { 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<ReferenceDescription>
|
|
{
|
|
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<string> groups)
|
|
{
|
|
DisplayName = name;
|
|
LdapGroups = groups;
|
|
}
|
|
public new string DisplayName { get; }
|
|
public IReadOnlyList<string> LdapGroups { get; }
|
|
}
|
|
}
|