c8f31bd653
WriteCommand grows three new options:
--secured Route the write through
LMXProxyServer.WriteSecured(currentUserId,
verifierUserId, value) instead of plain
Write(value, userId). Required for attributes
classified as Secured Write or Verified Write,
and useful for testing whether the audit
subsystem propagates user identity when
explicitly told the write is "secured".
--verifier-username Galaxy / OS username of the verifier for a
two-person Verified Write. Implies --secured.
--verifier-domain Domain composed with --verifier-username as
'<domain>\<username>'.
--verifier-password Verifier password. Redacted in the JSON
query echo.
When --secured is on without a verifier, the same auth_user_id is
used for both currentUserId and verifierUserId (single-user Secured
Write semantics). When a verifier is provided, the CLI authenticates
both users and bails cleanly with "verifier-authentication-failed"
on a verifier credential mismatch.
The JSON envelope's results[] gains `secured` and `verifier_user_id`
fields so an agent can confirm which path ran.
MxItem grows WriteSecured(value, currentUserId, verifierUserId).
Verified live against TestMachine_001.TestAlarm002.AckMsg under
eOSUserBased + ArchestraUsers role: --secured succeeds with
auth_user_id=1, verifier_user_id=1, MxCategoryOk. User_Name in the
Historian Events row remains NULL — same as plain Write. The
audit-attribution gate is not Write vs WriteSecured; running engines
likely still need a redeploy to pick up the new security mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
13 KiB
C#
275 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using CliFx;
|
|
using CliFx.Attributes;
|
|
using CliFx.Exceptions;
|
|
using CliFx.Infrastructure;
|
|
using MxAccess.Cli.Mx;
|
|
using MxAccess.Cli.Output;
|
|
|
|
namespace MxAccess.Cli.Commands
|
|
{
|
|
[Command("write", Description = "Write a scalar or array value to a tag and wait for OnWriteComplete.")]
|
|
public sealed class WriteCommand : ICommand
|
|
{
|
|
[CommandParameter(0, Name = "tag", Description = "Tag reference to write to. Use '<obj>.<attr>[]' to write a whole array.")]
|
|
public string Tag { get; init; }
|
|
|
|
[CommandParameter(1, Name = "values", Description = "Value(s) to write. One value for a scalar tag; multiple values for an array tag (the count must match the configured array dimension). Inferred as bool / int / double / string unless --type is set.")]
|
|
public IReadOnlyList<string> Values { get; init; }
|
|
|
|
[CommandOption("type", Description = "Force the .NET type used for the value(s): bool, byte, short, int, long, float, double, string, datetime.")]
|
|
public string TypeHint { get; init; }
|
|
|
|
[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 = "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";
|
|
|
|
[CommandOption("secured", Description = "Route the write through LMXProxyServer.WriteSecured(currentUserId, verifierUserId, value) instead of Write(value, userId). Required for attributes whose security classification is Secured Write or Verified Write, and may be required for the engine audit trail to attribute the write to the authenticated user. Defaults to single-user Secured (currentUserId == verifierUserId == auth_user_id); see --verifier-username for two-person Verified Write.")]
|
|
public bool Secured { get; init; }
|
|
|
|
[CommandOption("verifier-username", Description = "Galaxy / OS username of the verifier for a two-person Verified Write. Implies --secured. Combined with --verifier-domain as '<domain>\\<username>' just like the operator credential set.")]
|
|
public string VerifierUsername { get; init; }
|
|
|
|
[CommandOption("verifier-domain", Description = "Domain or hostname for the verifier OS user. Combined with --verifier-username.")]
|
|
public string VerifierDomain { get; init; }
|
|
|
|
[CommandOption("verifier-password", Description = "Password for the verifier user. Redacted in the JSON query echo.")]
|
|
public string VerifierPassword { get; init; }
|
|
|
|
[CommandOption("llm-json", Description = "Emit the JSON envelope instead of human-readable status.")]
|
|
public bool LlmJson { get; init; }
|
|
|
|
public ValueTask ExecuteAsync(IConsole console)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(Tag))
|
|
throw new CommandException("Tag reference is required.", 2);
|
|
if (Values == null || Values.Count == 0)
|
|
throw new CommandException("At least one value is required.", 2);
|
|
if (TimeoutSeconds <= 0)
|
|
throw new CommandException("--timeout must be positive.", 2);
|
|
|
|
// Decide scalar vs. array. The tag form `<obj>.<attr>[]` is the
|
|
// explicit array reference for whole-array writes (matches the
|
|
// read-side bracket convention). Multiple values without `[]`
|
|
// is an error: an indexed `[N]` reference takes one value.
|
|
bool isArrayWrite = Tag.EndsWith("[]", StringComparison.Ordinal);
|
|
if (!isArrayWrite && Values.Count > 1)
|
|
throw new CommandException(
|
|
"Multiple values supplied for a non-array reference. Use '<tag>[]' to write a whole array, or supply a single value for an indexed / scalar tag.", 2);
|
|
|
|
object coerced = isArrayWrite
|
|
? 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}";
|
|
}
|
|
string verifierVerifyUser = null;
|
|
if (!string.IsNullOrEmpty(VerifierUsername))
|
|
{
|
|
verifierVerifyUser = string.IsNullOrEmpty(VerifierDomain)
|
|
? VerifierUsername
|
|
: $@"{VerifierDomain}\{VerifierUsername}";
|
|
}
|
|
// A verifier implies --secured, even if not explicitly set.
|
|
bool useSecured = Secured || verifierVerifyUser != null;
|
|
|
|
var query = new
|
|
{
|
|
command = "write",
|
|
tag = Tag,
|
|
array = isArrayWrite,
|
|
value = coerced,
|
|
type = string.IsNullOrEmpty(TypeHint) ? coerced.GetType().Name : TypeHint,
|
|
count = Values.Count,
|
|
timeout_s = TimeoutSeconds,
|
|
user_id = UserId,
|
|
verify_user = verifyUser,
|
|
verifier_verify_user = verifierVerifyUser,
|
|
secured = useSecured,
|
|
// Never echo the plaintext password — agents replay query
|
|
// envelopes and would otherwise leak credentials.
|
|
password = string.IsNullOrEmpty(Password) ? null : "***",
|
|
verifier_password = string.IsNullOrEmpty(VerifierPassword) ? null : "***",
|
|
client = ClientName,
|
|
};
|
|
|
|
using var session = new MxSession(ClientName);
|
|
MxItem item = null;
|
|
int effectiveUserId = UserId;
|
|
int verifierUserId = 0;
|
|
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;
|
|
}
|
|
}
|
|
if (verifierVerifyUser != null)
|
|
{
|
|
verifierUserId = session.Authenticate(verifierVerifyUser, VerifierPassword ?? string.Empty);
|
|
if (verifierUserId == 0)
|
|
{
|
|
EmitFailure(console, query, "verifier-authentication-failed", Array.Empty<MxStatusInfo>());
|
|
Environment.ExitCode = 1;
|
|
return default;
|
|
}
|
|
}
|
|
else if (useSecured)
|
|
{
|
|
// Single-user Secured Write — both ids are the operator.
|
|
verifierUserId = effectiveUserId;
|
|
}
|
|
|
|
item = session.AddItem(Tag);
|
|
|
|
// Advise + wait for first OnDataChange to ensure the proxy has
|
|
// the attribute type / data quality resolved. Without this the
|
|
// first Write() throws ArgumentException "Value does not fall
|
|
// within the expected range" because the proxy doesn't yet
|
|
// know the destination type.
|
|
//
|
|
// Pick the advise variant based on whether a user was supplied:
|
|
// - --username given → Advise (operator action, attribute
|
|
// the write to the authenticated Galaxy user in the audit
|
|
// trail).
|
|
// - anonymous → AdviseSupervisory (the write is on
|
|
// behalf of the hosting client itself; the audit trail
|
|
// records it as a supervisory action without trying to
|
|
// attribute it to a specific user).
|
|
//
|
|
// Caveat: a bare-array reference (no brackets) will return
|
|
// MxCategoryCommunicationError, Detail=1003 here — same as on
|
|
// a `read` of that form. Tag the user-facing error so the
|
|
// failure mode is recognizable.
|
|
if (verifyUser != null) item.Advise();
|
|
else item.AdviseSupervisory();
|
|
var resolveTimeout = TimeSpan.FromSeconds(TimeoutSeconds);
|
|
if (!session.WaitForUpdate(
|
|
u => u.Kind == MxUpdateKind.DataChange && u.ItemHandle == item.Handle,
|
|
resolveTimeout, out var resolveUpdate))
|
|
{
|
|
EmitFailure(console, query, "timeout-resolving-type", Array.Empty<MxStatusInfo>());
|
|
Environment.ExitCode = 1;
|
|
return default;
|
|
}
|
|
if (!resolveUpdate.IsOk)
|
|
{
|
|
EmitFailure(console, query, "type-resolution-failed", resolveUpdate.Statuses);
|
|
Environment.ExitCode = 1;
|
|
return default;
|
|
}
|
|
|
|
if (useSecured)
|
|
item.WriteSecured(coerced, effectiveUserId, verifierUserId);
|
|
else
|
|
item.Write(coerced, effectiveUserId);
|
|
|
|
var got = session.WaitForUpdate(
|
|
u => u.Kind == MxUpdateKind.WriteComplete && u.ItemHandle == item.Handle,
|
|
TimeSpan.FromSeconds(TimeoutSeconds),
|
|
out var ack);
|
|
|
|
bool ok;
|
|
object[] results;
|
|
if (!got)
|
|
{
|
|
ok = false;
|
|
results = new object[] { new { tag = Tag, ok = false, error = "timeout", statuses = Array.Empty<MxStatusInfo>() } };
|
|
}
|
|
else
|
|
{
|
|
ok = ack.IsOk;
|
|
results = new object[]
|
|
{
|
|
new
|
|
{
|
|
tag = Tag,
|
|
ok = ack.IsOk,
|
|
error = ack.IsOk ? null : "write-failed",
|
|
authenticated = verifyUser != null,
|
|
auth_user_id = verifyUser != null ? (int?)effectiveUserId : null,
|
|
secured = useSecured,
|
|
verifier_user_id = useSecured ? (int?)verifierUserId : null,
|
|
statuses = ack.Statuses,
|
|
}
|
|
};
|
|
}
|
|
|
|
if (LlmJson)
|
|
{
|
|
Envelope.Write(console, query, ok, results);
|
|
}
|
|
else if (ok)
|
|
{
|
|
var rendered = isArrayWrite
|
|
? $"[{string.Join(", ", Values)}] ({Values.Count} elements)"
|
|
: Values[0];
|
|
var authNote = verifyUser != null
|
|
? $" (as {verifyUser}, userId={effectiveUserId})"
|
|
: "";
|
|
console.Output.WriteLine($"[OK ] write {Tag} = {rendered}{authNote}");
|
|
}
|
|
else
|
|
{
|
|
var err = (string)((dynamic)results[0]).error ?? "unknown";
|
|
console.Error.WriteLine($"[ERR] write {Tag}: {err}");
|
|
}
|
|
|
|
if (!ok) Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
item?.Dispose();
|
|
}
|
|
return default;
|
|
}
|
|
|
|
private void EmitFailure(IConsole console, object query, string error, MxStatusInfo[] statuses)
|
|
{
|
|
if (LlmJson)
|
|
{
|
|
Envelope.Write(console, query, ok: false,
|
|
results: new object[] { new { tag = Tag, ok = false, error, statuses } });
|
|
}
|
|
else
|
|
{
|
|
console.Error.WriteLine($"[ERR] write {Tag}: {error}");
|
|
}
|
|
}
|
|
}
|
|
}
|