diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
index 09521fc6..ef967a28 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
@@ -41,10 +41,15 @@ public interface IAlarmSubscriptionHandle
}
/// One alarm acknowledgement in a batch.
+/// Driver-side identifier for the alarm source.
+/// Stable id correlating raise / ack / clear of the same condition.
+/// Operator-supplied comment forwarded to the upstream alarm system.
+/// Authenticated principal performing the ack; null on the raw/non-OPC-UA path.
public sealed record AlarmAcknowledgeRequest(
string SourceNodeId,
string ConditionId,
- string? Comment);
+ string? Comment,
+ string? OperatorUser = null);
/// Event payload for .
/// Subscription this event belongs to.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
index 684f1d55..eabf3411 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
@@ -68,11 +68,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
foreach (var a in acknowledgements)
{
- // The base interface doesn't carry a user identity — Stream G provides the
- // authenticated principal at the OPC UA dispatch layer + proxies through
- // the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
- // so callers using the raw IAlarmSource still produce an audit entry.
- await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
+ // Honor the authenticated principal carried on the request when present
+ // (the OPC UA dispatch layer / Stream G threads it through). When absent —
+ // the raw/non-OPC-UA path — default to "opcua-client" so the ack still
+ // produces an audit entry.
+ await _engine.AcknowledgeAsync(a.ConditionId, a.OperatorUser ?? "opcua-client", a.Comment, cancellationToken)
.ConfigureAwait(false);
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index a3f098da..f7a7efbd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -1122,7 +1122,9 @@ public sealed class GalaxyDriver
await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference,
ack.Comment ?? string.Empty,
- operatorUser: string.Empty, // server-side ACL fills this from the OPC UA session
+ // Honor the authenticated principal carried on the request when present;
+ // fall back to empty (server-side ACL fills this from the OPC UA session).
+ operatorUser: ack.OperatorUser ?? string.Empty,
cancellationToken).ConfigureAwait(false);
}
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
index ce499c52..dda21ce5 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
@@ -129,6 +129,37 @@ public sealed class ScriptedAlarmSourceTests
state.LastAckComment.ShouldBe("ack via opcua");
}
+ ///
+ /// Verifies that AcknowledgeAsync honors a supplied authenticated principal
+ /// () and falls back to
+ /// "opcua-client" only when none is supplied.
+ ///
+ [Fact]
+ public async Task ScriptedAlarmSource_uses_supplied_principal_else_default()
+ {
+ var (engine, source, up) = await BuildAsync();
+ using var _e = engine;
+ using var _s = source;
+
+ // Supplied principal -> honored.
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+ await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
+ "Plant/Line1", "Plant/Line1::HighTemp", "ack as bob", "bob")],
+ TestContext.Current.CancellationToken);
+ engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("bob");
+
+ // No principal -> default audit identity.
+ up.Push("Temp", 50);
+ await Task.Delay(200);
+ up.Push("Temp", 150);
+ await Task.Delay(200);
+ await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
+ "Plant/Line1", "Plant/Line1::HighTemp", "ack anon", null)],
+ TestContext.Current.CancellationToken);
+ engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("opcua-client");
+ }
+
/// Verifies that null arguments are rejected.
[Fact]
public async Task Null_arguments_rejected()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
index 0a9c173e..dd523b0e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
@@ -210,6 +210,26 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
}
+ ///
+ /// Verifies that AcknowledgeAsync forwards the request's authenticated principal
+ /// () to the acknowledger so the
+ /// gateway records WHO acked, rather than the generic empty-string fallback.
+ ///
+ [Fact]
+ public async Task AcknowledgeAsync_forwards_OperatorUser()
+ {
+ var feed = new FakeAlarmFeed();
+ var ack = new RecordingAcknowledger();
+ using var driver = NewDriver(feed, ack);
+
+ await driver.AcknowledgeAsync(
+ [new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")],
+ CancellationToken.None);
+
+ ack.Calls.ShouldHaveSingleItem();
+ ack.Calls[0].Operator.ShouldBe("alice");
+ }
+
/// Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()