diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index b271c8f..76fb408 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
return string.Empty;
}
- private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
+ internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
{
parseError = null;
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
string comment = request.Comment ?? string.Empty;
string operatorUser = request.OperatorUser ?? string.Empty;
+ string operatorPassword = request.OperatorPassword ?? string.Empty;
+ string operatorDomain = request.OperatorDomain ?? string.Empty;
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
{
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment,
OperatorUser = operatorUser,
OperatorNode = string.Empty,
- OperatorDomain = string.Empty,
+ OperatorDomain = operatorDomain,
+ OperatorPassword = operatorPassword,
OperatorFullName = string.Empty,
},
};
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment,
OperatorUser = operatorUser,
OperatorNode = string.Empty,
- OperatorDomain = string.Empty,
+ OperatorDomain = operatorDomain,
+ OperatorPassword = operatorPassword,
OperatorFullName = string.Empty,
},
};
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
index 94363bf..bcd72ec 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
///
public int MaxAlarmsPerFetch { get; init; } = 1024;
+ ///
+ /// MXAccess client name the alarm worker's ack writer registers with. The
+ /// AVEVA Historian records it as each acknowledgement's OperatorNode.
+ /// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
+ ///
+ public string AckClientName { get; init; } = "MxGateway";
+
///
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
index f103bdc..c98d6be 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase 64
+ || options.AckClientName.Any(char.IsControl))
+ {
+ builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
+ }
+
if (!options.Enabled)
{
return;
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs
index bc517d9..0d2020b 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
{
"AuthenticateUser",
"WriteSecured",
- "WriteSecured2"
+ "WriteSecured2",
+ // Carry operator_password.
+ "AcknowledgeAlarm",
+ "AcknowledgeAlarmByName"
};
///
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs
index 7976a61..249e3e1 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorSeam.cs
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
}
RedactCommandValue(properties);
+
+ foreach (string key in properties.Keys.ToList())
+ {
+ if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null)
+ {
+ properties[key] = GatewayLogRedactor.RedactedValue;
+ }
+ }
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs
index 1a326d5..99ba149 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs
@@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService(
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
}
+ if (string.IsNullOrWhiteSpace(request.OperatorUser))
+ {
+ throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
+ }
+
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
.ConfigureAwait(false);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
index de305b5..11ca0f6 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
@@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;
+
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
return startInfo;
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs
new file mode 100644
index 0000000..9906087
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs
@@ -0,0 +1,46 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Alarms;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
+
+public sealed class GatewayAlarmMonitorAcknowledgeTests
+{
+ /// A GUID-referenced ack carries the operator identity and password to the worker command.
+ [Fact]
+ public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
+ {
+ MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
+ {
+ AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
+ Comment = "checked",
+ OperatorUser = "alice",
+ OperatorDomain = "CORP",
+ OperatorPassword = "pw-secret",
+ }, out string? error);
+
+ Assert.Null(error);
+ Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
+ Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
+ Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
+ Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
+ }
+
+ /// A name-referenced ack carries the operator identity and password to the by-name command.
+ [Fact]
+ public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
+ {
+ MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
+ {
+ AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
+ OperatorUser = "alice",
+ OperatorDomain = "CORP",
+ OperatorPassword = "pw-secret",
+ }, out _);
+
+ Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
+ Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
+ Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
+ Assert.Equal("CORP", command.AcknowledgeAlarmByNameCommand.OperatorDomain);
+ Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
index cbafda0..e52055f 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
@@ -164,6 +164,45 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
+ /// Verifies AckClientName defaults to "MxGateway" and passes validation.
+ [Fact]
+ public void Validate_Succeeds_WithDefaultAckClientName()
+ {
+ Assert.Equal("MxGateway", new AlarmsOptions().AckClientName);
+
+ ValidateOptionsResult result = new GatewayOptionsValidator()
+ .Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions()));
+ Assert.True(result.Succeeded);
+ }
+
+ /// Verifies an empty, over-long, or control-character AckClientName fails validation.
+ /// Client name under test.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("bad\nname")]
+ public void Validate_Fails_WhenAckClientNameInvalid(string name)
+ {
+ ValidateOptionsResult result = new GatewayOptionsValidator()
+ .Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = name }));
+
+ Assert.False(result.Succeeded);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("MxGateway:Alarms:AckClientName", StringComparison.Ordinal));
+ }
+
+ /// Verifies a 65-character AckClientName fails and a 64-character one passes.
+ [Fact]
+ public void Validate_AckClientNameLengthBoundary()
+ {
+ GatewayOptionsValidator validator = new();
+ Assert.False(validator.Validate(
+ null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 65) })).Succeeded);
+ Assert.True(validator.Validate(
+ null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 64) })).Succeeded);
+ }
+
/// Verifies the alarm poll cadence and per-fetch cap defaults pass validation.
[Fact]
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs
index 31e4071..562b01b 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorSeamTests.cs
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
Assert.False(props.ContainsKey("CommandValue"));
}
+
+ /// Any property whose name contains "password" is masked regardless of casing.
+ [Fact]
+ public void Redact_PasswordNamedProperty_IsRedacted()
+ {
+ GatewayLogRedactorSeam redactor = new();
+ Dictionary props = new()
+ {
+ ["OperatorPassword"] = "pw-secret",
+ ["verify_user_PASSWORD"] = "pw-secret",
+ ["OperatorUser"] = "alice",
+ };
+
+ redactor.Redact(props);
+
+ Assert.Equal("[redacted]", props["OperatorPassword"]);
+ Assert.Equal("[redacted]", props["verify_user_PASSWORD"]);
+ Assert.Equal("alice", props["OperatorUser"]);
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs
index 41df638..82ec1d3 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs
@@ -118,6 +118,8 @@ public sealed class GatewayLogRedactorTests
[InlineData("AuthenticateUser")]
[InlineData("WriteSecured")]
[InlineData("WriteSecured2")]
+ [InlineData("AcknowledgeAlarm")]
+ [InlineData("AcknowledgeAlarmByName")]
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
{
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
index 15c5493..1b473aa 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
+ /// Verifies AcknowledgeAlarm rejects an empty operator_user.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
+ {
+ MxAccessGatewayService service = CreateService(new FakeSessionManager());
+
+ RpcException exception = await Assert.ThrowsAsync(
+ async () => await service.AcknowledgeAlarm(
+ new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
+ new TestServerCallContext()));
+
+ Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
+ Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
+ }
+
/// Verifies AcknowledgeAlarm delegates a valid request to the alarm service.
/// A task that represents the asynchronous operation.
[Fact]
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs
index 7fbbb3a..0f8a4ef 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
WorkerLaunchEnvironment.EventQueueCapacity,
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
+ WorkerLaunchEnvironment.AlarmAckClientName,
];
/// Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.
@@ -75,6 +76,10 @@ public sealed class WorkerProcessLauncherTests
"1024",
processFactory.LastStartInfo.Environment[
WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
+ Assert.Equal(
+ "MxGateway",
+ processFactory.LastStartInfo.Environment[
+ WorkerLaunchEnvironment.AlarmAckClientName]);
Assert.All(
RequiredLaunchEnvironmentVariables,
variable => Assert.True(
@@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
}
+ /// Verifies a configured AckClientName reaches the worker environment.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task LaunchAsync_WithConfiguredAckClientName_ExportsItToTheWorkerEnvironment()
+ {
+ using TestDirectory directory = TestDirectory.Create();
+ string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
+ FakeWorkerProcessFactory processFactory = new(new FakeWorkerProcess(processId: 1234));
+ WorkerProcessLauncher launcher = CreateLauncher(
+ executablePath,
+ processFactory,
+ new SucceedingStartupProbe(),
+ ackClientName: "Plant-Ack");
+
+ using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest());
+
+ Assert.NotNull(processFactory.LastStartInfo);
+ Assert.Equal(
+ "Plant-Ack",
+ processFactory.LastStartInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName]);
+ }
+
///
/// Verifies that a configured — not the shipped
/// default — is what reaches the worker, so the option is deployable without a worker rebuild.
@@ -265,10 +292,12 @@ public sealed class WorkerProcessLauncherTests
int startupTimeoutSeconds = 30,
int startupProbeRetryAttempts = 3,
int startupProbeRetryDelayMilliseconds = 250,
- int eventQueueCapacity = 10000)
+ int eventQueueCapacity = 10000,
+ string ackClientName = "MxGateway")
{
GatewayOptions options = new()
{
+ Alarms = new AlarmsOptions { AckClientName = ackClientName },
Worker = new WorkerOptions
{
ExecutablePath = executablePath,