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,