feat(dotnet-client): operator credentials on acknowledge-alarm

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:42:19 -04:00
parent 9907a97cb0
commit e8ace39418
5 changed files with 86 additions and 13 deletions
+10 -3
View File
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
reference, optional comment, ack target). All three accept a cancellation
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
writes the comment to the alarm's `AckMsg`, so the Historian records the
operator). All three accept a cancellation
token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing.
@@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
`acknowledge-alarm` reads the operator password from `--operator-password` or the
environment variable named by `--operator-password-env` (default
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
any error output. Prefer the environment variable to keep it off the command line.
`smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
exception.Message,
apiKey,
TryResolveVerifyUserPassword(arguments),
ResolveOperatorPassword(arguments),
arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json"))
@@ -356,6 +357,7 @@ public static class MxGatewayClientCli
/// Legacy environment variable, still honoured as a deprecated fallback
/// for one release so existing scripts keep working.
/// </summary>
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
/// <summary>
@@ -388,6 +390,24 @@ public static class MxGatewayClientCli
/// the error-redaction catch block can strip it from any surfaced error,
/// mirroring <see cref="TryResolveApiKey" />.
/// </summary>
private static string? ResolveOperatorPassword(CliArguments arguments)
{
string? password = arguments.GetOptional("operator-password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
if (string.IsNullOrEmpty(environmentName))
{
environmentName = OperatorPasswordEnvironmentName;
}
password = Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrEmpty(password) ? null : password;
}
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
{
string? password = arguments.GetOptional("password");
@@ -1535,6 +1555,8 @@ public static class MxGatewayClientCli
AlarmFullReference = arguments.GetRequired("reference"),
Comment = arguments.GetOptional("comment") ?? string.Empty,
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
};
return WriteReplyAsync(
@@ -2465,7 +2487,7 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "investigating",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
OperatorDomain = "CORP",
});
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
@@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
Assert.Equal("investigating", call.Request.Comment);
Assert.Equal("alice", call.Request.OperatorUser);
Assert.Equal("pw-secret", call.Request.OperatorPassword);
Assert.Equal("CORP", call.Request.OperatorDomain);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
{
FakeGatewayTransport transport = CreateTransport();
// The real transport maps RpcException to MxGatewayException using the status
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
await using MxGatewayClient client = CreateClient(transport);
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
}));
Assert.DoesNotContain("pw-secret", ex.Message);
Assert.Contains("boom", ex.Message);
}
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests
[Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
@@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests
"ack from cli",
"--operator",
"operator1",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json",
],
output,
@@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
Assert.Equal("ack from cli", request.Comment);
Assert.Equal("operator1", request.OperatorUser);
Assert.Equal("CORP", request.OperatorDomain);
Assert.Equal("op-pw-secret", request.OperatorPassword);
Assert.DoesNotContain("op-pw-secret", output.ToString());
Assert.Contains("ack-fixture", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
}
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// scope and forwards the acknowledge to the worker's MXAccess session;
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// Acknowledges an alarm as the operator. The gateway authenticates
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
/// comment to the alarm's AckMsg attribute, so the alarm history records
/// the operator. The password is redacted from any surfaced error.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken);
try
{
return await ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken).ConfigureAwait(false);
}
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
{
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
}
}
/// <summary>