feat(gateway): forward operator credentials on alarm acknowledgement

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:40:23 -04:00
parent d6db0ff40d
commit 2858e42e80
13 changed files with 192 additions and 5 deletions
@@ -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,
},
};
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
/// </summary>
public int MaxAlarmsPerFetch { get; init; } = 1024;
/// <summary>
/// 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.
/// </summary>
public string AckClientName { get; init; } = "MxGateway";
/// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
}
if (string.IsNullOrWhiteSpace(options.AckClientName)
|| options.AckClientName.Length > 64
|| options.AckClientName.Any(char.IsControl))
{
builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
}
if (!options.Enabled)
{
return;
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
{
"AuthenticateUser",
"WriteSecured",
"WriteSecured2"
"WriteSecured2",
// Carry operator_password.
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
};
/// <summary>
@@ -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;
}
}
}
/// <summary>
@@ -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);
}
@@ -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;
@@ -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
{
/// <summary>A GUID-referenced ack carries the operator identity and password to the worker command.</summary>
[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);
}
/// <summary>A name-referenced ack carries the operator identity and password to the by-name command.</summary>
[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);
}
}
@@ -164,6 +164,45 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies AckClientName defaults to "MxGateway" and passes validation.</summary>
[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);
}
/// <summary>Verifies an empty, over-long, or control-character AckClientName fails validation.</summary>
/// <param name="name">Client name under test.</param>
[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));
}
/// <summary>Verifies a 65-character AckClientName fails and a 64-character one passes.</summary>
[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);
}
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
Assert.False(props.ContainsKey("CommandValue"));
}
/// <summary>Any property whose name contains "password" is masked regardless of casing.</summary>
[Fact]
public void Redact_PasswordNamedProperty_IsRedacted()
{
GatewayLogRedactorSeam redactor = new();
Dictionary<string, object?> 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"]);
}
}
@@ -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));
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies AcknowledgeAlarm rejects an empty operator_user.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
{
MxAccessGatewayService service = CreateService(new FakeSessionManager());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
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);
}
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
WorkerLaunchEnvironment.EventQueueCapacity,
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
WorkerLaunchEnvironment.AlarmAckClientName,
];
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
@@ -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);
}
/// <summary>Verifies a configured AckClientName reaches the worker environment.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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]);
}
/// <summary>
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — 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,