fix(CLI-45): standardize the CLI credential env var and fail fast on empty passwords

All five client CLIs now share one credential contract for `authenticate-user`:
flags `--password` / `--password-env` (Go: `-password` / `-password-env`) with
default env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved
credential that is missing *or empty* is a usage error naming the flag and the
variable. The value is never echoed and never reaches the wire.

Go and Java previously sent an empty credential when the variable was unset,
turning a misconfigured environment into a real MXAccess authentication attempt.
Go now returns the guard error before dialing; Java throws a picocli
ParameterException instead of falling back to "". Python's `--password-env`
gained the canonical default and its UsageError names the resolved variable.
Rust treats an empty flag or env value as missing, with the resolution extracted
into a testable `resolve_verify_user_password`. .NET adopts the canonical flags
and keeps `--verify-user-password`, `--verify-user-password-env`, and
MXGATEWAY_VERIFY_USER_PASSWORD as deprecated aliases for one release.

Docs same commit: CrossLanguageSmokeMatrix.md gains the credential contract and
the per-CLI subcommand-coverage table (the documented-not-fixed half of the
finding); all five READMEs name the canonical variable and the fail-fast rule,
and the .NET README carries the deprecation note. Tracking flipped to Done in
both remediation registers with a change-log row.

No .proto changed; no generated code regenerated.
This commit is contained in:
Joseph Doherty
2026-08-07 06:03:24 -04:00
parent cf66ebbcfb
commit 37cb3b0df8
17 changed files with 764 additions and 53 deletions
+26
View File
@@ -258,6 +258,32 @@ optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
output redacts API keys supplied through `--api-key`.
### `authenticate-user` credentials
```powershell
$env:MXGATEWAY_VERIFY_PASSWORD = "<verify-user password>"
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- authenticate-user --session-id <id> --server-handle 1 --verify-user operator --json
```
The credential comes from `--password` or, preferably, the environment variable
named by `--password-env` (default `MXGATEWAY_VERIFY_PASSWORD`) so it stays out
of shell history and the process table. It is never echoed to stdout or stderr,
and error output routes it through the same redaction seam as the API key. A
missing or empty resolved credential is a usage error naming the option and the
variable: the CLI fails before the invoke rather than authenticating with an
empty password.
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client CLIs
— see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
**Deprecated names.** This CLI previously used `--verify-user-password`,
`--verify-user-password-env`, and `MXGATEWAY_VERIFY_USER_PASSWORD`. All three
still resolve, for one release only, so existing scripts keep working; migrate to
the canonical names above. The full resolution order is `--password`,
`--verify-user-password`, the variable named by `--password-env` (or the
deprecated `--verify-user-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`),
then `MXGATEWAY_VERIFY_USER_PASSWORD`.
## Galaxy Repository Browse
`GalaxyRepositoryClient` is a separate read-only wrapper around the
@@ -346,31 +346,78 @@ public static class MxGatewayClientCli
}
/// <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"/>.
/// Canonical CLI credential environment variable, shared by every official
/// client CLI (CLI-45) so one exported variable drives the same operator
/// workflow in all five languages.
/// </summary>
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
/// <summary>
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
/// for one release so existing scripts keep working.
/// </summary>
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
/// <summary>
/// Resolves the name of the environment variable holding the verify-user
/// credential: <c>--password-env</c>, then the deprecated
/// <c>--verify-user-password-env</c> alias, then
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>.
/// </summary>
private static string ResolveVerifyPasswordEnvironmentName(CliArguments arguments)
{
string? environmentName = arguments.GetOptional("password-env");
if (!string.IsNullOrEmpty(environmentName))
{
return environmentName;
}
environmentName = arguments.GetOptional("verify-user-password-env");
return string.IsNullOrEmpty(environmentName)
? DefaultVerifyPasswordEnvironmentName
: environmentName;
}
/// <summary>
/// Resolves the effective MXAccess verify-user credential in the CLI-45
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
/// alias, the environment variable named by <c>--password-env</c> (default
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
/// treated as absent. 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");
string? password = arguments.GetOptional("password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
password = arguments.GetOptional("verify-user-password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
return Environment.GetEnvironmentVariable(passwordEnvironmentName);
password = Environment.GetEnvironmentVariable(ResolveVerifyPasswordEnvironmentName(arguments));
if (!string.IsNullOrEmpty(password))
{
return password;
}
password = Environment.GetEnvironmentVariable(LegacyVerifyPasswordEnvironmentName);
return string.IsNullOrEmpty(password) ? null : password;
}
/// <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.
/// a redaction-safe error when no source yields a non-empty value. Failing
/// fast keeps a misconfigured environment from becoming a real MXAccess
/// authentication attempt with an empty credential (CLI-45); the thrown
/// message names only the option/env var, never the value.
/// </summary>
private static string ResolveVerifyUserPassword(CliArguments arguments)
{
@@ -380,11 +427,10 @@ public static class MxGatewayClientCli
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}.");
"Verify-user password is required. Pass --password or set "
+ $"{ResolveVerifyPasswordEnvironmentName(arguments)} (deprecated aliases: "
+ $"--verify-user-password, --verify-user-password-env, {LegacyVerifyPasswordEnvironmentName}).");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
@@ -710,8 +756,10 @@ public static class MxGatewayClientCli
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
// The credential is resolved from --password or its env var (default
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
// fails fast before the invoke rather than reaching the wire (CLI-45).
// On any surfaced error the RunCoreAsync catch block routes
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
return InvokeAndWriteAsync(
arguments,
@@ -2372,7 +2420,9 @@ public static class MxGatewayClientCli
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 authenticate-user --session-id <id> --server-handle <n> --verify-user <user> [--password <pw>] [--password-env <ENVVAR>] [--json]");
writer.WriteLine(" credential: --password, else the --password-env variable (default MXGATEWAY_VERIFY_PASSWORD); required and never empty.");
writer.WriteLine(" deprecated aliases: --verify-user-password, --verify-user-password-env, MXGATEWAY_VERIFY_USER_PASSWORD.");
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 unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
@@ -235,7 +235,7 @@ public sealed class MxGatewayClientCliTests
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
"--password", password,
],
output,
error,
@@ -246,6 +246,235 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("[redacted]", error.ToString());
}
/// <summary>
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
/// four CLIs. The credential reaches the wire but never stdout/stderr.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_AuthenticateUser_AcceptsCanonicalPasswordFlag()
{
const string password = "canonical-flag-credential";
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 = 11 },
});
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",
"--password", password,
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(password, error.ToString(), StringComparison.Ordinal);
}
/// <summary>
/// CLI-45: the credential is read from the environment variable named by
/// <c>--password-env</c>, whose default is the canonical
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
[InlineData("MXGW_TEST_CLI45_ENV", "MXGW_TEST_CLI45_ENV")]
public async Task RunAsync_AuthenticateUser_ReadsCredentialFromNamedEnvironmentVariable(
string? passwordEnvArgument,
string environmentName)
{
const string password = "env-sourced-credential";
using EnvironmentVariableScope scope = new(environmentName, password);
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 = 12 },
});
List<string> args =
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--json",
];
if (passwordEnvArgument is not null)
{
args.Add("--password-env");
args.Add(passwordEnvArgument);
}
int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
}
/// <summary>
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
/// <c>--verify-user-password</c> flag and the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_AuthenticateUser_HonoursDeprecatedAliases()
{
using var flagOutput = new StringWriter();
using var flagError = new StringWriter();
FakeCliClient flagClient = new();
flagClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AuthenticateUser = new AuthenticateUserReply { UserId = 13 },
});
int flagExitCode = 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", "legacy-flag-credential",
"--json",
],
flagOutput,
flagError,
_ => flagClient);
Assert.Equal(0, flagExitCode);
Assert.Equal(
"legacy-flag-credential",
Assert.Single(flagClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);
using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", null);
using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", "legacy-env-credential");
using var envOutput = new StringWriter();
using var envError = new StringWriter();
FakeCliClient envClient = new();
envClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AuthenticateUser = new AuthenticateUserReply { UserId = 14 },
});
int envExitCode = await MxGatewayClientCli.RunAsync(
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--json",
],
envOutput,
envError,
_ => envClient);
Assert.Equal(0, envExitCode);
Assert.Equal(
"legacy-env-credential",
Assert.Single(envClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);
}
/// <summary>
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
/// never sends a fabricated empty password to the wire. The error names the flag
/// and the environment variable, never a value.
/// </summary>
/// <param name="explicitEmptyFlag">Whether to pass an explicit empty --password.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RunAsync_AuthenticateUser_FailsFastOnMissingOrEmptyCredential(bool explicitEmptyFlag)
{
using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", explicitEmptyFlag ? string.Empty : null);
using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", null);
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
List<string> args =
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
];
if (explicitEmptyFlag)
{
args.Add("--password");
args.Add(string.Empty);
}
int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);
Assert.Equal(1, exitCode);
Assert.Empty(fakeClient.InvokeRequests);
Assert.Contains("--password", error.ToString(), StringComparison.Ordinal);
Assert.Contains("MXGATEWAY_VERIFY_PASSWORD", error.ToString(), StringComparison.Ordinal);
}
/// <summary>
/// Sets an environment variable for the duration of a test and restores the
/// previous value on dispose, so credential-resolution tests do not depend on
/// (or leak into) the ambient environment.
/// </summary>
private sealed class EnvironmentVariableScope : IDisposable
{
private readonly string _name;
private readonly string? _original;
public EnvironmentVariableScope(string name, string? value)
{
_name = name;
_original = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
public void Dispose()
{
Environment.SetEnvironmentVariable(_name, _original);
}
}
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
+5 -1
View File
@@ -177,7 +177,11 @@ 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`.
`write-secured`. The credential is required: a missing or empty resolved value is
a usage error naming the flag and the variable, so the CLI fails before dialing
instead of authenticating with an empty password. `MXGATEWAY_VERIFY_PASSWORD` is
the canonical variable across all five client CLIs — see
[Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
### Array writes replace the whole array
+18 -3
View File
@@ -446,6 +446,11 @@ func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Write
return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
}
// defaultVerifyPasswordEnv is the canonical CLI credential environment variable,
// shared by every official client CLI (CLI-45) so one exported variable drives
// the same operator workflow in all five languages.
const defaultVerifyPasswordEnv = "MXGATEWAY_VERIFY_PASSWORD"
func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -458,7 +463,7 @@ func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.W
// 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")
passwordEnv := flags.String("password-env", defaultVerifyPasswordEnv, "environment variable containing the verify-user password")
if err := flags.Parse(args); err != nil {
return err
@@ -471,8 +476,18 @@ func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.W
}
resolvedPassword := *password
if resolvedPassword == "" && *passwordEnv != "" {
resolvedPassword = os.Getenv(*passwordEnv)
envName := *passwordEnv
if envName == "" {
envName = defaultVerifyPasswordEnv
}
if resolvedPassword == "" {
resolvedPassword = os.Getenv(envName)
}
// Fail fast rather than dialing: an unset or empty variable must not become a
// real MXAccess authentication attempt with an empty credential. The message
// names only the flag and the variable — never the resolved value.
if resolvedPassword == "" {
return fmt.Errorf("a password is required via -password or the %s environment variable", envName)
}
client, options, err := dialForCommand(ctx, common)
+63
View File
@@ -598,6 +598,69 @@ func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
}
}
// TestRunAuthenticateUserRejectsEmptyPassword pins the CLI-45 fail-fast contract:
// an unresolved credential must abort before dialing rather than authenticating
// with an empty password, and the usage error must name both -password and the
// canonical environment variable without echoing any value.
func TestRunAuthenticateUserRejectsEmptyPassword(t *testing.T) {
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "")
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"authenticate-user",
"-session-id", "s1",
"-verify-user", "operator",
"-plaintext",
"-api-key", "test",
}, &stdout, &stderr)
if err == nil {
t.Fatal("authenticate-user without a credential must fail before dialing")
}
if !strings.Contains(err.Error(), "-password") {
t.Fatalf("error must name the -password flag: %v", err)
}
if !strings.Contains(err.Error(), "MXGATEWAY_VERIFY_PASSWORD") {
t.Fatalf("error must name the canonical environment variable: %v", err)
}
}
// TestRunAuthenticateUserReadsPasswordFromCanonicalEnv pins that the default
// -password-env is MXGATEWAY_VERIFY_PASSWORD: with it set the credential guard
// passes and the command proceeds past it to the dial, which fails against an
// unused port under a short context — proving the guard was cleared without
// needing a live gateway.
func TestRunAuthenticateUserReadsPasswordFromCanonicalEnv(t *testing.T) {
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "env-sourced-credential")
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
var stdout, stderr bytes.Buffer
err := runWithIO(ctx, []string{
"authenticate-user",
"-session-id", "s1",
"-verify-user", "operator",
"-endpoint", "127.0.0.1:1",
"-plaintext",
"-api-key", "test",
"-call-timeout", "1s",
}, &stdout, &stderr)
if err == nil {
t.Fatal("expected the dial/RPC to fail against an unused port")
}
if strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("test invoked an unknown flag, so it never reached the guard: %v", err)
}
if strings.Contains(err.Error(), "a password is required") {
t.Fatalf("credential guard must be satisfied from %s: %v", "MXGATEWAY_VERIFY_PASSWORD", err)
}
if strings.Contains(err.Error(), "env-sourced-credential") ||
strings.Contains(stdout.String(), "env-sourced-credential") ||
strings.Contains(stderr.String(), "env-sourced-credential") {
t.Fatal("the resolved credential must never be echoed")
}
}
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial.
+7 -2
View File
@@ -167,8 +167,13 @@ session.write(serverHandle, itemHandle, value, userId);
native failure is surfaced, not papered over.
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
(credential via `--password` or `--password-env`, never echoed), and `write` /
`write2` take `--user-id`.
(credential via `--password` or the variable named by `--password-env`, default
`MXGATEWAY_VERIFY_PASSWORD`, never echoed), and `write` / `write2` take
`--user-id`. The credential is required: a missing or empty resolved value is a
picocli usage error naming the option and the variable, so the CLI fails before
connecting instead of authenticating with an empty password.
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
### Array writes replace the whole array
@@ -70,6 +70,7 @@ import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Spec;
/**
@@ -182,6 +183,13 @@ public final class MxGatewayCli implements Callable<Integer> {
/** Sentinel written to stdout after every command result in batch mode. */
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
/**
* Canonical CLI credential environment variable, shared by every official
* client CLI (CLI-45) so one exported variable drives the same operator
* workflow in all five languages.
*/
static final String DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD";
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
private static final Object ALARM_FEED_END = new Object();
@@ -1139,7 +1147,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(
names = "--password-env",
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
defaultValue = DEFAULT_VERIFY_PASSWORD_ENV,
description = "Environment variable holding the password when --password is omitted.")
String passwordEnv;
@@ -1151,11 +1159,20 @@ public final class MxGatewayCli implements Callable<Integer> {
public Integer call() {
// Resolve the credential from the flag or environment. It flows only
// into the request; it is never written to output, logs, or errors.
String environmentName =
passwordEnv == null || passwordEnv.isBlank() ? DEFAULT_VERIFY_PASSWORD_ENV : passwordEnv;
String resolvedPassword = password == null || password.isBlank()
? System.getenv(passwordEnv)
? System.getenv(environmentName)
: password;
if (resolvedPassword == null) {
resolvedPassword = "";
if (resolvedPassword == null || resolvedPassword.isBlank()) {
// Fail fast instead of dialing: a misconfigured environment must not
// become a real MXAccess authentication attempt with an empty
// credential (CLI-45). The message names the option and the variable
// only never the value.
throw new ParameterException(
common.spec.commandLine(),
"a password is required via --password or the " + environmentName
+ " environment variable");
}
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
int userId = client.session(sessionId)
@@ -2,7 +2,10 @@ package com.zb.mom.ww.mxgateway.cli;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
@@ -211,6 +214,73 @@ final class MxGatewayCliTests {
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
}
/**
* CLI-45: an unresolved credential must abort with a picocli usage error
* before the CLI dials, instead of authenticating with an empty password.
* The message names the option and the variable, never a value.
*/
@Test
void authenticateUserRejectsMissingCredentialWithUsageError() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
"--json");
assertNotEquals(0, run.exitCode(), "a missing credential must fail");
assertTrue(run.errors().contains("--password"), run.errors());
assertTrue(run.errors().contains("MXGW_CLI45_ABSENT_PASSWORD_VAR"), run.errors());
assertNull(factory.client, "the CLI must not connect without a credential");
}
/**
* CLI-45: a blank {@code --password} is treated as missing the CLI never
* sends a fabricated empty credential to the wire.
*/
@Test
void authenticateUserRejectsBlankPasswordValue() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password", "",
"--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
"--json");
assertNotEquals(0, run.exitCode(), "a blank credential must fail");
assertNull(factory.client, "the CLI must not connect without a credential");
}
/**
* CLI-45: {@code --password-env} defaults to the canonical
* {@code MXGATEWAY_VERIFY_PASSWORD}, so the usage error names it when no
* explicit variable is given. Skipped if the canonical variable happens to be
* exported in the running environment (which would satisfy the credential).
*/
@Test
void authenticateUserDefaultsToCanonicalPasswordEnvName() {
assumeTrue(System.getenv(MxGatewayCli.DEFAULT_VERIFY_PASSWORD_ENV) == null);
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--json");
assertNotEquals(0, run.exitCode());
assertTrue(run.errors().contains("MXGATEWAY_VERIFY_PASSWORD"), run.errors());
}
// ---- ping subcommand (D4) ----
@Test
+7 -1
View File
@@ -187,7 +187,13 @@ await session.write_secured(
```
The CLI mirrors these as `authenticate-user` (credential via `--password` or,
preferably, `--password-env`) and `write-secured`.
preferably, the variable named by `--password-env`, default
`MXGATEWAY_VERIFY_PASSWORD`) and `write-secured`. The credential is required: a
missing or empty resolved value raises a `UsageError` naming the option and the
variable, so the CLI fails before connecting instead of authenticating with an
empty password. `MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all
five client CLIs — see
[Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
### Array writes replace the whole array
@@ -32,6 +32,11 @@ logger = logging.getLogger(__name__)
MAX_AGGREGATE_EVENTS = 10_000
#: Canonical CLI credential environment variable, shared by every official client
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
#: five languages.
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
_BATCH_EOR = "__MXGW_BATCH_EOR__"
@@ -328,7 +333,8 @@ def write_secured(**kwargs: Any) -> None:
)
@click.option(
"--password-env",
default=None,
default=DEFAULT_VERIFY_PASSWORD_ENV,
show_default=True,
help="Environment variable holding the user password.",
)
@click.option("--correlation-id", default="", help="Client correlation id.")
@@ -834,17 +840,23 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
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.
Prefers the explicit flag, then falls back to the environment variable named
by ``--password-env`` (default :data:`DEFAULT_VERIFY_PASSWORD_ENV`). A missing
*or empty* value from either source is a usage error (CLI-45): the CLI never
sends a fabricated empty credential to the wire. The error names the option
and the variable only the resolved secret is never echoed, and callers pass
it into the ``secrets`` redaction list so it cannot leak through a surfaced
error either.
"""
env_name = kwargs.get("password_env") or DEFAULT_VERIFY_PASSWORD_ENV
password = kwargs.get("password")
if not password:
env_name = kwargs.get("password_env")
password = os.environ.get(env_name) if env_name else None
password = os.environ.get(env_name)
if not password:
raise click.UsageError("a password is required via --password or --password-env")
raise click.UsageError(
f"a password is required via --password or the {env_name} environment variable"
)
return password
+78 -1
View File
@@ -752,7 +752,9 @@ def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPat
assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw"
def test_authenticate_user_requires_a_password() -> None:
def test_authenticate_user_requires_a_password(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("MXGATEWAY_VERIFY_PASSWORD", raising=False)
result = CliRunner().invoke(
main,
[
@@ -770,6 +772,81 @@ def test_authenticate_user_requires_a_password() -> None:
assert result.exit_code != 0
assert "password is required" in result.output
# CLI-45: the usage error names the option and the canonical env var.
assert "--password" in result.output
assert "MXGATEWAY_VERIFY_PASSWORD" in result.output
def test_authenticate_user_reads_password_from_canonical_default_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CLI-45: --password-env defaults to MXGATEWAY_VERIFY_PASSWORD.
Exporting the canonical variable alone must satisfy the credential, with no
explicit --password-env flag the same operator workflow as the other CLIs.
"""
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=11),
)
fake = _FakeInvokeClient(reply)
async def fake_connect(options, **_kwargs):
return fake
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
monkeypatch.setenv("MXGATEWAY_VERIFY_PASSWORD", "canonical-env-pw")
result = CliRunner().invoke(
main,
[
"authenticate-user",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--verify-user",
"operator",
"--json",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output)["userId"] == 11
assert "canonical-env-pw" not in result.output
assert fake.last_request.command.authenticate_user.verify_user_password == "canonical-env-pw"
def test_authenticate_user_rejects_empty_password_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CLI-45: an empty resolved credential fails fast, never reaching the wire."""
monkeypatch.setenv("MXGATEWAY_VERIFY_PASSWORD", "")
result = CliRunner().invoke(
main,
[
"authenticate-user",
"--plaintext",
"--session-id",
"s1",
"--server-handle",
"3",
"--verify-user",
"operator",
"--password",
"",
"--json",
],
)
assert result.exit_code != 0
assert "password is required" in result.output
def test_write_secured_command_does_not_echo_value_on_failure(
+6 -1
View File
@@ -216,7 +216,12 @@ 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`.
variable named by `--password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, never
echoed) and `write-secured`. The credential is required: a missing or empty
resolved value is a usage error naming the flag and the variable, so the CLI
fails before dialing instead of authenticating with an empty password.
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
The remaining single-item command helpers round out MXAccess parity:
`unregister`, `suspend` / `activate` (each returns the operation's
+86 -11
View File
@@ -752,17 +752,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
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 verify_user_password = resolve_verify_user_password(password, &password_env)?;
let session = session_for(connection, session_id).await?;
let user_id = session
.authenticate_user(server_handle, &verify_user, &verify_user_password)
@@ -1736,6 +1726,31 @@ fn print_ok(operation: &str, use_json: bool) {
}
}
/// Resolves the `authenticate-user` credential from `--password`, falling back to
/// the environment variable named by `--password-env` (default
/// `MXGATEWAY_VERIFY_PASSWORD`).
///
/// An empty value from either source counts as missing (CLI-45): the CLI fails
/// fast with a usage error rather than sending a fabricated empty credential to
/// the wire. The error names the flag and the variable only — never the value,
/// which is never echoed to stdout/stderr or embedded in an error message.
fn resolve_verify_user_password(
password: Option<String>,
password_env: &str,
) -> Result<String, Error> {
password
.filter(|value| !value.is_empty())
.or_else(|| {
env::var(password_env)
.ok()
.filter(|value| !value.is_empty())
})
.ok_or_else(|| Error::InvalidArgument {
name: "password".to_owned(),
detail: format!("supply --password or set the environment variable `{password_env}`"),
})
}
fn print_bulk_results(
operation: &str,
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
@@ -2617,6 +2632,66 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
/// CLI-45: `--password-env` must default to the canonical
/// `MXGATEWAY_VERIFY_PASSWORD` shared by every official client CLI.
#[test]
fn authenticate_user_password_env_defaults_to_canonical_name() {
let parsed = Cli::try_parse_from([
"mxgw",
"authenticate-user",
"--session-id",
"session-1",
"--server-handle",
"7",
"--verify-user",
"verifier",
])
.expect("parse");
match parsed.command {
Command::AuthenticateUser { password_env, .. } => {
assert_eq!(password_env, "MXGATEWAY_VERIFY_PASSWORD");
}
other => panic!("expected authenticate-user, got {other:?}"),
}
}
/// CLI-45: a credential that resolves to an empty string — whether from the
/// flag or from the named environment variable — is treated as missing, so
/// the CLI never sends a fabricated empty password to the wire. The usage
/// error names the flag and the variable, never a value.
#[test]
fn resolve_verify_user_password_rejects_missing_and_empty_values() {
const ABSENT: &str = "MXGW_CLI45_ABSENT_PASSWORD_VAR";
const EMPTY: &str = "MXGW_CLI45_EMPTY_PASSWORD_VAR";
const PRESENT: &str = "MXGW_CLI45_PRESENT_PASSWORD_VAR";
std::env::remove_var(ABSENT);
std::env::set_var(EMPTY, "");
std::env::set_var(PRESENT, "env-sourced-credential");
for (password, env_name) in [
(None, ABSENT),
(Some(String::new()), ABSENT),
(None, EMPTY),
(Some(String::new()), EMPTY),
] {
let error = super::resolve_verify_user_password(password, env_name)
.expect_err("empty or missing credential must be a usage error");
let rendered = error.to_string();
assert!(rendered.contains("--password"), "{rendered}");
assert!(rendered.contains(env_name), "{rendered}");
}
assert_eq!(
super::resolve_verify_user_password(None, PRESENT).expect("env credential"),
"env-sourced-credential"
);
assert_eq!(
super::resolve_verify_user_password(Some("flag-credential".to_owned()), EMPTY)
.expect("flag credential"),
"flag-credential"
);
}
#[test]
fn parses_write_secured_command() {
let parsed = Cli::try_parse_from([