feat(gateway): forward operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user