From 0d25ec445fedb648a9437062f162abe08f43184f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 3 May 2026 22:00:06 -0400 Subject: [PATCH] mxaccesscli: add --username / --domain / --password to write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps LMXProxyServer.AuthenticateUser at the session level (MxSession.Authenticate) and surfaces three new options on the write command: -u, --username galaxy / OS user --domain composed as \; omit for galaxy-authenticated logins or UPN forms -p, --password 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 = (as , 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) --- mxaccesscli/docs/usage.md | 60 ++++++++++++++++++- .../src/MxAccess.Cli/Commands/WriteCommand.cs | 59 +++++++++++++++--- mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs | 16 +++++ 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/mxaccesscli/docs/usage.md b/mxaccesscli/docs/usage.md index 67bbd4d..f4b81b0 100644 --- a/mxaccesscli/docs/usage.md +++ b/mxaccesscli/docs/usage.md @@ -66,9 +66,12 @@ Writes one value to one tag and waits for `OnWriteComplete`. | --- | --- | --- | | `--type ` | inferred | Force the .NET type used for the boxed value. One of `bool`, `byte`, `short`, `int`, `long`, `float`, `double`, `string`, `datetime`. | | `-t`, `--timeout ` | `5` | How long to wait for `OnWriteComplete`. | -| `--user-id ` | `0` | Authenticated user id. `0` is unauthenticated; secured attributes will reject. | +| `--user-id ` | `0` | Pre-resolved authenticated user id passed straight to `Write()`. Use only when you already have a userId. `0` = unauthenticated. | +| `-u`, `--username ` | (none) | Galaxy / OS username. Combined with `--domain` (if set) into `\` and resolved to a userId via `AuthenticateUser` before `Write()`. See [Authentication](#authentication). | +| `--domain ` | (none) | Domain or hostname for OS-authenticated galaxies. Combined with `--username` as `\`. Omit for galaxy-authenticated logins. | +| `-p`, `--password ` | (none) | Password for `--username`. Redacted (`***`) in the LLM-JSON `query` echo. | | `--client ` | `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 , 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 ` to subsequent `write` invocations. This skips the per-call auth round-trip. + ## `mxa subscribe [...]` Streams `OnDataChange` events for a duration. diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs index 13ae9bb..72cbe48 100644 --- a/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs +++ b/mxaccesscli/src/MxAccess.Cli/Commands/WriteCommand.cs @@ -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 '\\' 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 '\\'. 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 `\`; galaxy- + // authenticated ones want bare ``. 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()); + 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 { diff --git a/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs b/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs index 75497e0..8452804 100644 --- a/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs +++ b/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs @@ -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 → `\` + /// - galaxyAuthenticationMode → `` only + /// - mixed/AAD → `` (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 match, TimeSpan timeout, out MxUpdate captured)