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;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||||
{
|
{
|
||||||
parseError = null;
|
parseError = null;
|
||||||
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||||
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
string comment = request.Comment ?? string.Empty;
|
string comment = request.Comment ?? string.Empty;
|
||||||
string operatorUser = request.OperatorUser ?? 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))
|
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||||
{
|
{
|
||||||
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Comment = comment,
|
Comment = comment,
|
||||||
OperatorUser = operatorUser,
|
OperatorUser = operatorUser,
|
||||||
OperatorNode = string.Empty,
|
OperatorNode = string.Empty,
|
||||||
OperatorDomain = string.Empty,
|
OperatorDomain = operatorDomain,
|
||||||
|
OperatorPassword = operatorPassword,
|
||||||
OperatorFullName = string.Empty,
|
OperatorFullName = string.Empty,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Comment = comment,
|
Comment = comment,
|
||||||
OperatorUser = operatorUser,
|
OperatorUser = operatorUser,
|
||||||
OperatorNode = string.Empty,
|
OperatorNode = string.Empty,
|
||||||
OperatorDomain = string.Empty,
|
OperatorDomain = operatorDomain,
|
||||||
|
OperatorPassword = operatorPassword,
|
||||||
OperatorFullName = string.Empty,
|
OperatorFullName = string.Empty,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxAlarmsPerFetch { get; init; } = 1024;
|
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>
|
/// <summary>
|
||||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
/// 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}.");
|
$"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)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
|
|||||||
{
|
{
|
||||||
"AuthenticateUser",
|
"AuthenticateUser",
|
||||||
"WriteSecured",
|
"WriteSecured",
|
||||||
"WriteSecured2"
|
"WriteSecured2",
|
||||||
|
// Carry operator_password.
|
||||||
|
"AcknowledgeAlarm",
|
||||||
|
"AcknowledgeAlarmByName"
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
|
|||||||
}
|
}
|
||||||
|
|
||||||
RedactCommandValue(properties);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService(
|
|||||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
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)
|
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
|
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
|
||||||
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;
|
||||||
|
|
||||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||||
|
|
||||||
return startInfo;
|
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,
|
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>
|
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
|
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
|
||||||
|
|||||||
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
|
|||||||
|
|
||||||
Assert.False(props.ContainsKey("CommandValue"));
|
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("AuthenticateUser")]
|
||||||
[InlineData("WriteSecured")]
|
[InlineData("WriteSecured")]
|
||||||
[InlineData("WriteSecured2")]
|
[InlineData("WriteSecured2")]
|
||||||
|
[InlineData("AcknowledgeAlarm")]
|
||||||
|
[InlineData("AcknowledgeAlarmByName")]
|
||||||
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
|
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
|
||||||
{
|
{
|
||||||
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
|
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
|
||||||
|
|||||||
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
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>
|
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
|
|||||||
WorkerLaunchEnvironment.EventQueueCapacity,
|
WorkerLaunchEnvironment.EventQueueCapacity,
|
||||||
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
|
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
|
||||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
|
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
|
||||||
|
WorkerLaunchEnvironment.AlarmAckClientName,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
|
/// <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",
|
"1024",
|
||||||
processFactory.LastStartInfo.Environment[
|
processFactory.LastStartInfo.Environment[
|
||||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
|
WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
|
||||||
|
Assert.Equal(
|
||||||
|
"MxGateway",
|
||||||
|
processFactory.LastStartInfo.Environment[
|
||||||
|
WorkerLaunchEnvironment.AlarmAckClientName]);
|
||||||
Assert.All(
|
Assert.All(
|
||||||
RequiredLaunchEnvironmentVariables,
|
RequiredLaunchEnvironmentVariables,
|
||||||
variable => Assert.True(
|
variable => Assert.True(
|
||||||
@@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests
|
|||||||
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
|
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>
|
/// <summary>
|
||||||
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — not the shipped
|
/// 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.
|
/// 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 startupTimeoutSeconds = 30,
|
||||||
int startupProbeRetryAttempts = 3,
|
int startupProbeRetryAttempts = 3,
|
||||||
int startupProbeRetryDelayMilliseconds = 250,
|
int startupProbeRetryDelayMilliseconds = 250,
|
||||||
int eventQueueCapacity = 10000)
|
int eventQueueCapacity = 10000,
|
||||||
|
string ackClientName = "MxGateway")
|
||||||
{
|
{
|
||||||
GatewayOptions options = new()
|
GatewayOptions options = new()
|
||||||
{
|
{
|
||||||
|
Alarms = new AlarmsOptions { AckClientName = ackClientName },
|
||||||
Worker = new WorkerOptions
|
Worker = new WorkerOptions
|
||||||
{
|
{
|
||||||
ExecutablePath = executablePath,
|
ExecutablePath = executablePath,
|
||||||
|
|||||||
Reference in New Issue
Block a user