OneShotShelve / TimedShelve / Unshelve now reach the ScriptedAlarmEngine. Scripted-alarm condition nodes get a ShelvedStateMachine subtree created before alarm.Create so the stack wires each shelve method's dispatch handler; AlarmConditionState.OnShelve / OnTimedUnshelve route to the engine and mirror the result onto the OPC UA node via SetShelvingState. The three per-instance shelve method NodeIds are indexed so the Call gate resolves them to OpcUaOperation.AlarmShelve instead of falling through to generic Call. Engine dispatch is split into the node-free InvokeEngineShelve so the routing decision is unit-testable. Adds 9 unit tests; updates phase-7-status.md Gap 1 (only AddComment remains unwired) and the #24 entry in looseends.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
412 lines
18 KiB
C#
412 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 resolves via the indexed shelve-method NodeId set (Task #24
|
|
/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call</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 resolution in MapCallOperation (Task #24 follow-up)
|
|
// Shelve methods carry per-instance NodeIds, so they resolve to AlarmShelve
|
|
// via membership in the indexed shelve-method set rather than a constant match.
|
|
// ======================================================================
|
|
|
|
[Fact]
|
|
public void MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve()
|
|
{
|
|
// The address-space build indexes each scripted alarm's three ShelvedStateMachine
|
|
// method NodeIds. A call whose MethodId is in that set gates as AlarmShelve, so
|
|
// operators can be granted shelve rights independently of generic MethodCall.
|
|
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
|
var index = new HashSet<NodeId> { shelveMethodId };
|
|
|
|
DriverNodeManager.MapCallOperation(shelveMethodId, index).ShouldBe(OpcUaOperation.AlarmShelve);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapCallOperation_unindexed_shelve_method_falls_through_to_Call()
|
|
{
|
|
// Without the index (e.g. a deployment with no scripted alarms) a shelve-shaped
|
|
// NodeId is indistinguishable from a generic driver method and gates as Call.
|
|
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
|
|
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
|
|
}
|
|
|
|
[Fact]
|
|
public void MethodCall_grant_allows_generic_Call()
|
|
{
|
|
// Users with MethodCall permission can invoke generic (non-alarm) driver methods.
|
|
// Shelve methods now gate as AlarmShelve when indexed (see
|
|
// MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve).
|
|
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; }
|
|
}
|
|
}
|