feat(dotnet-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
|
|||||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||||
the active alarms the gateway's central monitor currently holds),
|
the active alarms the gateway's central monitor currently holds),
|
||||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||||
reference, optional comment, ack target). All three accept a cancellation
|
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
|
token and pass through the `MxGateway:Alarms` configuration on the
|
||||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||||
stream rather than failing.
|
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 -- 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-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 -- 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
|
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,
|
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
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
|
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
|
|||||||
exception.Message,
|
exception.Message,
|
||||||
apiKey,
|
apiKey,
|
||||||
TryResolveVerifyUserPassword(arguments),
|
TryResolveVerifyUserPassword(arguments),
|
||||||
|
ResolveOperatorPassword(arguments),
|
||||||
arguments.GetOptional("value"));
|
arguments.GetOptional("value"));
|
||||||
|
|
||||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||||
@@ -356,6 +357,7 @@ public static class MxGatewayClientCli
|
|||||||
/// Legacy environment variable, still honoured as a deprecated fallback
|
/// Legacy environment variable, still honoured as a deprecated fallback
|
||||||
/// for one release so existing scripts keep working.
|
/// for one release so existing scripts keep working.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
|
||||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -388,6 +390,24 @@ public static class MxGatewayClientCli
|
|||||||
/// the error-redaction catch block can strip it from any surfaced error,
|
/// the error-redaction catch block can strip it from any surfaced error,
|
||||||
/// mirroring <see cref="TryResolveApiKey" />.
|
/// mirroring <see cref="TryResolveApiKey" />.
|
||||||
/// </summary>
|
/// </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)
|
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
|
||||||
{
|
{
|
||||||
string? password = arguments.GetOptional("password");
|
string? password = arguments.GetOptional("password");
|
||||||
@@ -1535,6 +1555,8 @@ public static class MxGatewayClientCli
|
|||||||
AlarmFullReference = arguments.GetRequired("reference"),
|
AlarmFullReference = arguments.GetRequired("reference"),
|
||||||
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||||
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||||
|
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
|
||||||
|
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
return WriteReplyAsync(
|
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 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-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 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 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 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]");
|
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = "investigating",
|
Comment = "investigating",
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
|
OperatorPassword = "pw-secret",
|
||||||
|
OperatorDomain = "CORP",
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
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("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||||
Assert.Equal("investigating", call.Request.Comment);
|
Assert.Equal("investigating", call.Request.Comment);
|
||||||
Assert.Equal("alice", call.Request.OperatorUser);
|
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"));
|
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>
|
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
|
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
|
||||||
using var output = new StringWriter();
|
using var output = new StringWriter();
|
||||||
using var error = new StringWriter();
|
using var error = new StringWriter();
|
||||||
FakeCliClient fakeClient = new();
|
FakeCliClient fakeClient = new();
|
||||||
@@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
|||||||
"ack from cli",
|
"ack from cli",
|
||||||
"--operator",
|
"--operator",
|
||||||
"operator1",
|
"operator1",
|
||||||
|
"--operator-domain",
|
||||||
|
"CORP",
|
||||||
|
"--operator-password-env",
|
||||||
|
"MXGW_TEST_OPPW",
|
||||||
"--json",
|
"--json",
|
||||||
],
|
],
|
||||||
output,
|
output,
|
||||||
@@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||||
Assert.Equal("ack from cli", request.Comment);
|
Assert.Equal("ack from cli", request.Comment);
|
||||||
Assert.Equal("operator1", request.OperatorUser);
|
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.Contains("ack-fixture", output.ToString());
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
/// Acknowledges an alarm as the operator. The gateway authenticates
|
||||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
||||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
||||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
||||||
|
/// the operator. The password is redacted from any surfaced error.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
||||||
return ExecuteSafeUnaryAsync(
|
try
|
||||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
{
|
||||||
cancellationToken);
|
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>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user