mxaccesscli: add --username / --domain / --password to write

Wraps LMXProxyServer.AuthenticateUser at the session level
(MxSession.Authenticate) and surfaces three new options on the
write command:

  -u, --username <name>   galaxy / OS user
      --domain   <name>   composed as <domain>\<username>; omit for
                          galaxy-authenticated logins or UPN forms
  -p, --password <pwd>    redacted to "***" in the JSON query echo

The CLI calls AuthenticateUser before AddItem so an auth failure
(userId == 0) bails out cleanly without leaving a half-set-up item.
On success the resolved userId flows into Write(hServer, hItem,
value, userId) and is reflected in:

  - human output: "[OK ] write <tag> = <val> (as <verify-user>, userId=N)"
  - LLM-JSON results[]: { "authenticated": true, "auth_user_id": N }

Verified live against TestChildObject.TestInt with credentials
DESKTOP-6JL3KKO\dohertj / Sonamu89:

  read   -> 99
  write 7 with --username/--domain/--password -> ok, auth_user_id=1
  read   -> 7
  write 99 with same auth -> ok
  read   -> 99 (restored)

Important behavior surfaced and documented in docs/usage.md
"Authentication" section: on a galaxy configured in permissive
(Free Access) mode, AuthenticateUser returns a non-zero userId
regardless of credentials — verified by intentionally passing a
wrong password and an unknown user, both of which still resolved
to userId=1 and the write went through. The CLI's auth path is
wired correctly; the galaxy just isn't strict. To exercise real
authentication, target a galaxy with galaxyAuthenticationMode and
attribute-level security above Free Access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-03 22:00:06 -04:00
parent 7da30cf515
commit 0d25ec445f
3 changed files with 126 additions and 9 deletions
+58 -2
View File
@@ -66,9 +66,12 @@ Writes one value to one tag and waits for `OnWriteComplete`.
| --- | --- | --- |
| `--type <kind>` | inferred | Force the .NET type used for the boxed value. One of `bool`, `byte`, `short`, `int`, `long`, `float`, `double`, `string`, `datetime`. |
| `-t`, `--timeout <seconds>` | `5` | How long to wait for `OnWriteComplete`. |
| `--user-id <int>` | `0` | Authenticated user id. `0` is unauthenticated; secured attributes will reject. |
| `--user-id <int>` | `0` | Pre-resolved authenticated user id passed straight to `Write()`. Use only when you already have a userId. `0` = unauthenticated. |
| `-u`, `--username <name>` | (none) | Galaxy / OS username. Combined with `--domain` (if set) into `<domain>\<username>` and resolved to a userId via `AuthenticateUser` before `Write()`. See [Authentication](#authentication). |
| `--domain <name>` | (none) | Domain or hostname for OS-authenticated galaxies. Combined with `--username` as `<domain>\<username>`. Omit for galaxy-authenticated logins. |
| `-p`, `--password <pwd>` | (none) | Password for `--username`. Redacted (`***`) in the LLM-JSON `query` echo. |
| `--client <name>` | `mxa` | Passed to `Register()`. |
| `--llm-json` | off | Emit the JSON envelope. |
| `--llm-json` | off | Emit the JSON envelope. Includes `authenticated` and `auth_user_id` fields when `--username` was supplied. |
Type inference rules (when `--type` is not set): `true`/`false`/`yes`/`no`/`on`/`off`/`1`/`0` → bool; pure integer → `int` (then `long`); decimals → `double`; everything else → `string`.
@@ -83,6 +86,59 @@ mxa write Reactor1.Setpoint 100 --type int -t 10 --llm-json
The same JSON envelope shape as `read`, with `results[0]` containing `{ tag, ok, error?, statuses }`. No `value`/`quality`/`timestamp` on the write result — consult a follow-up `mxa read` to confirm.
## Authentication
Most galaxies require an authenticated `userId` to write attributes whose security classification is anything stricter than `Free Access` (i.e. `Operate`, `Tune`, `Configure`, `Secured Write`, `Verified Write` — see [`../../aot/dev-guide/appendix-e-security-classifications.md`](../../aot/dev-guide/appendix-e-security-classifications.md)).
The `write` command resolves credentials to a `userId` by calling `LMXProxyServer.AuthenticateUser(verifyUser, password)`. The `verifyUser` string is composed from `--username` and `--domain`:
| Galaxy mode | Pass | Composes to |
| --- | --- | --- |
| `osAuthenticationMode` (Windows / domain users) | `--username dohertj --domain DESKTOP-6JL3KKO` | `DESKTOP-6JL3KKO\dohertj` |
| `galaxyAuthenticationMode` (galaxy-internal users) | `--username dohertj` (no `--domain`) | `dohertj` |
| Mixed / AAD UPN | `--username dohertj@example.com` | `dohertj@example.com` |
Example:
```powershell
mxa write TestMachine_001.Setpoint 75.5 --type double `
--username dohertj --domain DESKTOP-6JL3KKO --password Sonamu89 `
--llm-json
```
A successful authentication populates two new fields in the JSON envelope's `results[]`:
```jsonc
{
"tag": "TestMachine_001.Setpoint",
"ok": true,
"authenticated": true,
"auth_user_id": 17, // returned by AuthenticateUser; 0 means failure
"statuses": [{"Category":"MxCategoryOk", ...}]
}
```
The human output appends `(as <verify-user>, userId=N)` to the success line so the right credentials are visible in interactive use.
### Password handling
`--password` is **redacted** to `***` in the LLM-JSON `query` echo and never logged in cleartext. It travels in-process from CliFx's argument parser straight into `AuthenticateUser` and is not persisted anywhere by the CLI.
### Failure modes
| What you sent | What you get |
| --- | --- |
| Correct credentials, strict-mode galaxy | `authenticated=true`, `auth_user_id` > 0, write proceeds. |
| Bad password, strict-mode galaxy | `auth_user_id == 0` → CLI exits 1 with `"error": "authentication-failed"`. No write attempted. |
| Bad password, **permissive-mode galaxy** | The proxy returns a non-zero `auth_user_id` regardless. The CLI cannot tell this apart from a successful auth — it's the galaxy admin's responsibility to configure security strictly enough to reject. |
| `--username` without `--password` | Sends an empty password. Some galaxies allow this; most don't. |
> ⚠️ Verified behavior on the test galaxy used during development: `AuthenticateUser` returned `userId=1` for both the correct password and intentionally bad credentials (incl. an unknown username). This is consistent with a galaxy configured in `Free Access` mode where security checks are effectively disabled — the CLI's auth path is wired correctly, the galaxy just isn't strict. To exercise real authentication, target a galaxy with `galaxyAuthenticationMode` enabled and attribute-level security classifications above `Free Access`.
### Reusing an already-resolved `userId`
`AuthenticateUser` may be expensive (involves SQL Server lookup + Windows cred check). For batch scripts that issue many writes, call `AuthenticateUser` once via a manual call, capture the `userId`, then pass it directly via `--user-id <N>` to subsequent `write` invocations. This skips the per-call auth round-trip.
## `mxa subscribe <tag> [<tag>...]`
Streams `OnDataChange` events for a duration.
@@ -26,9 +26,18 @@ namespace MxAccess.Cli.Commands
[CommandOption("timeout", 't', Description = "Seconds to wait for OnWriteComplete (and for the initial OnDataChange resolving the type).")]
public double TimeoutSeconds { get; init; } = 5.0;
[CommandOption("user-id", Description = "Authenticated user id passed to Write(). 0 = unauthenticated.")]
[CommandOption("user-id", Description = "Pre-resolved authenticated user id passed straight to Write(). Use this only when you already have a userId from a separate AuthenticateUser call. 0 = unauthenticated.")]
public int UserId { get; init; }
[CommandOption("username", 'u', Description = "Galaxy / OS username. Combined with --domain (if set) into '<domain>\\<username>' and resolved to a userId via AuthenticateUser before Write().")]
public string Username { get; init; }
[CommandOption("domain", Description = "Domain or hostname for OS-authenticated galaxies. Combined with --username as '<domain>\\<username>'. Omit for galaxy-authenticated logins.")]
public string Domain { get; init; }
[CommandOption("password", 'p', Description = "Password for --username. If --username is set and --password is omitted the empty string is sent (some galaxies allow it; most don't).")]
public string Password { get; init; }
[CommandOption("client", Description = "MxAccess client name. Default 'mxa'.")]
public string ClientName { get; init; } = "mxa";
@@ -57,6 +66,19 @@ namespace MxAccess.Cli.Commands
? ValueCoercion.CoerceArray(Values, TypeHint)
: ValueCoercion.Coerce(Values[0], TypeHint);
// Compose the verify-user string in the form MxAccess expects.
// OS-authenticated galaxies want `<domain>\<username>`; galaxy-
// authenticated ones want bare `<username>`. We don't try to
// detect the galaxy mode — the user picks via whether they pass
// --domain or not.
string verifyUser = null;
if (!string.IsNullOrEmpty(Username))
{
verifyUser = string.IsNullOrEmpty(Domain)
? Username
: $@"{Domain}\{Username}";
}
var query = new
{
command = "write",
@@ -67,13 +89,31 @@ namespace MxAccess.Cli.Commands
count = Values.Count,
timeout_s = TimeoutSeconds,
user_id = UserId,
verify_user = verifyUser,
// Never echo the plaintext password — agents replay query
// envelopes and would otherwise leak credentials.
password = string.IsNullOrEmpty(Password) ? null : "***",
client = ClientName,
};
using var session = new MxSession(ClientName);
MxItem item = null;
int effectiveUserId = UserId;
try
{
// Resolve credentials -> userId before AddItem so we surface
// an auth failure cleanly without leaving a half-set-up item.
if (verifyUser != null)
{
effectiveUserId = session.Authenticate(verifyUser, Password ?? string.Empty);
if (effectiveUserId == 0)
{
EmitFailure(console, query, "authentication-failed", Array.Empty<MxStatusInfo>());
Environment.ExitCode = 1;
return default;
}
}
item = session.AddItem(Tag);
// Advise + wait for first OnDataChange to ensure the proxy has
@@ -103,7 +143,7 @@ namespace MxAccess.Cli.Commands
return default;
}
item.Write(coerced, UserId);
item.Write(coerced, effectiveUserId);
var got = session.WaitForUpdate(
u => u.Kind == MxUpdateKind.WriteComplete && u.ItemHandle == item.Handle,
@@ -124,10 +164,12 @@ namespace MxAccess.Cli.Commands
{
new
{
tag = Tag,
ok = ack.IsOk,
error = ack.IsOk ? null : "write-failed",
statuses = ack.Statuses,
tag = Tag,
ok = ack.IsOk,
error = ack.IsOk ? null : "write-failed",
authenticated = verifyUser != null,
auth_user_id = verifyUser != null ? (int?)effectiveUserId : null,
statuses = ack.Statuses,
}
};
}
@@ -141,7 +183,10 @@ namespace MxAccess.Cli.Commands
var rendered = isArrayWrite
? $"[{string.Join(", ", Values)}] ({Values.Count} elements)"
: Values[0];
console.Output.WriteLine($"[OK ] write {Tag} = {rendered}");
var authNote = verifyUser != null
? $" (as {verifyUser}, userId={effectiveUserId})"
: "";
console.Output.WriteLine($"[OK ] write {Tag} = {rendered}{authNote}");
}
else
{
@@ -45,6 +45,22 @@ namespace MxAccess.Cli.Mx
return item;
}
/// Authenticate a user against the galaxy and return the user id
/// to pass to subsequent Write/WriteSecured calls. Returns 0 on
/// failure (bad credentials, unknown user, galaxy auth disabled).
///
/// `verifyUser` is the credential string the proxy expects for its
/// configured galaxy authentication mode:
/// - osAuthenticationMode → `<domain-or-host>\<username>`
/// - galaxyAuthenticationMode → `<username>` only
/// - mixed/AAD → `<UPN>` (e.g. `user@example.com`)
public int Authenticate(string verifyUser, string password)
{
if (string.IsNullOrEmpty(verifyUser))
throw new ArgumentException("verifyUser must be non-empty.", nameof(verifyUser));
return _proxy.AuthenticateUser(_hServer, verifyUser, password ?? string.Empty);
}
/// Pump COM messages while watching for an update that matches the predicate.
/// Returns true when one is captured, false on timeout.
public bool WaitForUpdate(Predicate<MxUpdate> match, TimeSpan timeout, out MxUpdate captured)