Architecture remediation: P2 tier (completeness & polish) #122

Open
dohertj2 wants to merge 25 commits from fix/archreview-p2 into main
21 changed files with 3496 additions and 97 deletions
Showing only changes of commit bde042b4d4 - Show all commits
@@ -1,18 +1,31 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary> /// <summary>Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor internal static class MxGatewayCliSecretRedactor
{ {
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary> /// <summary>
/// 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.
/// </summary>
/// <param name="value">The message text to redact.</param> /// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param> /// <param name="secrets">The secret values to remove (API key, verify-user password, secured payloads).</param>
public static string Redact(string value, string? apiKey) 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;
} }
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;
} }
} }
@@ -112,6 +112,24 @@ public static class MxGatewayClientCli
.ConfigureAwait(false), .ConfigureAwait(false),
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token) "advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .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) "subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .ConfigureAwait(false),
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token) "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 // Client.Dotnet-028: redact the *effective* key — from --api-key or the
// --api-key-env environment variable — so an env-var-sourced key echoed // --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? 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")) if (forceJsonErrors || arguments.HasFlag("json"))
{ {
@@ -319,6 +343,48 @@ public static class MxGatewayClientCli
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName); return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
} }
/// <summary>
/// Resolves the effective MXAccess verify-user credential from
/// <c>--verify-user-password</c> or, failing that, the
/// <c>--verify-user-password-env</c>-named environment variable (default
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>). The credential is never echoed;
/// this resolver exists so the error-redaction catch block can strip it
/// from any surfaced error (CLI-04), mirroring <see cref="TryResolveApiKey"/>.
/// </summary>
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);
}
/// <summary>
/// Resolves the verify-user credential for <c>authenticate-user</c>, 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.
/// </summary>
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) private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{ {
var cancellation = new CancellationTokenSource(); var cancellation = new CancellationTokenSource();
@@ -475,6 +541,215 @@ public static class MxGatewayClientCli
cancellationToken); cancellationToken);
} }
private static Task<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> SubscribeBulkAsync( private static Task<int> SubscribeBulkAsync(
CliArguments arguments, CliArguments arguments,
IMxGatewayCliClient client, IMxGatewayCliClient client,
@@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli
or "add-item" or "add-item"
or "advise" or "advise"
or "advise-supervisory" 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 "subscribe-bulk"
or "unsubscribe-bulk" or "unsubscribe-bulk"
or "read-bulk" or "read-bulk"
@@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]"); writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]"); writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]"); writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet unregister --session-id <id> --server-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet add-buffered-item --session-id <id> --server-handle <n> --item <ref> [--item-context <s>] [--json]");
writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id <id> --server-handle <n> --interval-ms <n> [--json]");
writer.WriteLine("mxgw-dotnet suspend --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet activate --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet write-secured --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet authenticate-user --session-id <id> --server-handle <n> --verify-user <user> (--verify-user-password <pw> | --verify-user-password-env <ENVVAR>) [--json]");
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]"); writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]"); writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]"); writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
@@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
[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());
}
/// <summary>
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary>
[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());
}
/// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary>
[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());
}
/// <summary>Verifies that error output redacts sensitive API key values.</summary> /// <summary>Verifies that error output redacts sensitive API key values.</summary>
[Fact] [Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey() public async Task RunAsync_ErrorOutput_RedactsApiKey()
@@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(7, el.Value.Int32Value); Assert.Equal(7, el.Value.Int32Value);
} }
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
[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);
}
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
[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);
}
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
[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);
}
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
[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);
}
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
[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);
}
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
[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);
}
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
[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);
}
/// <summary>
/// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
/// natively. The client must surface that scripted failure (as an
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
/// </summary>
[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<MxAccessException>(
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);
}
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
[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);
}
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<MxAccessException>(
async () => await session.AuthenticateUserAsync(12, "operator", password));
Assert.DoesNotContain(password, exception.Message);
Assert.DoesNotContain(password, exception.ToString());
}
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
[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) private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{ {
return new MxGatewayClient(transport.Options, transport); return new MxGatewayClient(transport.Options, transport);
@@ -842,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Unregisters a previously registered client from the MXAccess session
/// (MXAccess <c>Unregister</c>), releasing its ServerHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task UnregisterAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Unregisters a previously registered client without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> UnregisterRawAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand { ServerHandle = serverHandle },
},
cancellationToken);
}
/// <summary>
/// Subscribes to supervisory events for an item (MXAccess <c>AdviseSupervisory</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AdviseSupervisoryAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Subscribes to supervisory events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AdviseSupervisoryRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AdviseSupervisory,
AdviseSupervisory = new AdviseSupervisoryCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Adds a buffered item to the MXAccess session (MXAccess <c>AddBufferedItem</c>),
/// returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new buffered item.</returns>
public async Task<int> 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;
}
/// <summary>
/// Adds a buffered item to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Sets the buffered-item update interval on the MXAccess session
/// (MXAccess <c>SetBufferedUpdateInterval</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task SetBufferedUpdateIntervalAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync(
serverHandle,
updateIntervalMilliseconds,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Sets the buffered-item update interval without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SetBufferedUpdateIntervalRawAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.SetBufferedUpdateInterval,
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
{
ServerHandle = serverHandle,
UpdateIntervalMilliseconds = updateIntervalMilliseconds,
},
},
cancellationToken);
}
/// <summary>
/// Suspends updates for an item (MXAccess <c>Suspend</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> SuspendAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Suspend?.Status;
}
/// <summary>
/// Suspends updates for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SuspendRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Suspend,
Suspend = new SuspendCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Resumes updates for a suspended item (MXAccess <c>Activate</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> ActivateAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Activate?.Status;
}
/// <summary>
/// Resumes updates for a suspended item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> ActivateRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Activate,
Activate = new ActivateCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Writes a secured value to an item on the MXAccess server (MXAccess <c>WriteSecured</c>).
/// </summary>
/// <remarks>
/// MXAccess parity: <c>WriteSecured</c> fails when it is issued before a value-bearing
/// NMX body or before a prior <c>AuthenticateUser</c> + <c>AdviseSupervisory</c>. That
/// native failure is surfaced unchanged — the client does not pre-validate or reorder it.
/// The <paramref name="value"/> is credential-sensitive and must never reach logs; the
/// client mirrors the single-item WriteSecured redaction contract.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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();
}
/// <summary>
/// Writes a secured value to an item without error checking. See
/// <see cref="WriteSecuredAsync"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Writes a secured value and timestamp to an item (MXAccess <c>WriteSecured2</c>).
/// </summary>
/// <remarks>
/// Same parity and redaction contract as <see cref="WriteSecuredAsync"/>: the native
/// failure that occurs before a value-bearing NMX body or a prior authenticate is
/// surfaced unchanged, and the credential-sensitive <paramref name="value"/> must never
/// reach logs.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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();
}
/// <summary>
/// Writes a secured value and timestamp to an item without error checking. See
/// <see cref="WriteSecured2Async"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Authenticates an MXAccess verify-user (MXAccess <c>AuthenticateUser</c>), returning
/// the resolved user id used by <c>WriteSecured</c> / <c>WriteSecured2</c>.
/// </summary>
/// <remarks>
/// The <paramref name="verifyUserPassword"/> 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.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated user id.</returns>
public async Task<int> 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;
}
/// <summary>
/// Authenticates an MXAccess verify-user without error checking. See
/// <see cref="AuthenticateUserAsync"/> for the credential-handling contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id
/// (MXAccess <c>ArchestrAUserToId</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved MXAccess user id.</returns>
public async Task<int> 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;
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary> /// <summary>
/// Invokes an MXAccess command on this session. /// Invokes an MXAccess command on this session.
/// </summary> /// </summary>
+23 -16
View File
@@ -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). [Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`, `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 `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the 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 supervisory and then pass that user id on the write. Without the supervisory
advise the `userID` on a plain write is ignored. advise the `userID` on a plain write is ignored.
The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
through the generic command channel:
```go ```go
_, err := client.Invoke(ctx, &pb.MxCommandRequest{ err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
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.Write(ctx, serverHandle, itemHandle, value, userID) err = session.Write(ctx, serverHandle, itemHandle, value, userID)
``` ```
The CLI exposes the same command as `advise-supervisory`, and `write` The CLI exposes the same command as `advise-supervisory`, and `write`
takes `-user-id`. 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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an 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. | | `unsubscribe-bulk` | Unadvise many item handles in one call. |
| `read-bulk` | Read snapshots for many item handles in one call. | | `read-bulk` | Read snapshots for many item handles in one call. |
| `write` | Write one value (`-type`, `-value`). | | `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). | | `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). | | `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). | | `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
+86 -14
View File
@@ -21,7 +21,6 @@ import (
"syscall" "syscall"
"time" "time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway" "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect" "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) return runAdvise(ctx, args[1:], stdout, stderr)
case "advise-supervisory": case "advise-supervisory":
return runAdviseSupervisory(ctx, args[1:], stdout, stderr) 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": case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr) return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk": case "unsubscribe-bulk":
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
} }
defer client.Close() defer client.Close()
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{ session := mxgateway.NewSessionForID(client, *sessionID)
SessionId: *sessionID, reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
Command: &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: int32(*serverHandle),
ItemHandle: int32(*itemHandle),
},
},
},
})
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err) 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 { func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError) flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
} }
func writeUsage(writer io.Writer) { func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>") fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured|write-secured-bulk|write-secured2-bulk|authenticate-user|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
} }
// batchEOR is the end-of-result sentinel emitted to stdout after every command // batchEOR is the end-of-result sentinel emitted to stdout after every command
+30
View File
@@ -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 // TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast // guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial. // before any dial.
+58
View File
@@ -3,10 +3,68 @@ package mxgateway
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" 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 = "<redacted>"
// 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 // ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
// (cancel-when-full) path when the buffered results channel overflows because // (cancel-when-full) path when the buffered results channel overflows because
// the consumer fell behind. It is delivered as the final EventResult.Err before // the consumer fell behind. It is delivered as the final EventResult.Err before
+271
View File
@@ -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, // Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent. // context cancellation stops Recv, or a terminal error is sent.
// //
@@ -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())
}
}
+27 -11
View File
@@ -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 item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored. supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`unadvise` but not supervisory advise, so send it The session exposes a typed `advise_supervisory` helper alongside
through the generic command channel: `advise`/`unadvise`:
```python ```python
await session.invoke( await session.advise_supervisory(server_handle, item_handle)
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=server_handle,
item_handle=item_handle,
),
)
)
await session.write(server_handle, item_handle, value, user_id=user_id) 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` / The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`. `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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an A write to an array attribute **replaces the entire array**; it is not an
@@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence 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 .events import ReplayGap
from .generated import mxaccess_gateway_pb2 as pb from .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value from .values import MxValueInput, to_mx_value
@@ -569,6 +570,249 @@ class Session:
correlation_id=correlation_id, 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( def stream_events(
self, 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)") 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 from .client import GatewayClient # noqa: E402
@@ -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") @main.command("subscribe-bulk")
@gateway_options @gateway_options
@click.option("--session-id", required=True, help="Gateway session id.") @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 def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client: async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"]) session = _session(client, kwargs["session_id"])
await session.invoke( await session.advise_supervisory(
pb.MxCommand( kwargs["server_handle"],
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY, kwargs["item_handle"],
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=kwargs["server_handle"],
item_handle=kwargs["item_handle"],
),
),
correlation_id=kwargs["correlation_id"], correlation_id=kwargs["correlation_id"],
) )
return {"ok": True} 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 def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client: async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"]) session = _session(client, kwargs["session_id"])
+172
View File
@@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
assert result.exit_code == 0 assert result.exit_code == 0
assert "--parent-gobject-id" in result.output 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
@@ -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
+22 -12
View File
@@ -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 item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored. supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`un_advise` but not supervisory advise, so send it The session exposes a typed `advise_supervisory` helper alongside
through the generic command channel: `advise`/`un_advise`:
```rust ```rust
session session.advise_supervisory(server_handle, item_handle).await?;
.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
session.write(server_handle, item_handle, value, user_id).await?; session.write(server_handle, item_handle, value, user_id).await?;
``` ```
The CLI exposes the same command as `advise-supervisory`, and `write` / The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`. `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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an A write to an array attribute **replaces the entire array**; it is not an
+178 -14
View File
@@ -21,11 +21,10 @@ use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode}; 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::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage, alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest, OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
WriteSecuredBulkEntry,
}; };
use zb_mom_ww_mxgateway_client::{ use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient, next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
@@ -118,6 +117,67 @@ enum Command {
#[arg(long)] #[arg(long)]
json: bool, 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<String>,
/// 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 { SubscribeBulk {
#[command(flatten)] #[command(flatten)]
connection: ConnectionArgs, connection: ConnectionArgs,
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
} => { } => {
let session = session_for(connection, session_id).await?; let session = session_for(connection, session_id).await?;
session session
.invoke( .advise_supervisory(server_handle, item_handle)
MxCommandKind::AdviseSupervisory,
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
AdviseSupervisoryCommand {
server_handle,
item_handle,
},
),
)
.await?; .await?;
print_ok("advise-supervisory", json); 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 { Command::SubscribeBulk {
connection, connection,
session_id, session_id,
@@ -2489,6 +2600,59 @@ mod tests {
assert_eq!(value["workerProtocolVersion"], 1); 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] #[test]
fn parses_stream_alarms_command() { fn parses_stream_alarms_command() {
let parsed = Cli::try_parse_from([ let parsed = Cli::try_parse_from([
+357 -9
View File
@@ -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::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply; use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{ use crate::generated::mxaccess_gateway::v1::{
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand, ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue, AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry, 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; const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply) 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 /// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`. /// assigned `ItemHandle`.
/// ///
@@ -230,6 +252,133 @@ impl Session {
Ok(()) 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<MxStatus, Error> {
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<MxStatus, Error> {
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<i32, Error> {
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 /// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector. /// `SubscribeResult` in the returned vector.
/// ///
@@ -628,6 +777,142 @@ impl Session {
Ok(()) 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<i32, Error> {
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<i32, Error> {
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. /// Open the per-session event stream from the beginning.
/// ///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem) /// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
@@ -769,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
} }
} }
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
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<i32, Error> {
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<i32, Error> {
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<MxStatus, Error> {
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<MxStatus, Error> {
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 { enum BulkReplyKind {
AddItem, AddItem,
AdviseItem, AdviseItem,
+183 -4
View File
@@ -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::{ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind, AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState, ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry, 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)); 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("<redacted>"), "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] #[tokio::test]
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() { async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
let state = Arc::new(FakeState::default()); let state = Arc::new(FakeState::default());
@@ -732,6 +860,10 @@ struct FakeState {
/// Captures the last `WriteCommand` payload received, populated when the /// Captures the last `WriteCommand` payload received, populated when the
/// `WriteOk` override is active. Used by `write_array_elements` e2e test. /// `WriteOk` override is active. Used by `write_array_elements` e2e test.
last_write_command: Mutex<Option<WriteCommand>>, last_write_command: Mutex<Option<WriteCommand>>,
/// 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<Option<AuthenticateUserCommand>>,
stream_dropped: Arc<AtomicBool>, stream_dropped: Arc<AtomicBool>,
/// Optional per-test override that pins the fake's `Invoke` handler to /// Optional per-test override that pins the fake's `Invoke` handler to
/// a specific reply shape (or `Err(Status)`). The default of `None` /// a specific reply shape (or `Err(Status)`). The default of `None`
@@ -765,6 +897,12 @@ enum InvokeOverride {
/// and capture the decoded `WriteCommand` in /// and capture the decoded `WriteCommand` in
/// `FakeState::last_write_command` for inspection. /// `FakeState::last_write_command` for inspection.
WriteOk, 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)] #[derive(Clone)]
@@ -846,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
..MxCommandReply::default() ..MxCommandReply::default()
})), })),
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)), 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 => { InvokeOverride::WriteOk => {
// Extract and capture the WriteCommand payload so the test // Extract and capture the WriteCommand payload so the test
// can assert on server_handle, item_handle, user_id, and value. // 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 { Ok(Response::new(MxCommandReply {
session_id: request.session_id, session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(), correlation_id: "fake-correlation".to_owned(),
+23
View File
@@ -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 The gateway session id and MXAccess handles must remain visible. The library may
offer helper methods, but it must not invent alternate handle semantics. 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 ## Shared API Shape
Each language should support this conceptual API: Each language should support this conceptual API: