From bde042b4d4833e72d258d0a4d65dab8b5f1e5ec0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 16:41:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(clients):=20CLI-04=20typed=20single-item?= =?UTF-8?q?=20command=20parity=20(+CLI-30=20unregister)=20=E2=80=94=204/5?= =?UTF-8?q?=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every parity-critical single-item MXAccess command now has a typed session helper instead of only a raw-Invoke escape hatch. Added per client: - Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser, ArchestrAUserToId - Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate - CLI-30: Unregister (Rust + .NET; Go/Python already had it) Each wraps the existing raw-command machinery (no new wire surface) and runs the same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces the native failure unchanged (not pre-validated/reordered). Credentials (AuthenticateUser password, WriteSecured payloads) route through each client's secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors. New CLI subcommands source credentials via flag/env, never echoed. - .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor. Build clean (0 warn), 102 passed. - Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory to typed. gofmt/vet/build/test clean. - Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on secured paths; error.rs credential scrub. fmt/check/test/clippy clean. - Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands. 145 passed. - Shared doc: ClientLibrariesDesign "Typed Command Parity" section. Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30 stay open until it lands. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW --- .../MxGatewayCliSecretRedactor.cs | 25 +- .../MxGatewayClientCli.cs | 297 +++++++++- .../MxGatewayClientCliTests.cs | 114 ++++ .../MxGatewayClientSessionTests.cs | 291 ++++++++++ .../MxGatewaySession.cs | 519 ++++++++++++++++++ clients/go/README.md | 39 +- clients/go/cmd/mxgw-go/main.go | 100 +++- clients/go/cmd/mxgw-go/main_test.go | 30 + clients/go/mxgateway/errors.go | 58 ++ clients/go/mxgateway/session.go | 271 +++++++++ .../mxgateway/session_parity_helpers_test.go | 224 ++++++++ clients/python/README.md | 38 +- .../python/src/zb_mom_ww_mxgateway/session.py | 281 +++++++++- .../src/zb_mom_ww_mxgateway_cli/commands.py | 105 +++- clients/python/tests/test_cli.py | 172 ++++++ .../tests/test_typed_command_helpers.py | 227 ++++++++ clients/rust/README.md | 34 +- clients/rust/crates/mxgw-cli/src/main.rs | 192 ++++++- clients/rust/src/session.rs | 366 +++++++++++- clients/rust/tests/client_behavior.rs | 187 ++++++- docs/ClientLibrariesDesign.md | 23 + 21 files changed, 3496 insertions(+), 97 deletions(-) create mode 100644 clients/go/mxgateway/session_parity_helpers_test.go create mode 100644 clients/python/tests/test_typed_command_helpers.py diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs index d4e3003..7119c85 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs @@ -1,18 +1,31 @@ namespace ZB.MOM.WW.MxGateway.Client.Cli; -/// Utility to redact API keys from error messages for safe output. +/// Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output. internal static class MxGatewayCliSecretRedactor { - /// Replaces occurrences of the API key in the value with a redacted placeholder. + /// + /// Replaces every occurrence of any supplied secret in the value with a + /// redacted placeholder. Null or empty secrets are ignored, so callers can + /// pass optional credentials without pre-filtering. + /// /// The message text to redact. - /// The API key to remove; no redaction if null or empty. - public static string Redact(string value, string? apiKey) + /// The secret values to remove (API key, verify-user password, secured payloads). + public static string Redact(string value, params string?[] secrets) { - if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) + if (string.IsNullOrEmpty(value) || secrets is null) { return value; } - return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal); + string redacted = value; + foreach (string? secret in secrets) + { + if (!string.IsNullOrEmpty(secret)) + { + redacted = redacted.Replace(secret, "[redacted]", StringComparison.Ordinal); + } + } + + return redacted; } } 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 e8d56a8..bb7250d 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -112,6 +112,24 @@ public static class MxGatewayClientCli .ConfigureAwait(false), "advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token) .ConfigureAwait(false), + "unregister" => await UnregisterAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "add-buffered-item" => await AddBufferedItemAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "set-buffered-update-interval" => await SetBufferedUpdateIntervalAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "suspend" => await SuspendAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "activate" => await ActivateAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "write-secured" => await WriteSecuredAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "write-secured2" => await WriteSecured2Async(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "authenticate-user" => await AuthenticateUserAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "archestra-user-to-id" => await ArchestraUserToIdAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), "subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token) .ConfigureAwait(false), "unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token) @@ -157,9 +175,15 @@ public static class MxGatewayClientCli { // Client.Dotnet-028: redact the *effective* key — from --api-key or the // --api-key-env environment variable — so an env-var-sourced key echoed - // in a transport error never reaches stderr unredacted. + // in a transport error never reaches stderr unredacted. CLI-04: also + // redact MXAccess credentials (AuthenticateUser password, WriteSecured + // payloads) that could otherwise be echoed back in a surfaced error. string? apiKey = TryResolveApiKey(arguments); - string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey); + string message = MxGatewayCliSecretRedactor.Redact( + exception.Message, + apiKey, + TryResolveVerifyUserPassword(arguments), + arguments.GetOptional("value")); if (forceJsonErrors || arguments.HasFlag("json")) { @@ -319,6 +343,48 @@ public static class MxGatewayClientCli return Environment.GetEnvironmentVariable(apiKeyEnvironmentName); } + /// + /// Resolves the effective MXAccess verify-user credential from + /// --verify-user-password or, failing that, the + /// --verify-user-password-env-named environment variable (default + /// MXGATEWAY_VERIFY_USER_PASSWORD). The credential is never echoed; + /// this resolver exists so the error-redaction catch block can strip it + /// from any surfaced error (CLI-04), mirroring . + /// + private static string? TryResolveVerifyUserPassword(CliArguments arguments) + { + string? password = arguments.GetOptional("verify-user-password"); + if (!string.IsNullOrEmpty(password)) + { + return password; + } + + string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env") + ?? "MXGATEWAY_VERIFY_USER_PASSWORD"; + + return Environment.GetEnvironmentVariable(passwordEnvironmentName); + } + + /// + /// Resolves the verify-user credential for authenticate-user, throwing + /// a redaction-safe error when neither the flag nor the env var is set. The + /// thrown message names only the option/env var, never the value. + /// + private static string ResolveVerifyUserPassword(CliArguments arguments) + { + string? password = TryResolveVerifyUserPassword(arguments); + if (!string.IsNullOrEmpty(password)) + { + return password; + } + + string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env") + ?? "MXGATEWAY_VERIFY_USER_PASSWORD"; + + throw new ArgumentException( + $"Verify-user password is required. Pass --verify-user-password or set {passwordEnvironmentName}."); + } + private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command) { var cancellation = new CancellationTokenSource(); @@ -475,6 +541,215 @@ public static class MxGatewayClientCli cancellationToken); } + private static Task UnregisterAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Unregister, + Unregister = new UnregisterCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + }, + }, + cancellationToken); + } + + private static Task AddBufferedItemAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.AddBufferedItem, + AddBufferedItem = new AddBufferedItemCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemDefinition = arguments.GetRequired("item"), + ItemContext = arguments.GetOptional("item-context") ?? string.Empty, + }, + }, + cancellationToken); + } + + private static Task SetBufferedUpdateIntervalAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.SetBufferedUpdateInterval, + SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + UpdateIntervalMilliseconds = arguments.GetInt32("interval-ms"), + }, + }, + cancellationToken); + } + + private static Task SuspendAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Suspend, + Suspend = new SuspendCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + }, + }, + cancellationToken); + } + + private static Task ActivateAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Activate, + Activate = new ActivateCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + }, + }, + cancellationToken); + } + + private static Task WriteSecuredAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + CurrentUserId = arguments.GetInt32("current-user-id"), + VerifierUserId = arguments.GetInt32("verifier-user-id", 0), + Value = ParseValue(arguments), + }, + }, + cancellationToken); + } + + private static Task WriteSecured2Async( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.WriteSecured2, + WriteSecured2 = new WriteSecured2Command + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + CurrentUserId = arguments.GetInt32("current-user-id"), + VerifierUserId = arguments.GetInt32("verifier-user-id", 0), + Value = ParseValue(arguments), + TimestampValue = ParseTimestampValue(arguments), + }, + }, + cancellationToken); + } + + private static Task AuthenticateUserAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + // The credential is resolved from --verify-user-password or its env var and + // is never echoed. On any surfaced error the RunCoreAsync catch block routes + // it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04). + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.AuthenticateUser, + AuthenticateUser = new AuthenticateUserCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + VerifyUser = arguments.GetRequired("verify-user"), + VerifyUserPassword = ResolveVerifyUserPassword(arguments), + }, + }, + cancellationToken); + } + + private static Task ArchestraUserToIdAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.ArchestraUserToId, + ArchestraUserToId = new ArchestrAUserToIdCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + UserIdGuid = arguments.GetRequired("user-guid"), + }, + }, + cancellationToken); + } + private static Task SubscribeBulkAsync( CliArguments arguments, IMxGatewayCliClient client, @@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli or "add-item" or "advise" or "advise-supervisory" + or "unregister" + or "add-buffered-item" + or "set-buffered-update-interval" + or "suspend" + or "activate" + or "write-secured" + or "write-secured2" + or "authenticate-user" + or "archestra-user-to-id" or "subscribe-bulk" or "unsubscribe-bulk" or "read-bulk" @@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli writer.WriteLine("mxgw-dotnet add-item --session-id --server-handle --item [--json]"); writer.WriteLine("mxgw-dotnet advise --session-id --server-handle --item-handle [--json]"); writer.WriteLine("mxgw-dotnet advise-supervisory --session-id --server-handle --item-handle [--json]"); + writer.WriteLine("mxgw-dotnet unregister --session-id --server-handle [--json]"); + writer.WriteLine("mxgw-dotnet add-buffered-item --session-id --server-handle --item [--item-context ] [--json]"); + writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id --server-handle --interval-ms [--json]"); + writer.WriteLine("mxgw-dotnet suspend --session-id --server-handle --item-handle [--json]"); + writer.WriteLine("mxgw-dotnet activate --session-id --server-handle --item-handle [--json]"); + writer.WriteLine("mxgw-dotnet write-secured --session-id --server-handle --item-handle --type --value --current-user-id [--verifier-user-id ] [--json]"); + writer.WriteLine("mxgw-dotnet write-secured2 --session-id --server-handle --item-handle --type --value --current-user-id [--verifier-user-id ] [--timestamp ] [--json]"); + writer.WriteLine("mxgw-dotnet authenticate-user --session-id --server-handle --verify-user (--verify-user-password | --verify-user-password-env ) [--json]"); + writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id --server-handle --user-guid [--json]"); writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id --server-handle --items [--json]"); writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id --server-handle --item-handles [--json]"); writer.WriteLine("mxgw-dotnet read-bulk --session-id --server-handle --items [--timeout-ms ] [--json]"); 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 bd39120..54642a7 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that write-secured builds a WriteSecured command with the value and user ids. + [Fact] + public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.InvokeReplies.Enqueue(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.WriteSecured, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "write-secured", + "--endpoint", "http://localhost:5000", + "--api-key", "test-api-key", + "--session-id", "session-fixture", + "--server-handle", "12", + "--item-handle", "34", + "--type", "int32", + "--value", "123", + "--current-user-id", "5", + "--verifier-user-id", "6", + "--json", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests); + Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind); + Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value); + Assert.Equal(5, request.Command.WriteSecured.CurrentUserId); + Assert.Equal(6, request.Command.WriteSecured.VerifierUserId); + Assert.Equal(string.Empty, error.ToString()); + } + + /// + /// Verifies that authenticate-user builds an AuthenticateUser command sourcing the + /// credential from the flag, and that the credential never appears in stdout/stderr. + /// + [Fact] + public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential() + { + const string password = "cli-secret-credential-987"; + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.InvokeReplies.Enqueue(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AuthenticateUser, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + AuthenticateUser = new AuthenticateUserReply { UserId = 4242 }, + }); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "authenticate-user", + "--endpoint", "http://localhost:5000", + "--api-key", "test-api-key", + "--session-id", "session-fixture", + "--server-handle", "12", + "--verify-user", "operator", + "--verify-user-password", password, + "--json", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests); + Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind); + Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser); + Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword); + Assert.DoesNotContain(password, output.ToString()); + Assert.DoesNotContain(password, error.ToString()); + } + + /// + /// CLI-04: a surfaced error for authenticate-user must have the credential + /// redacted (never echoed to stderr), mirroring the API-key redaction seam. + /// + [Fact] + public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential() + { + const string password = "leaky-credential-value"; + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "authenticate-user", + "--endpoint", "http://localhost:5000", + "--api-key", "test-api-key", + "--session-id", "session-fixture", + "--server-handle", "12", + "--verify-user", "operator", + "--verify-user-password", password, + ], + output, + error, + _ => throw new InvalidOperationException($"boom {password}")); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain(password, error.ToString()); + Assert.Contains("[redacted]", error.ToString()); + } + /// Verifies that error output redacts sensitive API key values. [Fact] public async Task RunAsync_ErrorOutput_RedactsApiKey() diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs index 1de4290..0b83237 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs @@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(7, el.Value.Int32Value); } + /// Verifies that unregister builds an unregister command with the server handle. + [Fact] + public async Task UnregisterAsync_BuildsUnregisterCommand() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Unregister, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + await session.UnregisterAsync(12); + + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.Unregister, request.Command.Kind); + Assert.Equal(12, request.Command.Unregister.ServerHandle); + } + + /// Verifies that advise-supervisory builds the supervisory advise command. + [Fact] + public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AdviseSupervisory, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + await session.AdviseSupervisoryAsync(12, 34); + + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.AdviseSupervisory, request.Command.Kind); + Assert.Equal(12, request.Command.AdviseSupervisory.ServerHandle); + Assert.Equal(34, request.Command.AdviseSupervisory.ItemHandle); + } + + /// Verifies that add-buffered-item returns the item handle from the typed reply. + [Fact] + public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AddBufferedItem, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + AddBufferedItem = new AddBufferedItemReply { ItemHandle = 77 }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + int itemHandle = await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime"); + + Assert.Equal(77, itemHandle); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.AddBufferedItem, request.Command.Kind); + Assert.Equal(12, request.Command.AddBufferedItem.ServerHandle); + Assert.Equal("Area001.Pump001.Speed", request.Command.AddBufferedItem.ItemDefinition); + Assert.Equal("runtime", request.Command.AddBufferedItem.ItemContext); + } + + /// Verifies that set-buffered-update-interval builds the command with the interval. + [Fact] + public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.SetBufferedUpdateInterval, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + await session.SetBufferedUpdateIntervalAsync(12, 500); + + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.SetBufferedUpdateInterval, request.Command.Kind); + Assert.Equal(12, request.Command.SetBufferedUpdateInterval.ServerHandle); + Assert.Equal(500, request.Command.SetBufferedUpdateInterval.UpdateIntervalMilliseconds); + } + + /// Verifies that suspend builds the command and returns the reply status. + [Fact] + public async Task SuspendAsync_BuildsCommandAndReturnsStatus() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Suspend, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Suspend = new SuspendReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxStatusProxy? status = await session.SuspendAsync(12, 34); + + Assert.NotNull(status); + Assert.Equal(1, status!.Success); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.Suspend, request.Command.Kind); + Assert.Equal(12, request.Command.Suspend.ServerHandle); + Assert.Equal(34, request.Command.Suspend.ItemHandle); + } + + /// Verifies that activate builds the command and returns the reply status. + [Fact] + public async Task ActivateAsync_BuildsCommandAndReturnsStatus() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Activate, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + Activate = new ActivateReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxStatusProxy? status = await session.ActivateAsync(12, 34); + + Assert.NotNull(status); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.Activate, request.Command.Kind); + Assert.Equal(34, request.Command.Activate.ItemHandle); + } + + /// Verifies that write-secured builds a WriteSecured command with value and user ids. + [Fact] + public async Task WriteSecuredAsync_BuildsWriteSecuredCommand() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.WriteSecured, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + MxValue value = 123.ToMxValue(); + + await session.WriteSecuredAsync(12, 34, value, currentUserId: 5, verifierUserId: 6); + + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind); + Assert.Equal(12, request.Command.WriteSecured.ServerHandle); + Assert.Equal(34, request.Command.WriteSecured.ItemHandle); + Assert.Same(value, request.Command.WriteSecured.Value); + Assert.Equal(5, request.Command.WriteSecured.CurrentUserId); + Assert.Equal(6, request.Command.WriteSecured.VerifierUserId); + } + + /// + /// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails + /// natively. The client must surface that scripted failure (as an + /// ) rather than pre-validating it away. + /// + [Fact] + public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.WriteSecured, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure }, + Hresult = unchecked((int)0x80040200), + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxAccessException exception = await Assert.ThrowsAsync( + async () => await session.WriteSecuredAsync(12, 34, 123.ToMxValue(), currentUserId: 0, verifierUserId: 0)); + + // The native HRESULT is surfaced; the request payload is never in the message. + Assert.Contains("WriteSecured", exception.Message); + Assert.Single(transport.InvokeCalls); + } + + /// Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp. + [Fact] + public async Task WriteSecured2Async_BuildsWriteSecured2Command() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.WriteSecured2, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + MxValue value = 123.ToMxValue(); + MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue(); + + await session.WriteSecured2Async(12, 34, value, timestampValue, currentUserId: 5, verifierUserId: 6); + + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.WriteSecured2, request.Command.Kind); + Assert.Same(value, request.Command.WriteSecured2.Value); + Assert.Same(timestampValue, request.Command.WriteSecured2.TimestampValue); + Assert.Equal(5, request.Command.WriteSecured2.CurrentUserId); + Assert.Equal(6, request.Command.WriteSecured2.VerifierUserId); + } + + /// Verifies that authenticate-user builds the command and returns the resolved user id. + [Fact] + public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AuthenticateUser, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + AuthenticateUser = new AuthenticateUserReply { UserId = 4242 }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + int userId = await session.AuthenticateUserAsync(12, "operator", "s3cr3t-p@ss"); + + Assert.Equal(4242, userId); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind); + Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser); + Assert.Equal("s3cr3t-p@ss", request.Command.AuthenticateUser.VerifyUserPassword); + } + + /// + /// SECRET REDACTION: when AuthenticateUser fails, the surfaced exception message + /// must never contain the credential — the error path is built only from + /// reply-derived diagnostics, not the request payload. + /// + [Fact] + public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage() + { + const string password = "super-secret-credential-123"; + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AuthenticateUser, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure }, + Hresult = unchecked((int)0x80040210), + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxAccessException exception = await Assert.ThrowsAsync( + async () => await session.AuthenticateUserAsync(12, "operator", password)); + + Assert.DoesNotContain(password, exception.Message); + Assert.DoesNotContain(password, exception.ToString()); + } + + /// Verifies that archestra-user-to-id builds the command and returns the resolved user id. + [Fact] + public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.ArchestraUserToId, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + ArchestraUserToId = new ArchestrAUserToIdReply { UserId = 909 }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + int userId = await session.ArchestraUserToIdAsync(12, "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); + + Assert.Equal(909, userId); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.ArchestraUserToId, request.Command.Kind); + Assert.Equal("BCC47053-9542-4D65-BDAA-BCDEA6A32A73", request.Command.ArchestraUserToId.UserIdGuid); + } + private static MxGatewayClient CreateClient(FakeGatewayTransport transport) { return new MxGatewayClient(transport.Options, transport); diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs index 1740a96..a8ca738 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs @@ -842,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Unregisters a previously registered client from the MXAccess session + /// (MXAccess Unregister), releasing its ServerHandle. + /// + /// The ServerHandle from register. + /// Cancellation token. + public async Task UnregisterAsync( + int serverHandle, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + /// + /// Unregisters a previously registered client without error checking. + /// + /// The ServerHandle from register. + /// Cancellation token. + /// The raw server reply. + public Task UnregisterRawAsync( + int serverHandle, + CancellationToken cancellationToken = default) + { + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.Unregister, + Unregister = new UnregisterCommand { ServerHandle = serverHandle }, + }, + cancellationToken); + } + + /// + /// Subscribes to supervisory events for an item (MXAccess AdviseSupervisory). + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + public async Task AdviseSupervisoryAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + /// + /// Subscribes to supervisory events for an item without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. + public Task AdviseSupervisoryRawAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.AdviseSupervisory, + AdviseSupervisory = new AdviseSupervisoryCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + cancellationToken); + } + + /// + /// Adds a buffered item to the MXAccess session (MXAccess AddBufferedItem), + /// returning an ItemHandle. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Additional context for the item. + /// Cancellation token. + /// The item handle assigned to the new buffered item. + public async Task AddBufferedItemAsync( + int serverHandle, + string itemDefinition, + string itemContext, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await AddBufferedItemRawAsync( + serverHandle, + itemDefinition, + itemContext, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply.AddBufferedItem?.ItemHandle ?? reply.ReturnValue.Int32Value; + } + + /// + /// Adds a buffered item to the MXAccess session without error checking. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Additional context for the item. + /// Cancellation token. + /// The raw server reply. + public Task AddBufferedItemRawAsync( + int serverHandle, + string itemDefinition, + string itemContext, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.AddBufferedItem, + AddBufferedItem = new AddBufferedItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = itemDefinition, + ItemContext = itemContext ?? string.Empty, + }, + }, + cancellationToken); + } + + /// + /// Sets the buffered-item update interval on the MXAccess session + /// (MXAccess SetBufferedUpdateInterval). + /// + /// The ServerHandle from register. + /// The buffered update interval, in milliseconds. + /// Cancellation token. + public async Task SetBufferedUpdateIntervalAsync( + int serverHandle, + int updateIntervalMilliseconds, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync( + serverHandle, + updateIntervalMilliseconds, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + /// + /// Sets the buffered-item update interval without error checking. + /// + /// The ServerHandle from register. + /// The buffered update interval, in milliseconds. + /// Cancellation token. + /// The raw server reply. + public Task SetBufferedUpdateIntervalRawAsync( + int serverHandle, + int updateIntervalMilliseconds, + CancellationToken cancellationToken = default) + { + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.SetBufferedUpdateInterval, + SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand + { + ServerHandle = serverHandle, + UpdateIntervalMilliseconds = updateIntervalMilliseconds, + }, + }, + cancellationToken); + } + + /// + /// Suspends updates for an item (MXAccess Suspend). + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The item's MXSTATUS_PROXY as reported by the worker, or null if the reply omitted it. + public async Task SuspendAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply.Suspend?.Status; + } + + /// + /// Suspends updates for an item without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. + public Task SuspendRawAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.Suspend, + Suspend = new SuspendCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + cancellationToken); + } + + /// + /// Resumes updates for a suspended item (MXAccess Activate). + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The item's MXSTATUS_PROXY as reported by the worker, or null if the reply omitted it. + public async Task ActivateAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply.Activate?.Status; + } + + /// + /// Resumes updates for a suspended item without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. + public Task ActivateRawAsync( + int serverHandle, + int itemHandle, + CancellationToken cancellationToken = default) + { + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.Activate, + Activate = new ActivateCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }, + cancellationToken); + } + + /// + /// Writes a secured value to an item on the MXAccess server (MXAccess WriteSecured). + /// + /// + /// MXAccess parity: WriteSecured fails when it is issued before a value-bearing + /// NMX body or before a prior AuthenticateUser + AdviseSupervisory. That + /// native failure is surfaced unchanged — the client does not pre-validate or reorder it. + /// The is credential-sensitive and must never reach logs; the + /// client mirrors the single-item WriteSecured redaction contract. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The secured value to write. + /// The current operator user id. + /// The verifier (secondary approver) user id. + /// Cancellation token. + public async Task WriteSecuredAsync( + int serverHandle, + int itemHandle, + MxValue value, + int currentUserId, + int verifierUserId, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await WriteSecuredRawAsync( + serverHandle, + itemHandle, + value, + currentUserId, + verifierUserId, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + /// + /// Writes a secured value to an item without error checking. See + /// for the parity and redaction contract. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The secured value to write. + /// The current operator user id. + /// The verifier (secondary approver) user id. + /// Cancellation token. + /// The raw server reply. + public Task WriteSecuredRawAsync( + int serverHandle, + int itemHandle, + MxValue value, + int currentUserId, + int verifierUserId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = value, + }, + }, + cancellationToken); + } + + /// + /// Writes a secured value and timestamp to an item (MXAccess WriteSecured2). + /// + /// + /// Same parity and redaction contract as : the native + /// failure that occurs before a value-bearing NMX body or a prior authenticate is + /// surfaced unchanged, and the credential-sensitive must never + /// reach logs. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The secured value to write. + /// The timestamp to write with the value. + /// The current operator user id. + /// The verifier (secondary approver) user id. + /// Cancellation token. + public async Task WriteSecured2Async( + int serverHandle, + int itemHandle, + MxValue value, + MxValue timestampValue, + int currentUserId, + int verifierUserId, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await WriteSecured2RawAsync( + serverHandle, + itemHandle, + value, + timestampValue, + currentUserId, + verifierUserId, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + /// + /// Writes a secured value and timestamp to an item without error checking. See + /// for the parity and redaction contract. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The secured value to write. + /// The timestamp to write with the value. + /// The current operator user id. + /// The verifier (secondary approver) user id. + /// Cancellation token. + /// The raw server reply. + public Task WriteSecured2RawAsync( + int serverHandle, + int itemHandle, + MxValue value, + MxValue timestampValue, + int currentUserId, + int verifierUserId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(timestampValue); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.WriteSecured2, + WriteSecured2 = new WriteSecured2Command + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + CurrentUserId = currentUserId, + VerifierUserId = verifierUserId, + Value = value, + TimestampValue = timestampValue, + }, + }, + cancellationToken); + } + + /// + /// Authenticates an MXAccess verify-user (MXAccess AuthenticateUser), returning + /// the resolved user id used by WriteSecured / WriteSecured2. + /// + /// + /// The is a raw MXAccess credential. It is never + /// logged and never placed on the exception path: gateway/MXAccess failures surface only + /// reply-derived diagnostics (kind, HRESULT, MXSTATUS_PROXY), never the request payload. + /// + /// The ServerHandle from register. + /// The user to verify. + /// The verify-user credential. Never logged. + /// Cancellation token. + /// The authenticated user id. + public async Task AuthenticateUserAsync( + int serverHandle, + string verifyUser, + string verifyUserPassword, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await AuthenticateUserRawAsync( + serverHandle, + verifyUser, + verifyUserPassword, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply.AuthenticateUser?.UserId ?? reply.ReturnValue.Int32Value; + } + + /// + /// Authenticates an MXAccess verify-user without error checking. See + /// for the credential-handling contract. + /// + /// The ServerHandle from register. + /// The user to verify. + /// The verify-user credential. Never logged. + /// Cancellation token. + /// The raw server reply. + public Task AuthenticateUserRawAsync( + int serverHandle, + string verifyUser, + string verifyUserPassword, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(verifyUser); + ArgumentNullException.ThrowIfNull(verifyUserPassword); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.AuthenticateUser, + AuthenticateUser = new AuthenticateUserCommand + { + ServerHandle = serverHandle, + VerifyUser = verifyUser, + VerifyUserPassword = verifyUserPassword, + }, + }, + cancellationToken); + } + + /// + /// Resolves an ArchestrA user GUID to its MXAccess user id + /// (MXAccess ArchestrAUserToId). + /// + /// The ServerHandle from register. + /// The ArchestrA user GUID to resolve. + /// Cancellation token. + /// The resolved MXAccess user id. + public async Task ArchestraUserToIdAsync( + int serverHandle, + string userIdGuid, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await ArchestraUserToIdRawAsync(serverHandle, userIdGuid, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply.ArchestraUserToId?.UserId ?? reply.ReturnValue.Int32Value; + } + + /// + /// Resolves an ArchestrA user GUID to its MXAccess user id without error checking. + /// + /// The ServerHandle from register. + /// The ArchestrA user GUID to resolve. + /// Cancellation token. + /// The raw server reply. + public Task ArchestraUserToIdRawAsync( + int serverHandle, + string userIdGuid, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userIdGuid); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.ArchestraUserToId, + ArchestraUserToId = new ArchestrAUserToIdCommand + { + ServerHandle = serverHandle, + UserIdGuid = userIdGuid, + }, + }, + cancellationToken); + } + /// /// Invokes an MXAccess command on this session. /// diff --git a/clients/go/README.md b/clients/go/README.md index 2adfec4..c76cbbe 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -84,7 +84,9 @@ true` to verify against the OS/system trust roots without pinning. See [Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). `Client.OpenSession` returns a `Session` with helpers for `Register`, -`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer +`AddItem`, `AddItem2`, `Advise`, `AdviseSupervisory`, `Write`, `WriteSecured`, +`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, +`SetBufferedUpdateInterval`, `Suspend`, `Activate`, `Events`, and `Close`. Prefer `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the returned subscription owns cancellation and exposes `Close` for deterministic goroutine cleanup. Raw protobuf messages remain available through the @@ -151,29 +153,32 @@ still need the write attributed to a user id, you must first advise the item supervisory and then pass that user id on the write. Without the supervisory advise the `userID` on a plain write is ignored. -The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it -through the generic command channel: +The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`: ```go -_, err := client.Invoke(ctx, &pb.MxCommandRequest{ - SessionId: session.ID(), - Command: &pb.MxCommand{ - Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY, - Payload: &pb.MxCommand_AdviseSupervisory{ - AdviseSupervisory: &pb.AdviseSupervisoryCommand{ - ServerHandle: serverHandle, - ItemHandle: itemHandle, - }, - }, - }, -}) - +err := session.AdviseSupervisory(ctx, serverHandle, itemHandle) +// ... err = session.Write(ctx, serverHandle, itemHandle, value, userID) ``` The CLI exposes the same command as `advise-supervisory`, and `write` takes `-user-id`. +### Secured writes and user authentication + +The verified/secured path has typed single-item helpers too: +`AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword)` returns the +resolved MXAccess user id, and `WriteSecured` / `WriteSecured2` issue the secured +write. Credentials passed to `AuthenticateUser`, and the string content of a +`WriteSecured`/`WriteSecured2` value, are kept out of any error the client +surfaces (they route through the same redaction seam as the API key) and are +never logged — callers must likewise keep them out of their own logs. MXAccess +parity holds: a `WriteSecured` issued without a matching prior `AuthenticateUser` +and supervisory advise fails natively, and that failure is surfaced unchanged +rather than pre-empted. The CLI exposes `authenticate-user` (credential via +`-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, or `-password`) and +`write-secured`. + ### Array writes replace the whole array A write to an array attribute **replaces the entire array**; it is not an @@ -378,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags | `unsubscribe-bulk` | Unadvise many item handles in one call. | | `read-bulk` | Read snapshots for many item handles in one call. | | `write` | Write one value (`-type`, `-value`). | +| `write-secured` | Secured single-item write (`-current-user-id`, `-verifier-user-id`, `-type`, `-value`). | +| `authenticate-user` | Authenticate a user, printing the resolved user id (`-verify-user`, `-password-env`/`-password`). | | `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). | | `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). | | `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). | diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 4d8e4c8..3a25ade 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -21,7 +21,6 @@ import ( "syscall" "time" - pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/reflect/protoreflect" @@ -90,6 +89,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err return runAdvise(ctx, args[1:], stdout, stderr) case "advise-supervisory": return runAdviseSupervisory(ctx, args[1:], stdout, stderr) + case "write-secured": + return runWriteSecured(ctx, args[1:], stdout, stderr) + case "authenticate-user": + return runAuthenticateUser(ctx, args[1:], stdout, stderr) case "subscribe-bulk": return runSubscribeBulk(ctx, args[1:], stdout, stderr) case "unsubscribe-bulk": @@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io. } defer client.Close() - reply, err := client.Invoke(ctx, &pb.MxCommandRequest{ - SessionId: *sessionID, - Command: &pb.MxCommand{ - Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY, - Payload: &pb.MxCommand_AdviseSupervisory{ - AdviseSupervisory: &pb.AdviseSupervisoryCommand{ - ServerHandle: int32(*serverHandle), - ItemHandle: int32(*itemHandle), - }, - }, - }, - }) + session := mxgateway.NewSessionForID(client, *sessionID) + reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle)) return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err) } +func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("write-secured", flag.ContinueOnError) + flags.SetOutput(stderr) + common := bindCommonFlags(flags) + jsonOutput := flags.Bool("json", false, "write JSON output") + sessionID := flags.String("session-id", "", "gateway session id") + serverHandle := flags.Int("server-handle", 0, "MXAccess server handle") + itemHandle := flags.Int("item-handle", 0, "MXAccess item handle") + currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id") + verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id") + valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string") + valueText := flags.String("value", "", "value text") + + if err := flags.Parse(args); err != nil { + return err + } + if *sessionID == "" { + return errors.New("session-id is required") + } + + value, err := parseValue(*valueType, *valueText) + if err != nil { + return err + } + + client, options, err := dialForCommand(ctx, common) + if err != nil { + return err + } + defer client.Close() + + session := mxgateway.NewSessionForID(client, *sessionID) + reply, err := session.WriteSecuredRaw(ctx, int32(*serverHandle), int32(*itemHandle), int32(*currentUserID), int32(*verifierUserID), value) + return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err) +} + +func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError) + flags.SetOutput(stderr) + common := bindCommonFlags(flags) + jsonOutput := flags.Bool("json", false, "write JSON output") + sessionID := flags.String("session-id", "", "gateway session id") + serverHandle := flags.Int("server-handle", 0, "MXAccess server handle") + verifyUser := flags.String("verify-user", "", "MXAccess user to authenticate") + // The credential is never accepted echoed on the command line by default: + // prefer the environment variable so it stays out of shell history and the + // process table. The -password flag remains for non-interactive scripting. + password := flags.String("password", "", "verify-user password (prefer -password-env)") + passwordEnv := flags.String("password-env", "MXGATEWAY_VERIFY_PASSWORD", "environment variable containing the verify-user password") + + if err := flags.Parse(args); err != nil { + return err + } + if *sessionID == "" { + return errors.New("session-id is required") + } + if *verifyUser == "" { + return errors.New("verify-user is required") + } + + resolvedPassword := *password + if resolvedPassword == "" && *passwordEnv != "" { + resolvedPassword = os.Getenv(*passwordEnv) + } + + client, options, err := dialForCommand(ctx, common) + if err != nil { + return err + } + defer client.Close() + + session := mxgateway.NewSessionForID(client, *sessionID) + // The raw reply carries only the resolved user id, never the credential, so + // writeCommandOutput can render it as-is; the credential is additionally + // scrubbed from any surfaced error by AuthenticateUserRaw. + reply, err := session.AuthenticateUserRaw(ctx, int32(*serverHandle), *verifyUser, resolvedPassword) + return writeCommandOutput(stdout, *jsonOutput, "authenticate-user", options, reply, err) +} + func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError) flags.SetOutput(stderr) @@ -1295,7 +1367,7 @@ type protojsonMessage interface { } func writeUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: mxgw-go ") + fmt.Fprintln(writer, "usage: mxgw-go ") } // batchEOR is the end-of-result sentinel emitted to stdout after every command diff --git a/clients/go/cmd/mxgw-go/main_test.go b/clients/go/cmd/mxgw-go/main_test.go index f78879b..b3102d8 100644 --- a/clients/go/cmd/mxgw-go/main_test.go +++ b/clients/go/cmd/mxgw-go/main_test.go @@ -568,6 +568,36 @@ func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) { } } +// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so +// it fails fast before dialing. +func TestRunWriteSecuredRequiresSessionID(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "session-id is required") { + t.Fatalf("write-secured without -session-id error = %v", err) + } +} + +// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast +// (before dialing) when the user is absent, and never echoes any credential in +// the guard error. +func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runWithIO(t.Context(), []string{ + "authenticate-user", + "-session-id", "s1", + "-password", "hunter2-password", + "-plaintext", + "-api-key", "test", + }, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "verify-user is required") { + t.Fatalf("authenticate-user without -verify-user error = %v", err) + } + if strings.Contains(err.Error(), "hunter2-password") { + t.Fatalf("authenticate-user guard error leaked the credential: %v", err) + } +} + // TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch // guard so a write-bulk with unequal item-handles / values counts fails fast // before any dial. diff --git a/clients/go/mxgateway/errors.go b/clients/go/mxgateway/errors.go index 778e56e..572716d 100644 --- a/clients/go/mxgateway/errors.go +++ b/clients/go/mxgateway/errors.go @@ -3,10 +3,68 @@ package mxgateway import ( "errors" "fmt" + "strings" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" ) +// redactedSecretMarker is the placeholder substituted for credential material in +// surfaced error text. It matches the marker used by RedactAPIKey so the client +// presents one consistent redaction shape everywhere secrets could otherwise +// leak. +const redactedSecretMarker = "" + +// secretRedactingError wraps a typed error so any occurrence of a known +// credential in the underlying message is replaced with redactedSecretMarker in +// the surfaced text. Unwrap still exposes the wrapped error, so errors.As / +// errors.Is continue to reach the underlying MxAccessError, CommandError, or +// GatewayError. This is the seam that keeps AuthenticateUser credentials and +// WriteSecured/WriteSecured2 payload strings out of any error a caller might log, +// even if a gateway diagnostic message were to echo them back. +type secretRedactingError struct { + err error + secrets []string +} + +// Error returns the wrapped error's message with every non-empty secret redacted. +func (e *secretRedactingError) Error() string { + if e == nil || e.err == nil { + return "" + } + message := e.err.Error() + for _, secret := range e.secrets { + if secret != "" { + message = strings.ReplaceAll(message, secret, redactedSecretMarker) + } + } + return message +} + +// Unwrap returns the wrapped error so typed-error inspection still works through +// the redaction wrapper. +func (e *secretRedactingError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced +// message is redacted, while errors.As / errors.Is still reach the wrapped typed +// error. It returns nil unchanged and skips wrapping when no non-empty secret is +// supplied, so non-secret-bearing calls keep their original error verbatim. +func redactSecrets(err error, secrets ...string) error { + if err == nil { + return nil + } + for _, secret := range secrets { + if secret != "" { + return &secretRedactingError{err: err, secrets: secrets} + } + } + return err +} + // ErrSlowConsumer is the terminal error sent on the Events/EventsAfter // (cancel-when-full) path when the buffered results channel overflows because // the consumer fell behind. It is delivered as the final EventResult.Err before diff --git a/clients/go/mxgateway/session.go b/clients/go/mxgateway/session.go index c58e00f..7e920aa 100644 --- a/clients/go/mxgateway/session.go +++ b/clients/go/mxgateway/session.go @@ -706,6 +706,277 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32, }) } +// AdviseSupervisory invokes MXAccess AdviseSupervisory, advising an item on the +// supervisory (as opposed to runtime) data path. +func (s *Session) AdviseSupervisory(ctx context.Context, serverHandle, itemHandle int32) error { + _, err := s.AdviseSupervisoryRaw(ctx, serverHandle, itemHandle) + return err +} + +// AdviseSupervisoryRaw invokes MXAccess AdviseSupervisory and returns the raw reply. +func (s *Session) AdviseSupervisoryRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) { + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY, + Payload: &pb.MxCommand_AdviseSupervisory{ + AdviseSupervisory: &pb.AdviseSupervisoryCommand{ + ServerHandle: serverHandle, + ItemHandle: itemHandle, + }, + }, + }) +} + +// WriteSecured invokes MXAccess WriteSecured (secured single-item write). +// +// The value is credential-sensitive: callers must not log it, and any error this +// call surfaces has the value's string content redacted (see WriteSecuredRaw). +// MXAccess parity is preserved — WriteSecured legitimately fails when it is not +// preceded by a matching AuthenticateUser + AdviseSupervisory or when the body +// carries no value; that native failure is surfaced as-is, never pre-empted or +// reordered by this client. +func (s *Session) WriteSecured(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) error { + _, err := s.WriteSecuredRaw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value) + return err +} + +// WriteSecuredRaw invokes MXAccess WriteSecured and returns the raw reply. Any +// surfaced error is routed through the client's secret-redaction seam so a string +// write value never appears in error text. +func (s *Session) WriteSecuredRaw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) (*MxCommandReply, error) { + if value == nil { + return nil, errors.New("mxgateway: write-secured value is required") + } + + reply, err := s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED, + Payload: &pb.MxCommand_WriteSecured{ + WriteSecured: &pb.WriteSecuredCommand{ + ServerHandle: serverHandle, + ItemHandle: itemHandle, + CurrentUserId: currentUserID, + VerifierUserId: verifierUserID, + Value: value, + }, + }, + }) + return reply, redactSecrets(err, stringSecrets(value)...) +} + +// WriteSecured2 invokes MXAccess WriteSecured2 (secured, timestamped single-item write). +// +// Like WriteSecured, the value is credential-sensitive and its string content is +// scrubbed from any surfaced error. Native parity failures (missing prior +// AuthenticateUser/AdviseSupervisory, value-less body) are surfaced unchanged. +func (s *Session) WriteSecured2(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) error { + _, err := s.WriteSecured2Raw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value, timestampValue) + return err +} + +// WriteSecured2Raw invokes MXAccess WriteSecured2 and returns the raw reply. Any +// surfaced error is routed through the client's secret-redaction seam. +func (s *Session) WriteSecured2Raw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) (*MxCommandReply, error) { + if value == nil { + return nil, errors.New("mxgateway: write-secured2 value is required") + } + if timestampValue == nil { + return nil, errors.New("mxgateway: write-secured2 timestamp value is required") + } + + reply, err := s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2, + Payload: &pb.MxCommand_WriteSecured2{ + WriteSecured2: &pb.WriteSecured2Command{ + ServerHandle: serverHandle, + ItemHandle: itemHandle, + CurrentUserId: currentUserID, + VerifierUserId: verifierUserID, + Value: value, + TimestampValue: timestampValue, + }, + }, + }) + return reply, redactSecrets(err, stringSecrets(value)...) +} + +// AuthenticateUser invokes MXAccess AuthenticateUser and returns the resolved +// MXAccess user id. +// +// verifyUserPassword is a raw MXAccess credential: this client never logs it and +// scrubs it from any error it surfaces (see AuthenticateUserRaw). Callers must +// likewise keep it out of their own logs, metrics, and diagnostics. +func (s *Session) AuthenticateUser(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (int32, error) { + reply, err := s.AuthenticateUserRaw(ctx, serverHandle, verifyUser, verifyUserPassword) + if err != nil { + return 0, err + } + if reply.GetAuthenticateUser() != nil { + return reply.GetAuthenticateUser().GetUserId(), nil + } + return reply.GetReturnValue().GetInt32Value(), nil +} + +// AuthenticateUserRaw invokes MXAccess AuthenticateUser and returns the raw +// reply. The credential is scrubbed from any surfaced error via the client's +// secret-redaction seam, so even a gateway diagnostic echoing the password back +// cannot leak it through this call's error. +func (s *Session) AuthenticateUserRaw(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (*MxCommandReply, error) { + if verifyUser == "" { + return nil, errors.New("mxgateway: verify user is required") + } + + reply, err := s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER, + Payload: &pb.MxCommand_AuthenticateUser{ + AuthenticateUser: &pb.AuthenticateUserCommand{ + ServerHandle: serverHandle, + VerifyUser: verifyUser, + VerifyUserPassword: verifyUserPassword, + }, + }, + }) + return reply, redactSecrets(err, verifyUserPassword) +} + +// ArchestrAUserToId invokes MXAccess ArchestrAUserToId, resolving an ArchestrA +// user GUID to its MXAccess integer user id. +func (s *Session) ArchestrAUserToId(ctx context.Context, serverHandle int32, userIDGuid string) (int32, error) { + reply, err := s.ArchestrAUserToIdRaw(ctx, serverHandle, userIDGuid) + if err != nil { + return 0, err + } + if reply.GetArchestraUserToId() != nil { + return reply.GetArchestraUserToId().GetUserId(), nil + } + return reply.GetReturnValue().GetInt32Value(), nil +} + +// ArchestrAUserToIdRaw invokes MXAccess ArchestrAUserToId and returns the raw reply. +func (s *Session) ArchestrAUserToIdRaw(ctx context.Context, serverHandle int32, userIDGuid string) (*MxCommandReply, error) { + if userIDGuid == "" { + return nil, errors.New("mxgateway: user id GUID is required") + } + + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, + Payload: &pb.MxCommand_ArchestraUserToId{ + ArchestraUserToId: &pb.ArchestrAUserToIdCommand{ + ServerHandle: serverHandle, + UserIdGuid: userIDGuid, + }, + }, + }) +} + +// AddBufferedItem invokes MXAccess AddBufferedItem and returns the item handle. +func (s *Session) AddBufferedItem(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) { + reply, err := s.AddBufferedItemRaw(ctx, serverHandle, itemDefinition, itemContext) + if err != nil { + return 0, err + } + if reply.GetAddBufferedItem() != nil { + return reply.GetAddBufferedItem().GetItemHandle(), nil + } + return reply.GetReturnValue().GetInt32Value(), nil +} + +// AddBufferedItemRaw invokes MXAccess AddBufferedItem and returns the raw reply. +func (s *Session) AddBufferedItemRaw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) { + if itemDefinition == "" { + return nil, errors.New("mxgateway: item definition is required") + } + + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_BUFFERED_ITEM, + Payload: &pb.MxCommand_AddBufferedItem{ + AddBufferedItem: &pb.AddBufferedItemCommand{ + ServerHandle: serverHandle, + ItemDefinition: itemDefinition, + ItemContext: itemContext, + }, + }, + }) +} + +// SetBufferedUpdateInterval invokes MXAccess SetBufferedUpdateInterval. +func (s *Session) SetBufferedUpdateInterval(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) error { + _, err := s.SetBufferedUpdateIntervalRaw(ctx, serverHandle, updateIntervalMilliseconds) + return err +} + +// SetBufferedUpdateIntervalRaw invokes MXAccess SetBufferedUpdateInterval and returns the raw reply. +func (s *Session) SetBufferedUpdateIntervalRaw(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) (*MxCommandReply, error) { + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL, + Payload: &pb.MxCommand_SetBufferedUpdateInterval{ + SetBufferedUpdateInterval: &pb.SetBufferedUpdateIntervalCommand{ + ServerHandle: serverHandle, + UpdateIntervalMilliseconds: updateIntervalMilliseconds, + }, + }, + }) +} + +// Suspend invokes MXAccess Suspend and returns the resulting item status. +func (s *Session) Suspend(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) { + reply, err := s.SuspendRaw(ctx, serverHandle, itemHandle) + if err != nil { + return nil, err + } + return reply.GetSuspend().GetStatus(), nil +} + +// SuspendRaw invokes MXAccess Suspend and returns the raw reply. +func (s *Session) SuspendRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) { + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND, + Payload: &pb.MxCommand_Suspend{ + Suspend: &pb.SuspendCommand{ + ServerHandle: serverHandle, + ItemHandle: itemHandle, + }, + }, + }) +} + +// Activate invokes MXAccess Activate and returns the resulting item status. +func (s *Session) Activate(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) { + reply, err := s.ActivateRaw(ctx, serverHandle, itemHandle) + if err != nil { + return nil, err + } + return reply.GetActivate().GetStatus(), nil +} + +// ActivateRaw invokes MXAccess Activate and returns the raw reply. +func (s *Session) ActivateRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) { + return s.invokeCommand(ctx, &pb.MxCommand{ + Kind: pb.MxCommandKind_MX_COMMAND_KIND_ACTIVATE, + Payload: &pb.MxCommand_Activate{ + Activate: &pb.ActivateCommand{ + ServerHandle: serverHandle, + ItemHandle: itemHandle, + }, + }, + }) +} + +// stringSecrets collects the non-empty string content of the given values so it +// can be scrubbed from surfaced errors. Only string-typed MxValues carry +// scrubable text; non-string values (numbers, timestamps, arrays) contribute +// nothing. +func stringSecrets(values ...*MxValue) []string { + var secrets []string + for _, value := range values { + if value == nil { + continue + } + if stringValue, ok := value.GetKind().(*pb.MxValue_StringValue); ok && stringValue.StringValue != "" { + secrets = append(secrets, stringValue.StringValue) + } + } + return secrets +} + // Events streams ordered session events until the server ends the stream, // context cancellation stops Recv, or a terminal error is sent. // diff --git a/clients/go/mxgateway/session_parity_helpers_test.go b/clients/go/mxgateway/session_parity_helpers_test.go new file mode 100644 index 0000000..79bae27 --- /dev/null +++ b/clients/go/mxgateway/session_parity_helpers_test.go @@ -0,0 +1,224 @@ +package mxgateway + +import ( + "context" + "errors" + "strings" + "testing" + + pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" +) + +// TestAdviseSupervisoryBuildsCommandAndExposesRawReply pins that the promoted +// typed helper emits an ADVISE_SUPERVISORY command carrying the server/item +// handles and returns the raw reply. +func TestAdviseSupervisoryBuildsCommandAndExposesRawReply(t *testing.T) { + fake := &fakeGatewayServer{ + invokeReply: &pb.MxCommandReply{ + SessionId: "session-1", + Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY, + ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}, + }, + } + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + reply, err := session.AdviseSupervisoryRaw(context.Background(), 12, 34) + if err != nil { + t.Fatalf("AdviseSupervisoryRaw() error = %v", err) + } + if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY { + t.Fatalf("reply kind = %s", reply.GetKind()) + } + cmd := fake.invokeRequest.GetCommand() + if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY { + t.Fatalf("command kind = %s", cmd.GetKind()) + } + if cmd.GetAdviseSupervisory().GetServerHandle() != 12 || cmd.GetAdviseSupervisory().GetItemHandle() != 34 { + t.Fatalf("advise-supervisory handles = (%d, %d), want (12, 34)", + cmd.GetAdviseSupervisory().GetServerHandle(), cmd.GetAdviseSupervisory().GetItemHandle()) + } + + // The error-returning wrapper drops the reply but must not error on success. + if err := session.AdviseSupervisory(context.Background(), 12, 34); err != nil { + t.Fatalf("AdviseSupervisory() error = %v", err) + } +} + +// TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate pins MXAccess +// parity: WriteSecured issued without a preceding AuthenticateUser is rejected +// natively, and the client surfaces that failure as a typed MxAccessError rather +// than pre-validating or reordering. The write value is also kept out of the +// surfaced error text. +func TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate(t *testing.T) { + hresult := int32(-2147024891) // E_ACCESSDENIED + fake := &fakeGatewayServer{ + invokeReply: &pb.MxCommandReply{ + SessionId: "session-1", + Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED, + Hresult: &hresult, + DiagnosticMessage: "WriteSecured requires a prior AuthenticateUser", + ProtocolStatus: &pb.ProtocolStatus{ + Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, + Message: "MXAccess failed", + }, + }, + } + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + securedValue := "supersecret-payload" + err := session.WriteSecured(context.Background(), 12, 34, 0, 0, StringValue(securedValue)) + + var mxErr *MxAccessError + if !errors.As(err, &mxErr) { + t.Fatalf("error %T does not support errors.As(*MxAccessError); err = %v", err, err) + } + if strings.Contains(err.Error(), securedValue) { + t.Fatalf("surfaced error leaked the secured payload: %q", err.Error()) + } + + // The command must carry the secured fields verbatim (parity: unaltered). + cmd := fake.invokeRequest.GetCommand() + if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED { + t.Fatalf("command kind = %s", cmd.GetKind()) + } + if cmd.GetWriteSecured().GetValue().GetStringValue() != securedValue { + t.Fatalf("wire value = %q, want %q", cmd.GetWriteSecured().GetValue().GetStringValue(), securedValue) + } +} + +// TestWriteSecuredRejectsNilValueWithoutRoundTrip pins the client-side required +// guard, which never echoes the (absent) value. +func TestWriteSecuredRejectsNilValue(t *testing.T) { + fake := &fakeGatewayServer{} + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + if err := session.WriteSecured(context.Background(), 1, 2, 0, 0, nil); err == nil || + !strings.Contains(err.Error(), "write-secured value is required") { + t.Fatalf("WriteSecured(nil value) error = %v", err) + } +} + +// TestAuthenticateUserReturnsUserIDOnHappyPath pins the typed helper unpacking of +// AuthenticateUserReply.user_id and that the credential is carried on the wire +// but never surfaced. +func TestAuthenticateUserReturnsUserIDOnHappyPath(t *testing.T) { + fake := &fakeGatewayServer{ + invokeReply: &pb.MxCommandReply{ + SessionId: "session-1", + Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER, + ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}, + Payload: &pb.MxCommandReply_AuthenticateUser{ + AuthenticateUser: &pb.AuthenticateUserReply{UserId: 4242}, + }, + }, + } + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "hunter2-password") + if err != nil { + t.Fatalf("AuthenticateUser() error = %v", err) + } + if userID != 4242 { + t.Fatalf("user id = %d, want 4242", userID) + } + + cmd := fake.invokeRequest.GetCommand() + if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER { + t.Fatalf("command kind = %s", cmd.GetKind()) + } + if cmd.GetAuthenticateUser().GetVerifyUser() != "operator" { + t.Fatalf("verify user = %q, want operator", cmd.GetAuthenticateUser().GetVerifyUser()) + } + if cmd.GetAuthenticateUser().GetVerifyUserPassword() != "hunter2-password" { + t.Fatalf("password not carried to the wire verbatim") + } +} + +// TestAuthenticateUserScrubsCredentialFromSurfacedError proves the redaction +// seam: even when the gateway diagnostic message echoes the credential back, the +// surfaced error redacts it while the typed MxAccessError remains reachable via +// errors.As. +func TestAuthenticateUserScrubsCredentialFromSurfacedError(t *testing.T) { + password := "hunter2-password" + fake := &fakeGatewayServer{ + invokeReply: &pb.MxCommandReply{ + SessionId: "session-1", + Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER, + DiagnosticMessage: "authentication failed for password " + password, + // Message is intentionally left empty so MxAccessError.Error() falls + // through to the diagnostic message — the free-text field that could + // otherwise echo the credential back to a caller's log. + ProtocolStatus: &pb.ProtocolStatus{ + Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, + }, + }, + } + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + _, err := session.AuthenticateUser(context.Background(), 12, "operator", password) + if err == nil { + t.Fatal("AuthenticateUser() returned no error on native failure") + } + if strings.Contains(err.Error(), password) { + t.Fatalf("surfaced error leaked the credential: %q", err.Error()) + } + if !strings.Contains(err.Error(), redactedSecretMarker) { + t.Fatalf("surfaced error missing redaction marker: %q", err.Error()) + } + var mxErr *MxAccessError + if !errors.As(err, &mxErr) { + t.Fatalf("redaction wrapper broke errors.As(*MxAccessError); err = %v", err) + } +} + +// TestAuthenticateUserRequiresVerifyUser pins the client-side required guard. +func TestAuthenticateUserRequiresVerifyUser(t *testing.T) { + fake := &fakeGatewayServer{} + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + if _, err := session.AuthenticateUser(context.Background(), 12, "", "pw"); err == nil || + !strings.Contains(err.Error(), "verify user is required") { + t.Fatalf("AuthenticateUser(empty user) error = %v", err) + } +} + +// TestSuspendActivateReturnStatus covers two Phase 2 helpers that unpack an +// MxStatusProxy from their dedicated reply arms. +func TestSuspendActivateReturnStatus(t *testing.T) { + fake := &fakeGatewayServer{ + invokeReply: &pb.MxCommandReply{ + SessionId: "session-1", + Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND, + ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}, + Payload: &pb.MxCommandReply_Suspend{ + Suspend: &pb.SuspendReply{Status: &pb.MxStatusProxy{Success: 1, DiagnosticText: "suspended"}}, + }, + }, + } + client, cleanup := newBufconnClient(t, fake) + defer cleanup() + session := NewSessionForID(client, "session-1") + + status, err := session.Suspend(context.Background(), 12, 34) + if err != nil { + t.Fatalf("Suspend() error = %v", err) + } + if status.GetDiagnosticText() != "suspended" { + t.Fatalf("status diagnostic = %q, want suspended", status.GetDiagnosticText()) + } + if fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle() != 34 { + t.Fatalf("suspend item handle = %d, want 34", fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle()) + } +} diff --git a/clients/python/README.md b/clients/python/README.md index ef8409f..2e14f73 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -153,19 +153,11 @@ but still need the write attributed to a user id, you must first advise the item supervisory and then pass that user id on the write. Without the supervisory advise the `user_id` on a plain write is ignored. -The session exposes `advise`/`unadvise` but not supervisory advise, so send it -through the generic command channel: +The session exposes a typed `advise_supervisory` helper alongside +`advise`/`unadvise`: ```python -await session.invoke( - pb.MxCommand( - kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY, - advise_supervisory=pb.AdviseSupervisoryCommand( - server_handle=server_handle, - item_handle=item_handle, - ), - ) -) +await session.advise_supervisory(server_handle, item_handle) await session.write(server_handle, item_handle, value, user_id=user_id) ``` @@ -173,6 +165,30 @@ await session.write(server_handle, item_handle, value, user_id=user_id) The CLI exposes the same command as `advise-supervisory`, and `write` / `write2` take `--user-id`. +For the verified/secured path, `authenticate_user`, `write_secured`, +`write_secured2`, and `archestra_user_to_id` are typed session helpers too. The +credential passed to `authenticate_user` and the values written by +`write_secured`/`write_secured2` are treated as secrets: they are never logged +and are scrubbed from any surfaced error message. MXAccess parity is preserved — +a `write_secured` that fails because no prior `authenticate_user` + +`advise_supervisory` established a supervisory context surfaces the native +failure as `MxAccessError` rather than being silently "fixed": + +```python +user_id = await session.authenticate_user(server_handle, "operator", password) +await session.advise_supervisory(server_handle, item_handle) +await session.write_secured( + server_handle, + item_handle, + value, + current_user_id=user_id, + verifier_user_id=user_id, +) +``` + +The CLI mirrors these as `authenticate-user` (credential via `--password` or, +preferably, `--password-env`) and `write-secured`. + ### Array writes replace the whole array A write to an array attribute **replaces the entire array**; it is not an diff --git a/clients/python/src/zb_mom_ww_mxgateway/session.py b/clients/python/src/zb_mom_ww_mxgateway/session.py index 973ba9a..057d37f 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/session.py +++ b/clients/python/src/zb_mom_ww_mxgateway/session.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence -from .errors import ensure_mxaccess_success +from .auth import redact_secret +from .errors import MxGatewayError, ensure_mxaccess_success from .events import ReplayGap from .generated import mxaccess_gateway_pb2 as pb from .values import MxValueInput, to_mx_value @@ -569,6 +570,249 @@ class Session: correlation_id=correlation_id, ) + async def _invoke_redacted( + self, + command: pb.MxCommand, + *, + correlation_id: str, + secrets: Sequence[str | None], + ) -> pb.MxCommandReply: + """Invoke a command whose request carries credential-sensitive data. + + Runs the same gateway + MXAccess validation as :meth:`invoke`, but scrubs + the supplied secret substrings from any surfaced error message before it + propagates. MXAccess parity is preserved — the native failure is still + raised as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`; only the + credential text is removed from the message so it can never reach logs. + """ + + try: + return await self.invoke(command, correlation_id=correlation_id) + except MxGatewayError as error: + _redact_error(error, secrets) + raise + + async def advise_supervisory( + self, + server_handle: int, + item_handle: int, + *, + correlation_id: str = "", + ) -> None: + """Invoke MXAccess `AdviseSupervisory` for an `ItemHandle`. + + Supervisory advise is the prerequisite for user-attributed and secured + writes: it must be established (typically after :meth:`authenticate_user`) + before a ``user_id``-bearing :meth:`write` or a :meth:`write_secured` + takes effect. + """ + await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY, + advise_supervisory=pb.AdviseSupervisoryCommand( + server_handle=server_handle, + item_handle=item_handle, + ), + ), + correlation_id=correlation_id, + ) + + async def write_secured( + self, + server_handle: int, + item_handle: int, + value: MxValueInput, + *, + current_user_id: int = 0, + verifier_user_id: int = 0, + correlation_id: str = "", + ) -> None: + """Invoke MXAccess `WriteSecured` — a signed/verified write. + + The written *value* is credential-sensitive and is scrubbed from any + surfaced error message (never logged). MXAccess parity is the contract: + ``WriteSecured`` failing before a prior :meth:`authenticate_user` + + :meth:`advise_supervisory`, or before a value-bearing body, is the native + behaviour and is surfaced as-is — it is not "fixed". + """ + await self._invoke_redacted( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_WRITE_SECURED, + write_secured=pb.WriteSecuredCommand( + server_handle=server_handle, + item_handle=item_handle, + current_user_id=current_user_id, + verifier_user_id=verifier_user_id, + value=to_mx_value(value), + ), + ), + correlation_id=correlation_id, + secrets=_value_secrets(value), + ) + + async def write_secured2( + self, + server_handle: int, + item_handle: int, + value: MxValueInput, + timestamp_value: MxValueInput, + *, + current_user_id: int = 0, + verifier_user_id: int = 0, + correlation_id: str = "", + ) -> None: + """Invoke MXAccess `WriteSecured2` — a signed/verified, timestamped write. + + Like :meth:`write_secured` but also stamps a client-supplied timestamp. + The written *value* is credential-sensitive and is scrubbed from any + surfaced error message. Native pre-condition failures are surfaced as-is. + """ + await self._invoke_redacted( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_WRITE_SECURED2, + write_secured2=pb.WriteSecured2Command( + server_handle=server_handle, + item_handle=item_handle, + current_user_id=current_user_id, + verifier_user_id=verifier_user_id, + value=to_mx_value(value), + timestamp_value=to_mx_value(timestamp_value), + ), + ), + correlation_id=correlation_id, + secrets=_value_secrets(value), + ) + + async def authenticate_user( + self, + server_handle: int, + verify_user: str, + verify_user_password: str, + *, + correlation_id: str = "", + ) -> int: + """Invoke MXAccess `AuthenticateUser` and return the resolved Galaxy user id. + + *verify_user_password* is a raw MXAccess credential: it is never logged + and is scrubbed from any surfaced error message. A native authentication + failure is surfaced as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError` + with the credential removed. + """ + reply = await self._invoke_redacted( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER, + authenticate_user=pb.AuthenticateUserCommand( + server_handle=server_handle, + verify_user=verify_user, + verify_user_password=verify_user_password, + ), + ), + correlation_id=correlation_id, + secrets=[verify_user_password], + ) + return reply.authenticate_user.user_id + + async def archestra_user_to_id( + self, + server_handle: int, + user_id_guid: str, + *, + correlation_id: str = "", + ) -> int: + """Invoke MXAccess `ArchestrAUserToId` and return the resolved user id.""" + reply = await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, + archestra_user_to_id=pb.ArchestrAUserToIdCommand( + server_handle=server_handle, + user_id_guid=user_id_guid, + ), + ), + correlation_id=correlation_id, + ) + return reply.archestra_user_to_id.user_id + + async def add_buffered_item( + self, + server_handle: int, + item_definition: str, + item_context: str = "", + *, + correlation_id: str = "", + ) -> int: + """Invoke MXAccess `AddBufferedItem` and return the new `ItemHandle`.""" + reply = await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM, + add_buffered_item=pb.AddBufferedItemCommand( + server_handle=server_handle, + item_definition=item_definition, + item_context=item_context, + ), + ), + correlation_id=correlation_id, + ) + return reply.add_buffered_item.item_handle + + async def set_buffered_update_interval( + self, + server_handle: int, + update_interval_milliseconds: int, + *, + correlation_id: str = "", + ) -> None: + """Invoke MXAccess `SetBufferedUpdateInterval` for the server handle.""" + await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL, + set_buffered_update_interval=pb.SetBufferedUpdateIntervalCommand( + server_handle=server_handle, + update_interval_milliseconds=update_interval_milliseconds, + ), + ), + correlation_id=correlation_id, + ) + + async def suspend( + self, + server_handle: int, + item_handle: int, + *, + correlation_id: str = "", + ) -> pb.MxStatusProxy: + """Invoke MXAccess `Suspend` for an `ItemHandle` and return its status.""" + reply = await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_SUSPEND, + suspend=pb.SuspendCommand( + server_handle=server_handle, + item_handle=item_handle, + ), + ), + correlation_id=correlation_id, + ) + return reply.suspend.status + + async def activate( + self, + server_handle: int, + item_handle: int, + *, + correlation_id: str = "", + ) -> pb.MxStatusProxy: + """Invoke MXAccess `Activate` for a suspended `ItemHandle` and return its status.""" + reply = await self.invoke( + pb.MxCommand( + kind=pb.MX_COMMAND_KIND_ACTIVATE, + activate=pb.ActivateCommand( + server_handle=server_handle, + item_handle=item_handle, + ), + ), + correlation_id=correlation_id, + ) + return reply.activate.status + def stream_events( self, *, @@ -631,4 +875,39 @@ def _ensure_bulk_size(name: str, count: int) -> None: raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)") +def _value_secrets(value: MxValueInput) -> list[str]: + """Return the redaction candidate strings for a credential-sensitive write value. + + Secured-write payloads may carry a password or other secret. Only textual + values can appear verbatim in a surfaced error, so a string value (or a + UTF-8-decodable ``bytes`` value) is returned for scrubbing; other value + kinds have no verbatim text form to leak. + """ + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, bytes): + try: + decoded = value.decode("utf-8") + except UnicodeDecodeError: + return [] + return [decoded] if decoded else [] + return [] + + +def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None: + """Scrub secret substrings from a raised error's message in place. + + Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through + the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential + text can never reach logs or be re-raised to a caller. The + ``protocol_status`` / ``raw_reply`` context is left untouched — those hold the + gateway's own fields, which never echo the client-supplied secret. + """ + scrubbed = [secret for secret in secrets if secret] + if not scrubbed: + return + if error.args and isinstance(error.args[0], str): + error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:]) + + from .client import GatewayClient # noqa: E402 diff --git a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py index 99876ad..154c339 100644 --- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py +++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py @@ -294,6 +294,56 @@ def advise_supervisory(**kwargs: Any) -> None: ) +@main.command("write-secured") +@gateway_options +@click.option("--session-id", required=True, help="Gateway session id.") +@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.") +@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.") +@click.option("--type", "value_type", default="string", show_default=True) +@click.option("--value", required=True, help="Value to write (credential-sensitive; never logged).") +@click.option("--current-user-id", default=0, type=int, show_default=True) +@click.option("--verifier-user-id", default=0, type=int, show_default=True) +@click.option("--correlation-id", default="", help="Client correlation id.") +@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.") +def write_secured(**kwargs: Any) -> None: + """Invoke MXAccess WriteSecured — a signed/verified write (credential-sensitive).""" + + _run( + _write_secured(**kwargs), + output_json=kwargs["output_json"], + secrets=_secrets(kwargs) + [kwargs.get("value")], + ) + + +@main.command("authenticate-user") +@gateway_options +@click.option("--session-id", required=True, help="Gateway session id.") +@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.") +@click.option("--verify-user", required=True, help="MXAccess user name to authenticate.") +@click.option( + "--password", + default=None, + help="User password. Prefer --password-env so the secret is not visible on the command line.", +) +@click.option( + "--password-env", + default=None, + help="Environment variable holding the user password.", +) +@click.option("--correlation-id", default="", help="Client correlation id.") +@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.") +def authenticate_user(**kwargs: Any) -> None: + """Invoke MXAccess AuthenticateUser — resolve a Galaxy user id (credential-sensitive).""" + + password = _resolve_password(kwargs) + kwargs["password"] = password + _run( + _authenticate_user(**kwargs), + output_json=kwargs["output_json"], + secrets=_secrets(kwargs) + [password], + ) + + @main.command("subscribe-bulk") @gateway_options @click.option("--session-id", required=True, help="Gateway session id.") @@ -745,19 +795,58 @@ async def _advise(**kwargs: Any) -> dict[str, Any]: async def _advise_supervisory(**kwargs: Any) -> dict[str, Any]: async with await _connect(kwargs) as client: session = _session(client, kwargs["session_id"]) - await session.invoke( - pb.MxCommand( - kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY, - advise_supervisory=pb.AdviseSupervisoryCommand( - server_handle=kwargs["server_handle"], - item_handle=kwargs["item_handle"], - ), - ), + await session.advise_supervisory( + kwargs["server_handle"], + kwargs["item_handle"], correlation_id=kwargs["correlation_id"], ) return {"ok": True} +async def _write_secured(**kwargs: Any) -> dict[str, Any]: + value = _parse_value(kwargs["value"], kwargs["value_type"]) + async with await _connect(kwargs) as client: + session = _session(client, kwargs["session_id"]) + await session.write_secured( + kwargs["server_handle"], + kwargs["item_handle"], + value, + current_user_id=kwargs["current_user_id"], + verifier_user_id=kwargs["verifier_user_id"], + correlation_id=kwargs["correlation_id"], + ) + return {"ok": True} + + +async def _authenticate_user(**kwargs: Any) -> dict[str, Any]: + async with await _connect(kwargs) as client: + session = _session(client, kwargs["session_id"]) + user_id = await session.authenticate_user( + kwargs["server_handle"], + kwargs["verify_user"], + kwargs["password"], + correlation_id=kwargs["correlation_id"], + ) + return {"userId": user_id} + + +def _resolve_password(kwargs: dict[str, Any]) -> str: + """Resolve the authenticate-user password from --password or --password-env. + + Prefers the explicit flag, then falls back to the named environment + variable. The resolved secret is never echoed; callers pass it into the + ``secrets`` redaction list so it cannot leak through a surfaced error. + """ + + password = kwargs.get("password") + if not password: + env_name = kwargs.get("password_env") + password = os.environ.get(env_name) if env_name else None + if not password: + raise click.UsageError("a password is required via --password or --password-env") + return password + + async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]: async with await _connect(kwargs) as client: session = _session(client, kwargs["session_id"]) diff --git a/clients/python/tests/test_cli.py b/clients/python/tests/test_cli.py index c0f34e6..856b83f 100644 --- a/clients/python/tests/test_cli.py +++ b/clients/python/tests/test_cli.py @@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None: assert result.exit_code == 0 assert "--parent-gobject-id" in result.output + + +class _FakeInvokeClient: + """Async-context-manager fake whose invoke_raw returns a scripted reply. + + Satisfies the session-backed CLI command bodies (register / write-secured / + authenticate-user) which build a Session over this client and call through to + ``invoke_raw``. Records the last command so tests can assert credentials are + carried on the wire but never echoed to stdout. + """ + + def __init__(self, reply) -> None: + self._reply = reply + self.last_request = None + + async def __aenter__(self) -> "_FakeInvokeClient": + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + async def invoke_raw(self, request): + self.last_request = request + return self._reply + + +def test_authenticate_user_command_returns_user_id_without_echoing_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb + + reply = pb.MxCommandReply( + session_id="s1", + kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER, + protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), + authenticate_user=pb.AuthenticateUserReply(user_id=42), + ) + fake = _FakeInvokeClient(reply) + + async def fake_connect(options, **_kwargs): + return fake + + monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect) + + result = CliRunner().invoke( + main, + [ + "authenticate-user", + "--plaintext", + "--session-id", + "s1", + "--server-handle", + "3", + "--verify-user", + "operator", + "--password", + "cli-secret-pw", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["userId"] == 42 + assert "cli-secret-pw" not in result.output + # Credential is carried on the wire, not echoed. + assert fake.last_request.command.authenticate_user.verify_user_password == "cli-secret-pw" + + +def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb + + reply = pb.MxCommandReply( + session_id="s1", + kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER, + protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), + authenticate_user=pb.AuthenticateUserReply(user_id=7), + ) + fake = _FakeInvokeClient(reply) + + async def fake_connect(options, **_kwargs): + return fake + + monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect) + monkeypatch.setenv("MXGW_TEST_PW", "env-secret-pw") + + result = CliRunner().invoke( + main, + [ + "authenticate-user", + "--plaintext", + "--session-id", + "s1", + "--server-handle", + "3", + "--verify-user", + "operator", + "--password-env", + "MXGW_TEST_PW", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + assert "env-secret-pw" not in result.output + assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw" + + +def test_authenticate_user_requires_a_password() -> None: + result = CliRunner().invoke( + main, + [ + "authenticate-user", + "--plaintext", + "--session-id", + "s1", + "--server-handle", + "3", + "--verify-user", + "operator", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "password is required" in result.output + + +def test_write_secured_command_does_not_echo_value_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb + + reply = pb.MxCommandReply( + session_id="s1", + kind=pb.MX_COMMAND_KIND_WRITE_SECURED, + protocol_status=pb.ProtocolStatus( + code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, + message="WriteSecured rejected value cli-secret-value", + ), + hresult=-1, + ) + fake = _FakeInvokeClient(reply) + + async def fake_connect(options, **_kwargs): + return fake + + monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect) + + result = CliRunner().invoke( + main, + [ + "write-secured", + "--plaintext", + "--session-id", + "s1", + "--server-handle", + "3", + "--item-handle", + "4", + "--value", + "cli-secret-value", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "cli-secret-value" not in result.output + + +def test_write_secured_and_authenticate_user_commands_are_registered() -> None: + names = set(main.commands) + assert {"write-secured", "authenticate-user"} <= names diff --git a/clients/python/tests/test_typed_command_helpers.py b/clients/python/tests/test_typed_command_helpers.py new file mode 100644 index 0000000..c0f7124 --- /dev/null +++ b/clients/python/tests/test_typed_command_helpers.py @@ -0,0 +1,227 @@ +"""Tests for the typed single-item command helpers (CLI-04). + +Covers the parity-critical MXAccess commands promoted from raw ``Invoke`` to +typed async session helpers: ``advise_supervisory``, ``write_secured`` / +``write_secured2``, ``authenticate_user``, ``archestra_user_to_id``, and the +buffered/suspend/activate family. The credential-redaction contract for the +secured/auth helpers is asserted explicitly. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError +from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb + + +class FakeUnary: + """Records requests and pops scripted replies, matching the client's call shape.""" + + def __init__(self, replies: list[Any]) -> None: + self.replies = replies + self.requests: list[Any] = [] + self.metadata: tuple[tuple[str, str], ...] | None = None + + async def __call__( + self, + request: Any, + *, + metadata: tuple[tuple[str, str], ...], + ) -> Any: + self.requests.append(request) + self.metadata = metadata + return self.replies.pop(0) + + +class FakeGatewayStub: + """Minimal stub: a fixed open-session reply plus a scriptable invoke queue.""" + + def __init__(self, invoke_replies: list[Any]) -> None: + self.open_session = FakeUnary( + [ + pb.OpenSessionReply( + session_id="session-1", + protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), + ), + ], + ) + self.invoke = FakeUnary(invoke_replies) + self.OpenSession = self.open_session + self.Invoke = self.invoke + + +async def _session_with(invoke_replies: list[Any]): + stub = FakeGatewayStub(invoke_replies) + client = await GatewayClient.connect( + ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), + stub=stub, + ) + session = await client.open_session() + return session, stub + + +def _ok(kind: "pb.MxCommandKind.ValueType", **payload: Any) -> pb.MxCommandReply: + return pb.MxCommandReply( + session_id="session-1", + kind=kind, + protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK), + **payload, + ) + + +@pytest.mark.asyncio +async def test_advise_supervisory_sends_typed_command() -> None: + session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY)]) + + await session.advise_supervisory(12, 34) + + command = stub.invoke.requests[0].command + assert command.kind == pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY + assert command.advise_supervisory.server_handle == 12 + assert command.advise_supervisory.item_handle == 34 + + +@pytest.mark.asyncio +async def test_authenticate_user_returns_user_id_and_sends_credentials() -> None: + session, stub = await _session_with( + [_ok(pb.MX_COMMAND_KIND_AUTHENTICATE_USER, authenticate_user=pb.AuthenticateUserReply(user_id=77))], + ) + + user_id = await session.authenticate_user(12, "operator", "s3cr3t-pw") + + assert user_id == 77 + command = stub.invoke.requests[0].command + assert command.kind == pb.MX_COMMAND_KIND_AUTHENTICATE_USER + assert command.authenticate_user.verify_user == "operator" + # The credential is carried on the wire (redaction is about logs/errors, not the RPC). + assert command.authenticate_user.verify_user_password == "s3cr3t-pw" + + +@pytest.mark.asyncio +async def test_authenticate_user_scrubs_credential_from_surfaced_error() -> None: + password = "super-secret-pw" + failure = pb.MxCommandReply( + session_id="session-1", + kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER, + protocol_status=pb.ProtocolStatus( + code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, + # Simulate a gateway that unwisely echoed the credential back in the message. + message=f"authentication failed for password {password}", + ), + hresult=-1, + ) + session, _ = await _session_with([failure]) + + with pytest.raises(MxAccessError) as captured: + await session.authenticate_user(12, "operator", password) + + assert password not in str(captured.value) + assert "[redacted]" in str(captured.value) + + +@pytest.mark.asyncio +async def test_write_secured_surfaces_native_failure_without_prior_authenticate() -> None: + """Parity: WriteSecured failing before authenticate/advise-supervisory is surfaced as-is.""" + secret_value = "priv-payload" + failure = pb.MxCommandReply( + session_id="session-1", + kind=pb.MX_COMMAND_KIND_WRITE_SECURED, + protocol_status=pb.ProtocolStatus( + code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, + message=f"WriteSecured rejected value {secret_value}", + ), + hresult=-2147217407, + ) + session, stub = await _session_with([failure]) + + with pytest.raises(MxAccessError) as captured: + await session.write_secured(12, 34, secret_value, current_user_id=5, verifier_user_id=6) + + # Native failure is surfaced (not "fixed") and the raw reply is preserved... + assert captured.value.raw_reply is failure + # ...but the credential-sensitive value is scrubbed from the surfaced message. + assert secret_value not in str(captured.value) + command = stub.invoke.requests[0].command + assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED + assert command.write_secured.current_user_id == 5 + assert command.write_secured.verifier_user_id == 6 + + +@pytest.mark.asyncio +async def test_write_secured2_sends_value_and_timestamp() -> None: + from datetime import datetime, timezone + + session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_WRITE_SECURED2)]) + + stamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + await session.write_secured2(12, 34, 42, stamp, current_user_id=5, verifier_user_id=6) + + command = stub.invoke.requests[0].command + assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2 + assert command.write_secured2.value.int32_value == 42 + assert command.write_secured2.HasField("timestamp_value") + + +@pytest.mark.asyncio +async def test_archestra_user_to_id_returns_user_id() -> None: + session, stub = await _session_with( + [_ok(pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, archestra_user_to_id=pb.ArchestrAUserToIdReply(user_id=9))], + ) + + user_id = await session.archestra_user_to_id(12, "guid-123") + + assert user_id == 9 + assert stub.invoke.requests[0].command.archestra_user_to_id.user_id_guid == "guid-123" + + +@pytest.mark.asyncio +async def test_add_buffered_item_returns_item_handle() -> None: + session, stub = await _session_with( + [_ok(pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM, add_buffered_item=pb.AddBufferedItemReply(item_handle=55))], + ) + + item_handle = await session.add_buffered_item(12, "Object.Attribute", "ctx") + + assert item_handle == 55 + command = stub.invoke.requests[0].command + assert command.add_buffered_item.item_definition == "Object.Attribute" + assert command.add_buffered_item.item_context == "ctx" + + +@pytest.mark.asyncio +async def test_set_buffered_update_interval_sends_command() -> None: + session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)]) + + await session.set_buffered_update_interval(12, 250) + + command = stub.invoke.requests[0].command + assert command.kind == pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL + assert command.set_buffered_update_interval.update_interval_milliseconds == 250 + + +@pytest.mark.asyncio +async def test_suspend_and_activate_return_status() -> None: + session, _ = await _session_with( + [ + _ok( + pb.MX_COMMAND_KIND_SUSPEND, + suspend=pb.SuspendReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)), + ), + ], + ) + status = await session.suspend(12, 34) + assert status.category == pb.MX_STATUS_CATEGORY_OK + + session, _ = await _session_with( + [ + _ok( + pb.MX_COMMAND_KIND_ACTIVATE, + activate=pb.ActivateReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)), + ), + ], + ) + status = await session.activate(12, 34) + assert status.category == pb.MX_STATUS_CATEGORY_OK diff --git a/clients/rust/README.md b/clients/rust/README.md index e27f707..2f1b951 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -192,26 +192,36 @@ but still need the write attributed to a user id, you must first advise the item supervisory and then pass that user id on the write. Without the supervisory advise the `user_id` on a plain write is ignored. -The session exposes `advise`/`un_advise` but not supervisory advise, so send it -through the generic command channel: +The session exposes a typed `advise_supervisory` helper alongside +`advise`/`un_advise`: ```rust -session - .invoke( - MxCommandKind::AdviseSupervisory, - Payload::AdviseSupervisory(AdviseSupervisoryCommand { - server_handle, - item_handle, - }), - ) - .await?; - +session.advise_supervisory(server_handle, item_handle).await?; session.write(server_handle, item_handle, value, user_id).await?; ``` The CLI exposes the same command as `advise-supervisory`, and `write` / `write2` take `--user-id`. +### Verified / secured writes and user resolution + +The verified path has typed session helpers too: `authenticate_user` (returns +the resolved MXAccess user id), `archestra_user_to_id`, and +`write_secured` / `write_secured2`. MXAccess parity is preserved — a +`write_secured` issued before the required `authenticate_user` + +`advise_supervisory` (or before a value-bearing body) fails natively and the +failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials +passed to `authenticate_user` (and secured write payloads) are placed only on +the wire — the client never logs them and never embeds them in an `Error`'s +`Display`/`Debug`; the only error text that can surface (from `tonic::Status` +messages and reply diagnostics) is scrubbed by the credential-redaction seam. +The CLI mirrors these as `authenticate-user` (password via `--password` or the +`--password-env` env var, never echoed) and `write-secured`. + +The remaining single-item command helpers round out MXAccess parity: +`unregister`, `suspend` / `activate` (each returns the operation's +`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`. + ### Array writes replace the whole array A write to an array attribute **replaces the entire array**; it is not an diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index 8a84ffe..056d510 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -21,11 +21,10 @@ use serde_json::Value; use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode}; use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent; use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ - alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage, - CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, - MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest, - StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, - WriteSecuredBulkEntry, + alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand, + MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue, + OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry, + WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry, }; use zb_mom_ww_mxgateway_client::{ next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient, @@ -118,6 +117,67 @@ enum Command { #[arg(long)] json: bool, }, + /// Release a `ServerHandle` (and the items advised under it) via + /// MXAccess `Unregister`. + Unregister { + #[command(flatten)] + connection: ConnectionArgs, + #[arg(long)] + session_id: String, + #[arg(long)] + server_handle: i32, + #[arg(long)] + json: bool, + }, + /// Resolve an MXAccess user id from a credential via `AuthenticateUser`. + /// The password is read from `--password` or, if omitted, from the + /// environment variable named by `--password-env`; it is never echoed to + /// stdout/stderr. + AuthenticateUser { + #[command(flatten)] + connection: ConnectionArgs, + #[arg(long)] + session_id: String, + #[arg(long)] + server_handle: i32, + #[arg(long)] + verify_user: String, + /// Verifier password. Prefer `--password-env` so the secret never + /// appears in the process command line. + #[arg(long)] + password: Option, + /// Name of the environment variable holding the verifier password. + /// Used only when `--password` is not supplied. + #[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")] + password_env: String, + #[arg(long)] + json: bool, + }, + /// Single credential-verified write via MXAccess `WriteSecured`. + /// + /// Parity note: this fails natively unless the session has first run + /// `authenticate-user` + `advise-supervisory` and the item carries a + /// value-bearing body — the native failure is surfaced, not hidden. + WriteSecured { + #[command(flatten)] + connection: ConnectionArgs, + #[arg(long)] + session_id: String, + #[arg(long)] + server_handle: i32, + #[arg(long)] + item_handle: i32, + #[arg(long, value_enum)] + value_type: CliValueType, + #[arg(long)] + value: String, + #[arg(long, default_value_t = 0)] + current_user_id: i32, + #[arg(long, default_value_t = 0)] + verifier_user_id: i32, + #[arg(long)] + json: bool, + }, SubscribeBulk { #[command(flatten)] connection: ConnectionArgs, @@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> { } => { let session = session_for(connection, session_id).await?; session - .invoke( - MxCommandKind::AdviseSupervisory, - zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory( - AdviseSupervisoryCommand { - server_handle, - item_handle, - }, - ), - ) + .advise_supervisory(server_handle, item_handle) .await?; print_ok("advise-supervisory", json); } + Command::Unregister { + connection, + session_id, + server_handle, + json, + } => { + let session = session_for(connection, session_id).await?; + session.unregister(server_handle).await?; + print_ok("unregister", json); + } + Command::AuthenticateUser { + connection, + session_id, + server_handle, + verify_user, + password, + password_env, + json, + } => { + // Resolve the credential from --password or the named env var. + // The password is passed straight to the typed helper and is never + // echoed to stdout/stderr or embedded in an error message. + let verify_user_password = password + .or_else(|| env::var(&password_env).ok()) + .ok_or_else(|| Error::InvalidArgument { + name: "password".to_owned(), + detail: format!( + "supply --password or set the environment variable `{password_env}`" + ), + })?; + let session = session_for(connection, session_id).await?; + let user_id = session + .authenticate_user(server_handle, &verify_user, &verify_user_password) + .await?; + print_handle("userId", user_id, json); + } + Command::WriteSecured { + connection, + session_id, + server_handle, + item_handle, + value_type, + value, + current_user_id, + verifier_user_id, + json, + } => { + let session = session_for(connection, session_id).await?; + session + .write_secured( + server_handle, + item_handle, + current_user_id, + verifier_user_id, + parse_value(value_type, &value)?, + ) + .await?; + print_ok("write-secured", json); + } Command::SubscribeBulk { connection, session_id, @@ -2489,6 +2600,59 @@ mod tests { assert_eq!(value["workerProtocolVersion"], 1); } + #[test] + fn parses_authenticate_user_command_with_password_env() { + let parsed = Cli::try_parse_from([ + "mxgw", + "authenticate-user", + "--session-id", + "session-1", + "--server-handle", + "7", + "--verify-user", + "verifier", + "--password-env", + "MY_PW_VAR", + ]); + assert!(parsed.is_ok(), "parse failed: {parsed:?}"); + } + + #[test] + fn parses_write_secured_command() { + let parsed = Cli::try_parse_from([ + "mxgw", + "write-secured", + "--session-id", + "session-1", + "--server-handle", + "12", + "--item-handle", + "34", + "--value-type", + "int32", + "--value", + "5", + "--current-user-id", + "1", + "--verifier-user-id", + "2", + ]); + assert!(parsed.is_ok(), "parse failed: {parsed:?}"); + } + + #[test] + fn parses_unregister_command() { + let parsed = Cli::try_parse_from([ + "mxgw", + "unregister", + "--session-id", + "session-1", + "--server-handle", + "12", + ]); + assert!(parsed.is_ok(), "parse failed: {parsed:?}"); + } + #[test] fn parses_stream_alarms_command() { let parsed = Cli::try_parse_from([ diff --git a/clients/rust/src/session.rs b/clients/rust/src/session.rs index 63f8189..621474a 100644 --- a/clients/rust/src/session.rs +++ b/clients/rust/src/session.rs @@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error}; use crate::generated::mxaccess_gateway::v1::mx_command::Payload; use crate::generated::mxaccess_gateway::v1::mx_command_reply; use crate::generated::mxaccess_gateway::v1::{ - AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand, - BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, - MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue, - OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, - StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, - UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, - Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, - WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry, + ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand, + AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand, + AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, + MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, + MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand, + RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand, + StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand, + UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand, + Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, + WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command, + WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand, }; -use crate::value::MxValue; +use crate::value::{MxStatus, MxValue}; const MAX_BULK_ITEMS: usize = 1_000; @@ -129,6 +132,25 @@ impl Session { register_server_handle(&reply) } + /// Run MXAccess `Unregister` to release the given `ServerHandle` and the + /// items advised under it. Mirrors [`Session::register`]; the worker + /// returns no payload, so the call resolves to `()` on success. + /// + /// # Errors + /// + /// Returns [`Error::Command`] if the worker reports a non-OK protocol + /// status and [`Error::MxAccess`] if MXAccess itself rejects the + /// unregister (negative `hresult` / non-success status), plus the usual + /// transport/status errors. + pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> { + self.invoke( + MxCommandKind::Unregister, + Payload::Unregister(UnregisterCommand { server_handle }), + ) + .await?; + Ok(()) + } + /// Run MXAccess `AddItem` against `server_handle` and return the /// assigned `ItemHandle`. /// @@ -230,6 +252,133 @@ impl Session { Ok(()) } + /// Run MXAccess `AdviseSupervisory` to start supervisory-mode change + /// notifications for the given item. Mirrors [`Session::advise`]; the + /// worker returns no payload, so the call resolves to `()` on success. + /// + /// # Errors + /// + /// Returns [`Error::Command`] for a non-OK protocol status and + /// [`Error::MxAccess`] when MXAccess reports a negative `hresult` / + /// non-success status, plus the usual transport/status errors. + pub async fn advise_supervisory( + &self, + server_handle: i32, + item_handle: i32, + ) -> Result<(), Error> { + self.invoke( + MxCommandKind::AdviseSupervisory, + Payload::AdviseSupervisory(AdviseSupervisoryCommand { + server_handle, + item_handle, + }), + ) + .await?; + Ok(()) + } + + /// Run MXAccess `Suspend` on the given item and return the native + /// `MXSTATUS_PROXY` the worker reports for the operation. + /// + /// A top-level MXAccess failure (negative `hresult` or a non-success + /// top-level status) still surfaces as [`Error::MxAccess`] via the shared + /// reply validation; the returned [`MxStatus`] is the per-operation status + /// carried in the `SuspendReply` payload. + /// + /// # Errors + /// + /// Returns [`Error::Command`] for a non-OK protocol status, + /// [`Error::MxAccess`] on an MXAccess-level failure, and + /// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload, + /// plus the usual transport/status errors. + pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result { + let reply = self + .invoke( + MxCommandKind::Suspend, + Payload::Suspend(SuspendCommand { + server_handle, + item_handle, + }), + ) + .await?; + + suspend_status(reply) + } + + /// Run MXAccess `Activate` on the given item and return the native + /// `MXSTATUS_PROXY` the worker reports for the operation. See + /// [`Session::suspend`] for the status/error contract. + /// + /// # Errors + /// + /// Same conditions as [`Session::suspend`] (with the `Activate` payload). + pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result { + let reply = self + .invoke( + MxCommandKind::Activate, + Payload::Activate(ActivateCommand { + server_handle, + item_handle, + }), + ) + .await?; + + activate_status(reply) + } + + /// Run MXAccess `AddBufferedItem` against `server_handle` and return the + /// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered + /// item carries a caller-supplied context string. + /// + /// # Errors + /// + /// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or + /// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply + /// lacks the item handle, plus the usual transport/status errors. + pub async fn add_buffered_item( + &self, + server_handle: i32, + item_definition: &str, + item_context: &str, + ) -> Result { + let reply = self + .invoke( + MxCommandKind::AddBufferedItem, + Payload::AddBufferedItem(AddBufferedItemCommand { + server_handle, + item_definition: item_definition.to_owned(), + item_context: item_context.to_owned(), + }), + ) + .await?; + + add_buffered_item_handle(&reply) + } + + /// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The + /// worker returns no payload, so the call resolves to `()` on success. + /// + /// # Errors + /// + /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol + /// status or an MXAccess-level failure, plus the usual transport/status + /// errors. + pub async fn set_buffered_update_interval( + &self, + server_handle: i32, + update_interval_milliseconds: i32, + ) -> Result<(), Error> { + self.invoke( + MxCommandKind::SetBufferedUpdateInterval, + Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand { + server_handle, + update_interval_milliseconds, + }), + ) + .await?; + Ok(()) + } + /// Bulk variant of [`Session::add_item`]. Each tag address yields one /// `SubscribeResult` in the returned vector. /// @@ -628,6 +777,142 @@ impl Session { Ok(()) } + /// Run MXAccess `WriteSecured` (single credential-verified write, no + /// caller-supplied timestamp). + /// + /// **MXAccess parity:** `WriteSecured` failing before a prior + /// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or + /// before a value-bearing body, is the native contract, not a client bug — + /// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is + /// **not** smoothed over. The `value` is credential-sensitive: it is placed + /// only in the wire command and is never logged or embedded in an error + /// message. + /// + /// # Errors + /// + /// Returns [`Error::Command`] for a non-OK protocol status, + /// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the + /// usual transport/status errors. + pub async fn write_secured( + &self, + server_handle: i32, + item_handle: i32, + current_user_id: i32, + verifier_user_id: i32, + value: MxValue, + ) -> Result<(), Error> { + self.invoke( + MxCommandKind::WriteSecured, + Payload::WriteSecured(WriteSecuredCommand { + server_handle, + item_handle, + current_user_id, + verifier_user_id, + value: Some(value.into_proto()), + }), + ) + .await?; + Ok(()) + } + + /// Run MXAccess `WriteSecured2` (credential-verified write with a + /// caller-supplied timestamp). See [`Session::write_secured`] for the + /// parity and credential-handling contract. + /// + /// # Errors + /// + /// Same conditions as [`Session::write_secured`]. + pub async fn write_secured2( + &self, + server_handle: i32, + item_handle: i32, + current_user_id: i32, + verifier_user_id: i32, + value: MxValue, + timestamp_value: MxValue, + ) -> Result<(), Error> { + self.invoke( + MxCommandKind::WriteSecured2, + Payload::WriteSecured2(WriteSecured2Command { + server_handle, + item_handle, + current_user_id, + verifier_user_id, + value: Some(value.into_proto()), + timestamp_value: Some(timestamp_value.into_proto()), + }), + ) + .await?; + Ok(()) + } + + /// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user + /// id. + /// + /// **Credential handling:** `verify_user_password` is a raw MXAccess + /// credential. It is placed only in the wire command; this helper never + /// logs it and never embeds it in an [`Error`] — the only error text that + /// can surface comes from `tonic::Status` messages and the reply's + /// diagnostic fields, both of which are scrubbed by the client's + /// credential-redaction seam (see [`crate::error`]). The reply itself + /// carries no echo of the credential. + /// + /// **MXAccess parity:** a failed authentication is a native outcome, not a + /// client error to paper over — it surfaces as [`Error::MxAccess`] + /// (negative `hresult`) with the credential absent from the message. + /// + /// # Errors + /// + /// Returns [`Error::Command`] for a non-OK protocol status, + /// [`Error::MxAccess`] when MXAccess rejects the credential, + /// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the + /// usual transport/status errors. + pub async fn authenticate_user( + &self, + server_handle: i32, + verify_user: &str, + verify_user_password: &str, + ) -> Result { + let reply = self + .invoke( + MxCommandKind::AuthenticateUser, + Payload::AuthenticateUser(AuthenticateUserCommand { + server_handle, + verify_user: verify_user.to_owned(), + verify_user_password: verify_user_password.to_owned(), + }), + ) + .await?; + + authenticate_user_id(&reply) + } + + /// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to + /// its MXAccess user id. + /// + /// # Errors + /// + /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol + /// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK + /// reply lacks the user id, plus the usual transport/status errors. + pub async fn archestra_user_to_id( + &self, + server_handle: i32, + user_id_guid: &str, + ) -> Result { + let reply = self + .invoke( + MxCommandKind::ArchestraUserToId, + Payload::ArchestraUserToId(ArchestrAUserToIdCommand { + server_handle, + user_id_guid: user_id_guid.to_owned(), + }), + ) + .await?; + + archestra_user_id(&reply) + } + /// Open the per-session event stream from the beginning. /// /// The returned [`EventStream`] yields [`EventItem`](crate::EventItem) @@ -769,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result { } } +fn add_buffered_item_handle(reply: &MxCommandReply) -> Result { + match reply.payload.as_ref() { + Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => { + Ok(add_buffered.item_handle) + } + _ => reply + .return_value + .as_ref() + .and_then(int32_reply_value) + .ok_or_else(|| Error::MalformedReply { + detail: + "add_buffered_item reply lacked an item_handle payload or int32 return_value" + .to_owned(), + }), + } +} + +fn authenticate_user_id(reply: &MxCommandReply) -> Result { + match reply.payload.as_ref() { + Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id), + _ => Err(Error::MalformedReply { + detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(), + }), + } +} + +fn archestra_user_id(reply: &MxCommandReply) -> Result { + match reply.payload.as_ref() { + Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id), + _ => Err(Error::MalformedReply { + detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(), + }), + } +} + +fn suspend_status(reply: MxCommandReply) -> Result { + match reply.payload { + Some(mx_command_reply::Payload::Suspend(suspend)) => suspend + .status + .map(MxStatus::from_proto) + .ok_or_else(|| Error::MalformedReply { + detail: "suspend reply payload lacked a status entry".to_owned(), + }), + _ => Err(Error::MalformedReply { + detail: "suspend reply did not carry a Suspend payload".to_owned(), + }), + } +} + +fn activate_status(reply: MxCommandReply) -> Result { + match reply.payload { + Some(mx_command_reply::Payload::Activate(activate)) => activate + .status + .map(MxStatus::from_proto) + .ok_or_else(|| Error::MalformedReply { + detail: "activate reply payload lacked a status entry".to_owned(), + }), + _ => Err(Error::MalformedReply { + detail: "activate reply did not carry an Activate payload".to_owned(), + }), + } +} + enum BulkReplyKind { AddItem, AdviseItem, diff --git a/clients/rust/tests/client_behavior.rs b/clients/rust/tests/client_behavior.rs index 477fa81..faf7fba 100644 --- a/clients/rust/tests/client_behavior.rs +++ b/clients/rust/tests/client_behavior.rs @@ -23,10 +23,11 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind; use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind, - BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, - CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent, - MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, - MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, + AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult, + BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest, + MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray, + MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue, + OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState, StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry, @@ -624,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() { assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32)); } +#[tokio::test] +async fn advise_supervisory_round_trips_and_sends_advise_supervisory_kind() { + let state = Arc::new(FakeState::default()); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + let session = client.session("session-fixture"); + + session.advise_supervisory(12, 34).await.unwrap(); + + let last_command = state.last_command_kind.lock().await; + assert_eq!(*last_command, Some(MxCommandKind::AdviseSupervisory as i32)); +} + +#[tokio::test] +async fn unregister_round_trips_and_sends_unregister_kind() { + let state = Arc::new(FakeState::default()); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + let session = client.session("session-fixture"); + + session.unregister(12).await.unwrap(); + + let last_command = state.last_command_kind.lock().await; + assert_eq!(*last_command, Some(MxCommandKind::Unregister as i32)); +} + +#[tokio::test] +async fn write_secured_surfaces_native_mxaccess_failure_and_redacts_diagnostic() { + // MXAccess parity: WriteSecured failing (e.g. before authenticate + + // advise-supervisory) is a native outcome, surfaced — not smoothed over. + // The scripted reply carries an Ok protocol envelope but a negative + // hresult, so this also proves the typed helper runs ensure_mxaccess_success. + let state = Arc::new(FakeState::default()); + *state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + let session = client.session("session-fixture"); + + let error = session + .write_secured(12, 34, 0, 0, ClientMxValue::int32(1)) + .await + .unwrap_err(); + + let Error::MxAccess(mx_access) = &error else { + panic!("write_secured must surface the native failure as Error::MxAccess: {error:?}"); + }; + assert_eq!(mx_access.reply().hresult, Some(-2_147_217_900)); + let rendered = error.to_string(); + assert!(rendered.contains(""), "diagnostic: {rendered}"); + assert!( + !rendered.contains("leaked_secret"), + "credential-shaped diagnostic must be scrubbed: {rendered}" + ); + + let last_command = state.last_command_kind.lock().await; + assert_eq!(*last_command, Some(MxCommandKind::WriteSecured as i32)); +} + +#[tokio::test] +async fn authenticate_user_returns_user_id_and_transmits_credential_on_wire() { + let state = Arc::new(FakeState::default()); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + let session = client.session("session-fixture"); + + let user_id = session + .authenticate_user(7, "verifier", "sup3r-s3cret-pw") + .await + .unwrap(); + + assert_eq!(user_id, 4242); + let captured = state + .last_authenticate_user + .lock() + .await + .take() + .expect("fake should have captured an AuthenticateUserCommand"); + assert_eq!(captured.server_handle, 7); + assert_eq!(captured.verify_user, "verifier"); + // The credential must reach the wire so authentication can succeed... + assert_eq!(captured.verify_user_password, "sup3r-s3cret-pw"); + let last_command = state.last_command_kind.lock().await; + assert_eq!(*last_command, Some(MxCommandKind::AuthenticateUser as i32)); +} + +#[tokio::test] +async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() { + // ...but a native authentication failure must never leak the credential + // into the error's Display or Debug rendering. + let state = Arc::new(FakeState::default()); + *state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure); + let endpoint = spawn_fake_gateway(state.clone()).await; + let client = GatewayClient::connect(ClientOptions::new(endpoint)) + .await + .unwrap(); + let session = client.session("session-fixture"); + + let password = "unique-credential-9f3b2"; + let error = session + .authenticate_user(7, "verifier", password) + .await + .unwrap_err(); + + assert!( + matches!(error, Error::MxAccess(_)), + "native auth failure should surface as Error::MxAccess: {error:?}" + ); + let display = error.to_string(); + let debug = format!("{error:?}"); + assert!( + !display.contains(password), + "credential leaked into Display: {display}" + ); + assert!( + !debug.contains(password), + "credential leaked into Debug: {debug}" + ); +} + #[tokio::test] async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() { let state = Arc::new(FakeState::default()); @@ -732,6 +860,10 @@ struct FakeState { /// Captures the last `WriteCommand` payload received, populated when the /// `WriteOk` override is active. Used by `write_array_elements` e2e test. last_write_command: Mutex>, + /// Captures the last `AuthenticateUserCommand` payload received, populated + /// by the `AuthenticateUser` happy-path handler so a test can confirm the + /// credential reaches the wire (but never a surfaced error). + last_authenticate_user: Mutex>, stream_dropped: Arc, /// Optional per-test override that pins the fake's `Invoke` handler to /// a specific reply shape (or `Err(Status)`). The default of `None` @@ -765,6 +897,12 @@ enum InvokeOverride { /// and capture the decoded `WriteCommand` in /// `FakeState::last_write_command` for inspection. WriteOk, + /// Reply with an `Ok` protocol envelope but a negative `hresult` and a + /// non-success status entry carrying a credential-shaped diagnostic. This + /// mimics the worker's COMException path (e.g. `WriteSecured` / + /// `AuthenticateUser` rejected by MXAccess) so the client's + /// `ensure_mxaccess_success` check is exercised on the typed helper path. + MxAccessFailure, } #[derive(Clone)] @@ -846,6 +984,27 @@ impl MxAccessGateway for FakeGateway { ..MxCommandReply::default() })), InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)), + InvokeOverride::MxAccessFailure => Ok(Response::new(MxCommandReply { + session_id: request.session_id, + correlation_id: "fake-correlation".to_owned(), + kind, + // Protocol envelope succeeds; MXAccess itself failed. + protocol_status: Some(ok_status("command ok")), + // 0x80040E14 (a COM failure) as a signed 32-bit value. + hresult: Some(-2_147_217_900), + statuses: vec![MxStatusProxy { + success: 0, + category: MxStatusCategory::SecurityError as i32, + detected_by: MxStatusSource::RespondingLmx as i32, + detail: 123, + // A credential-shaped token that must be scrubbed from + // any surfaced diagnostic text. + diagnostic_text: "denied for mxgw_leaked_secret".to_owned(), + ..MxStatusProxy::default() + }], + payload: None, + ..MxCommandReply::default() + })), InvokeOverride::WriteOk => { // Extract and capture the WriteCommand payload so the test // can assert on server_handle, item_handle, user_id, and value. @@ -977,6 +1136,26 @@ impl MxAccessGateway for FakeGateway { ))); } + if kind == MxCommandKind::AuthenticateUser as i32 { + // Capture the transmitted command so a test can confirm the + // credential reaches the wire but never an error message. + if let Some(mx_command::Payload::AuthenticateUser(auth)) = + request.command.and_then(|command| command.payload) + { + *self.state.last_authenticate_user.lock().await = Some(auth); + } + return Ok(Response::new(MxCommandReply { + session_id: request.session_id, + correlation_id: "fake-correlation".to_owned(), + kind, + protocol_status: Some(ok_status("command ok")), + payload: Some(mx_command_reply::Payload::AuthenticateUser( + AuthenticateUserReply { user_id: 4242 }, + )), + ..MxCommandReply::default() + })); + } + Ok(Response::new(MxCommandReply { session_id: request.session_id, correlation_id: "fake-correlation".to_owned(), diff --git a/docs/ClientLibrariesDesign.md b/docs/ClientLibrariesDesign.md index d6f9326..a616291 100644 --- a/docs/ClientLibrariesDesign.md +++ b/docs/ClientLibrariesDesign.md @@ -104,6 +104,29 @@ All languages should expose the same core concepts, using idiomatic naming: The gateway session id and MXAccess handles must remain visible. The library may offer helper methods, but it must not invent alternate handle semantics. +## Typed Command Parity + +Every command kind in the wire contract has a typed single-item session helper, +not just a raw-`Invoke` escape hatch (CLI-04). Beyond the register/add/advise/ +remove/write family, the parity-critical single-item helpers are: +`AdviseSupervisory`, `WriteSecured` / `WriteSecured2`, `AuthenticateUser`, +`ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`, `Suspend`, +`Activate`, and `Unregister`. Each is a thin wrapper over the same raw-command +machinery the bulk helpers use — it adds no wire surface — and runs the same +MXAccess-level reply validation (HRESULT `< 0` + per-item `MxStatusProxy`) as the +rest of the client. **MXAccess parity is preserved exactly**: e.g. `WriteSecured` +failing before a prior `AuthenticateUser` + `AdviseSupervisory` surfaces the +native failure unchanged — the helper does not pre-validate or reorder it. + +**Credential handling:** `AuthenticateUser` credentials and `WriteSecured` +secured payloads route through each client's secret-redaction seam so they never +reach logs, exception text, or `ToString`/`Debug`/`Display` — the value is carried +only on the wire. Each client's test suite asserts a distinctive credential is +absent from any surfaced error. + +Shipped for .NET / Go / Rust / Python; the Java client's typed parity helpers are +batched to the windows build host (no local JRE on the primary dev box). + ## Shared API Shape Each language should support this conceptual API: