feat(alarms): native condition Acknowledge routes to NativeAlarmAckRouter with principal [H6c]
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// H6c — the payload routed when an OPC UA client Acknowledges a NATIVE (driver-fed, e.g. Galaxy)
|
||||||
|
/// Part 9 condition. The scripted-alarm engine does not own native conditions, so the node manager
|
||||||
|
/// branches a native condition's inbound Acknowledge to a separate
|
||||||
|
/// <see cref="OtOpcUaNodeManager.NativeAlarmAckRouter"/> seam (the host later wires it to the backing
|
||||||
|
/// driver) instead of the scripted <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>.
|
||||||
|
/// <para>
|
||||||
|
/// This record is intentionally OpcUaServer-local (the smallest scope) and Akka-free: it carries
|
||||||
|
/// exactly what the driver-bound router needs — the condition node id to resolve back to a driver
|
||||||
|
/// ref, the operator's acknowledge comment, and the authenticated operator's display name.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ConditionNodeId">The acknowledged condition's folder-scoped NodeId identifier string
|
||||||
|
/// (the same value the <c>DriverHostActor</c> inverse map keys native conditions by), used to resolve the
|
||||||
|
/// command back to its backing driver + alarm ref.</param>
|
||||||
|
/// <param name="Comment">The operator's acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||||||
|
/// <param name="OperatorUser">The authenticated operator's display name (empty when none resolves).</param>
|
||||||
|
public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
|
||||||
@@ -97,6 +97,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
|
||||||
|
/// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
|
||||||
|
/// Acknowledges one, the condition's <c>OnAcknowledge</c> handler (wired in
|
||||||
|
/// <see cref="MaterialiseAlarmCondition"/>) branches on <see cref="IsNativeAlarmNode"/> and — after
|
||||||
|
/// the same <c>AlarmAck</c> role gate as <see cref="AlarmCommandRouter"/> — invokes THIS delegate
|
||||||
|
/// with a <see cref="NativeAlarmAck"/> instead of routing an <see cref="AlarmCommand"/> to the
|
||||||
|
/// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
|
||||||
|
/// (a later task wires the driver linkage).
|
||||||
|
/// <para>
|
||||||
|
/// Like <see cref="AlarmCommandRouter"/>, the handler delegate runs under the manager's
|
||||||
|
/// <c>Lock</c>, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
|
||||||
|
/// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
|
||||||
|
/// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
|
||||||
|
/// native condition stay on the scripted <see cref="AlarmCommandRouter"/> path (H6c scope is the
|
||||||
|
/// Acknowledge → driver path only).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }
|
||||||
|
|
||||||
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -602,8 +622,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
||||||
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
||||||
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
||||||
|
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
|
||||||
|
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
|
||||||
|
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
|
||||||
|
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
|
||||||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||||||
HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
IsNativeAlarmNode(alarmNodeId)
|
||||||
|
? HandleNativeAlarmAck(context, condition, comment)
|
||||||
|
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||||||
alarm.OnConfirm = (context, condition, _, comment) =>
|
alarm.OnConfirm = (context, condition, _, comment) =>
|
||||||
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||||
alarm.OnAddComment = (context, condition, _, comment) =>
|
alarm.OnAddComment = (context, condition, _, comment) =>
|
||||||
@@ -716,6 +742,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
return ServiceResult.Good;
|
return ServiceResult.Good;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
|
||||||
|
/// scripted engine does not own native conditions, so this routes a <see cref="NativeAlarmAck"/> to
|
||||||
|
/// <see cref="NativeAlarmAckRouter"/> (the driver-bound seam) rather than a scripted
|
||||||
|
/// <see cref="AlarmCommand"/> to <see cref="AlarmCommandRouter"/>. The calling principal is resolved
|
||||||
|
/// and the <c>AlarmAck</c> role gate applied EXACTLY as in <see cref="HandleAlarmCommand"/>
|
||||||
|
/// (<b>fails closed</b>: a missing identity or missing role is denied — no route, no state mutation).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
||||||
|
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session identity
|
||||||
|
/// (T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>).</param>
|
||||||
|
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
|
||||||
|
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
|
||||||
|
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||||||
|
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its event);
|
||||||
|
/// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||||
|
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
|
||||||
|
{
|
||||||
|
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
|
||||||
|
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
|
||||||
|
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||||
|
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
|
||||||
|
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
|
||||||
|
// (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
|
||||||
|
// Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
|
||||||
|
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
|
||||||
|
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||||
|
Comment: comment?.Text,
|
||||||
|
OperatorUser: identity.DisplayName ?? string.Empty));
|
||||||
|
|
||||||
|
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||||
|
return ServiceResult.Good;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
|
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
|
||||||
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
|
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
|
||||||
|
|||||||
@@ -411,6 +411,104 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>H6c — a NATIVE (driver-fed) condition's Acknowledge routes to <see
|
||||||
|
/// cref="OtOpcUaNodeManager.NativeAlarmAckRouter"/> (the driver-bound seam), NOT the scripted
|
||||||
|
/// <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>. With the AlarmAck role present, OnAcknowledge
|
||||||
|
/// returns Good, the captured <see cref="NativeAlarmAck"/> carries the condition NodeId, the operator
|
||||||
|
/// DisplayName, and the comment text, and the scripted router is NOT invoked.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
var scripted = new List<AlarmCommand>();
|
||||||
|
var native = new List<NativeAlarmAck>();
|
||||||
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1");
|
||||||
|
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-nak1");
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var ctx = SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck);
|
||||||
|
var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("native ack"));
|
||||||
|
|
||||||
|
result.ShouldBe(ServiceResult.Good);
|
||||||
|
native.Count.ShouldBe(1);
|
||||||
|
native[0].ConditionNodeId.ShouldBe("alm-nak1"); // folder-scoped condition NodeId identifier
|
||||||
|
native[0].OperatorUser.ShouldBe("nora");
|
||||||
|
native[0].Comment.ShouldBe("native ack");
|
||||||
|
scripted.ShouldBeEmpty(); // scripted engine NOT involved
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>H6c — a SCRIPTED condition's Acknowledge still routes through the scripted
|
||||||
|
/// <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/> (Operation == "Acknowledge"); the native
|
||||||
|
/// router is NOT invoked.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_OnAcknowledge_still_uses_AlarmCommandRouter()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
var scripted = new List<AlarmCommand>();
|
||||||
|
var native = new List<NativeAlarmAck>();
|
||||||
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2");
|
||||||
|
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-nak2");
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var ctx = SessionContext(server, "owen", OpcUaDataPlaneRoles.AlarmAck);
|
||||||
|
var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("scripted ack"));
|
||||||
|
|
||||||
|
result.ShouldBe(ServiceResult.Good);
|
||||||
|
scripted.Count.ShouldBe(1);
|
||||||
|
scripted[0].AlarmId.ShouldBe("alm-nak2");
|
||||||
|
scripted[0].Operation.ShouldBe("Acknowledge");
|
||||||
|
scripted[0].User.ShouldBe("owen");
|
||||||
|
scripted[0].Comment.ShouldBe("scripted ack");
|
||||||
|
native.ShouldBeEmpty(); // native seam NOT involved
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>H6c — a NATIVE condition's Acknowledge from an anonymous / role-less identity is vetoed
|
||||||
|
/// (BadUserAccessDenied) and NEITHER router is invoked — the gate fails closed before any route.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_OnAcknowledge_anonymous_is_denied()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
var scripted = new List<AlarmCommand>();
|
||||||
|
var native = new List<NativeAlarmAck>();
|
||||||
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3");
|
||||||
|
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-nak3");
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
|
||||||
|
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||||
|
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
|
||||||
|
|
||||||
|
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||||
|
native.ShouldBeEmpty();
|
||||||
|
scripted.ShouldBeEmpty();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
|
/// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
|
public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
|
||||||
|
|||||||
Reference in New Issue
Block a user