From e8ace39418cc720dc0c1b3b8ae872fffa3d34627 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:42:19 -0400 Subject: [PATCH] feat(dotnet-client): operator credentials on acknowledge-alarm Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- clients/dotnet/README.md | 13 +++++++-- .../MxGatewayClientCli.cs | 24 +++++++++++++++- .../MxGatewayClientAlarmsTests.cs | 28 +++++++++++++++++++ .../MxGatewayClientCliTests.cs | 8 ++++++ .../MxGatewayClient.cs | 26 +++++++++++------ 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 3c711f5..d651ddf 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -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 --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 --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 diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs index f84fb72..9429ef9 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -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. /// + private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD"; private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD"; /// @@ -388,6 +390,24 @@ public static class MxGatewayClientCli /// the error-redaction catch block can strip it from any surfaced error, /// mirroring . /// + 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 ] [--warmup-seconds ] [--bulk-size ] [--tag-start ] [--tag-prefix ] [--tag-attribute ] [--timeout-ms ] [--client-name ]"); writer.WriteLine("mxgw-dotnet stream-events --session-id [--max-events ] [--json]"); writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix ] [--max-events ] [--json] [--jsonl]"); - writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference [--comment ] [--operator ] [--json]"); + writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference --operator [--operator-domain ] [--operator-password | --operator-password-env ] [--comment ] [--json]"); writer.WriteLine("mxgw-dotnet write --session-id --server-handle --item-handle --type --value [--json]"); writer.WriteLine("mxgw-dotnet write2 --session-id --server-handle --item-handle --type --value [--timestamp ] [--json]"); writer.WriteLine("mxgw-dotnet smoke --item [--value --type ] [--json]"); diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs index 61582b6..803dbec 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs @@ -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")); } + /// AcknowledgeAlarmAsync redacts the operator password from a failing call's message. + /// A task that represents the asynchronous operation. + [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( + () => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest + { + AlarmFullReference = "Tank01.Level.HiHi", + OperatorUser = "alice", + OperatorPassword = "pw-secret", + })); + + Assert.DoesNotContain("pw-secret", ex.Message); + Assert.Contains("boom", ex.Message); + } + /// AcknowledgeAlarmAsync honors cancellation. /// A task that represents the asynchronous operation. [Fact] diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs index 43da1f1..cdae036 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -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()); } diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs index ed321b3..9ed1dcd 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs @@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable } /// - /// Acknowledges an active MXAccess alarm condition through the gateway. The - /// gateway authenticates the request against the API key's invoke:alarm-ack - /// scope and forwards the acknowledge to the worker's MXAccess session; - /// the resulting is returned in the reply. + /// Acknowledges an alarm as the operator. The gateway authenticates + /// with + /// 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. /// - /// The acknowledge request — alarm reference, comment, operator user. + /// The acknowledge request: alarm reference, comment, operator credentials. /// Cancellation token for the operation. /// The acknowledge reply with protocol + native MxStatus. - public Task AcknowledgeAlarmAsync( + public async Task 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); + } } ///