feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
v2-ci / unit-tests (push) Failing after 1h8m43s

mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:

1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
   held ONLY in the encrypted Secrets store
   (otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
   no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
   FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
   env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
   operator_user. Refusals are excluded from the resilience retry/breaker.

- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
  nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
  RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
  is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
  ("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
  account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
  acknowledger scrubs it from every gateway message it echoes and withholds
  any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
  Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
  path is fire-and-forget after the Part 9 method returns).

Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.

Closes #749
This commit is contained in:
Joseph Doherty
2026-11-06 16:23:00 -05:00
parent 7ec5b95d24
commit dc0a24f224
38 changed files with 1814 additions and 83 deletions
@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// The Secrets-store naming convention for an API key's Galaxy alarm-ack identity (lmxopcua#749).
/// Shared by the AdminUI <c>/api-keys</c> page (writer) and the Galaxy driver (reader) so the two can
/// never disagree on a name.
/// </summary>
/// <remarks>
/// <para>
/// Both halves live in the encrypted <c>ZB.MOM.WW.Secrets</c> store — nothing about the identity is
/// written to the config DB or the deployment artifact. The identity secret holds the Galaxy account
/// as <c>DOMAIN\user</c> or bare <c>user</c>; the password secret holds the password (may be empty —
/// Galaxy accounts can have empty passwords; an <i>absent</i> password secret is a refusal).
/// </para>
/// <para>
/// ⚠️ Secrets are node-local (optionally Akka-replicated within one mesh). The secret must exist on
/// the node that serves the OPC UA session and runs the Galaxy driver; on a split admin/driver
/// topology create it on the driver nodes too (their <c>/admin/secrets</c> page or the
/// <c>secret</c> CLI) under exactly these names.
/// </para>
/// </remarks>
public static class GalaxyAckIdentitySecrets
{
/// <summary>Common prefix for every per-key secret.</summary>
public const string Prefix = "otopcua/apikeys/";
/// <summary>Secret holding the key's Galaxy account (<c>DOMAIN\user</c> or <c>user</c>).</summary>
/// <param name="apiKeyId">The public API key id.</param>
/// <returns>The secret name.</returns>
public static string UserSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-user";
/// <summary>Secret holding the key's Galaxy account password.</summary>
/// <param name="apiKeyId">The public API key id.</param>
/// <returns>The secret name.</returns>
public static string PasswordSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-password";
/// <summary>Formats a user + optional domain as the identity secret value.</summary>
/// <param name="user">The Galaxy user name.</param>
/// <param name="domain">The optional domain.</param>
/// <returns><c>DOMAIN\user</c> or <c>user</c>.</returns>
public static string FormatIdentity(string user, string? domain) =>
string.IsNullOrWhiteSpace(domain) ? user.Trim() : $"{domain.Trim()}\\{user.Trim()}";
/// <summary>Splits an identity secret value into user + domain on the first backslash.</summary>
/// <param name="identity">The stored identity value.</param>
/// <returns>The user (trimmed) and the domain (empty when none).</returns>
public static (string User, string Domain) ParseIdentity(string identity)
{
var value = (identity ?? string.Empty).Trim();
var slash = value.IndexOf('\\');
return slash < 0
? (value, string.Empty)
: (value[(slash + 1)..].Trim(), value[..slash].Trim());
}
private static string Normalize(string apiKeyId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(apiKeyId);
var id = apiKeyId.Trim().ToLowerInvariant();
foreach (var c in id)
{
// Key ids are lowercase hex (ApiKeySecret.Generate); refuse anything that could change the
// secret-name path shape (a '/', '..') rather than trusting the caller.
if (!char.IsAsciiLetterOrDigit(c))
throw new ArgumentException($"API key id '{apiKeyId}' is not a valid key id.", nameof(apiKeyId));
}
return id;
}
}
@@ -39,6 +39,47 @@ public sealed record GalaxyDriverOptions(
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Operator-credential policy for native alarm acknowledgement (lmxopcua#749). mxaccessgw
/// (488649c+) acknowledges by authenticating as the operator and writing <c>&lt;alarm&gt;.AckMsg</c>,
/// so every ack needs a Galaxy user + password. See <see cref="GalaxyAlarmAckOptions"/>.
/// </summary>
public GalaxyAlarmAckOptions AlarmAck { get; init; } = new();
}
/// <summary>
/// Where the Galaxy credential for a native alarm acknowledge comes from (lmxopcua#749).
/// </summary>
/// <remarks>
/// <para>
/// The OPC UA data plane is machine-to-machine (API key or anonymous), so the session carries no
/// Galaxy password. Resolution order per acknowledge:
/// <list type="number">
/// <item>The authenticating API key's own Galaxy ack identity, set in AdminUI <c>/api-keys</c> and
/// held in the Secrets store under <see cref="GalaxyAckIdentitySecrets"/> names.</item>
/// <item>This fallback account — used by keys with no identity and by anonymous sessions.</item>
/// <item>Neither → the acknowledge is refused; nothing is sent to the gateway (never an empty
/// <c>operator_user</c>).</item>
/// </list>
/// </para>
/// <para>
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
/// </para>
/// </remarks>
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
/// <param name="FallbackDomain">Optional domain for <paramref name="FallbackUser"/> (sent as <c>operator_domain</c>).</param>
/// <param name="FallbackPasswordSecretRef">Reference to the fallback account's password.</param>
public sealed record GalaxyAlarmAckOptions(
string? FallbackUser = null,
string? FallbackDomain = null,
string? FallbackPasswordSecretRef = null)
{
/// <summary>True when a fallback account is configured.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackUser);
}
/// <summary>
@@ -116,6 +116,9 @@ public sealed class GalaxyDriver
// wiring without a running gateway. This driver bridges the feed's OnAlarmTransition
// onto IAlarmSource.OnAlarmEvent.
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
// lmxopcua#749: resolves the Galaxy account each acknowledge authenticates as (API key identity →
// fallback account → refused). Built in the ctor — it needs only options + the secret resolver.
private readonly IGalaxyAckCredentialResolver _ackCredentialResolver;
private IGalaxyAlarmFeed? _alarmFeed;
private readonly Lock _alarmHandlersLock = new();
private readonly Lock _alarmFeedLock = new();
@@ -300,6 +303,10 @@ public sealed class GalaxyDriver
/// <see cref="InitializeAsync"/> seeds the watcher from it and every rediscovery re-seeds
/// (production builds a <see cref="GatewayGalaxyPlatformSource"/> over the owned repository client).
/// </param>
/// <param name="ackCredentialResolver">
/// Optional ack-credential seam (lmxopcua#749). Defaults to <see cref="GalaxyAckCredentialResolver"/>
/// over <see cref="GalaxyDriverOptions.AlarmAck"/> and <paramref name="secretResolver"/>.
/// </param>
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -312,7 +319,8 @@ public sealed class GalaxyDriver
ILogger<GalaxyDriver>? logger = null,
ISecretResolver? secretResolver = null,
IGalaxyDeployWatchSource? deployWatchSource = null,
IGalaxyPlatformSource? platformSource = null)
IGalaxyPlatformSource? platformSource = null,
IGalaxyAckCredentialResolver? ackCredentialResolver = null)
{
_deployWatchSource = deployWatchSource;
_platformSource = platformSource;
@@ -328,6 +336,8 @@ public sealed class GalaxyDriver
_subscriber = subscriber;
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
@@ -1927,16 +1937,55 @@ public sealed class GalaxyDriver
? ack.ConditionId
: ack.SourceNodeId;
var alarmFullReference = ToAttributeRef(conditionReference);
// lmxopcua#749: the gateway acknowledges as a real Galaxy account (AuthenticateUser +
// AckMsg write), and the OPC UA session has no Galaxy password. Resolve the account mapped
// to the session's API key, else the driver's fallback account; with neither, refuse —
// nothing is sent, and never with an empty operator_user.
var resolution = await _ackCredentialResolver
.ResolveAsync(ack.ApiKeyId, cancellationToken).ConfigureAwait(false);
if (resolution.Credential is not { } credential)
{
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} by OPC UA principal {Principal} (key {ApiKeyId}) refused: {Reason}",
_driverInstanceId, alarmFullReference, PrincipalOf(ack), ack.ApiKeyId ?? "none", resolution.RefusalReason);
throw new DriverOperationRefusedException(
$"Galaxy acknowledge of '{alarmFullReference}' refused: {resolution.RefusalReason}");
}
// The Historian records the Galaxy account; keep the OPC UA principal + key id with the ack
// so it isn't lost when the account is a shared fallback.
var comment = AnnotateAckComment(ack.Comment, ack.OperatorUser, ack.ApiKeyId);
_logger.LogInformation(
"Galaxy driver {DriverInstanceId}: acknowledging {AlarmRef} as Galaxy user {GalaxyUser} ({CredentialSource}) for OPC UA principal {Principal} (key {ApiKeyId})",
_driverInstanceId, alarmFullReference, credential.DisplayName, credential.Source,
PrincipalOf(ack), ack.ApiKeyId ?? "none");
await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference,
ack.Comment ?? string.Empty,
// Honor the authenticated principal carried on the request when present;
// fall back to empty (server-side ACL fills this from the OPC UA session).
operatorUser: ack.OperatorUser ?? string.Empty,
cancellationToken).ConfigureAwait(false);
alarmFullReference, comment, credential, cancellationToken).ConfigureAwait(false);
}
}
private static string PrincipalOf(AlarmAcknowledgeRequest ack) =>
string.IsNullOrWhiteSpace(ack.OperatorUser) ? "anonymous" : ack.OperatorUser;
/// <summary>
/// Appends the OPC UA identity to the ack comment written to <c>AckMsg</c> (lmxopcua#749), e.g.
/// <c>"shift handover [OPC UA: mes-line1, key 0a1b…]"</c> or <c>"[OPC UA: anonymous]"</c>. The
/// Historian's Operator column holds the Galaxy account (possibly a shared fallback), so this is
/// the durable record of which client acknowledged.
/// </summary>
/// <param name="comment">The operator's comment, or null.</param>
/// <param name="principal">The OPC UA principal, or null/empty for anonymous.</param>
/// <param name="apiKeyId">The authenticating key id, or null.</param>
/// <returns>The annotated comment.</returns>
internal static string AnnotateAckComment(string? comment, string? principal, string? apiKeyId)
{
var who = string.IsNullOrWhiteSpace(principal) ? "anonymous" : principal.Trim();
var tag = string.IsNullOrWhiteSpace(apiKeyId) ? $"[OPC UA: {who}]" : $"[OPC UA: {who}, key {apiKeyId}]";
return string.IsNullOrWhiteSpace(comment) ? tag : $"{comment.TrimEnd()} {tag}";
}
/// <summary>
/// Receives <see cref="GalaxyAlarmTransition"/> events from the gateway alarm
/// feed and reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side
@@ -102,11 +102,17 @@ public static class GalaxyDriverFactoryExtensions
// carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its
// RawPath → attributeRef table from these. Empty when the config omits them.
RawTags = dto.RawTags ?? [],
AlarmAck = new GalaxyAlarmAckOptions(
FallbackUser: NullIfBlank(dto.AlarmAck?.FallbackUser),
FallbackDomain: NullIfBlank(dto.AlarmAck?.FallbackDomain),
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef)),
};
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
}
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -131,6 +137,19 @@ public static class GalaxyDriverFactoryExtensions
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets or sets the native alarm-ack credential policy (lmxopcua#749).</summary>
public AlarmAckDto? AlarmAck { get; init; }
}
/// <summary>Native alarm-ack credential section (lmxopcua#749).</summary>
internal sealed class AlarmAckDto
{
/// <summary>Gets or sets the fallback Galaxy user.</summary>
public string? FallbackUser { get; init; }
/// <summary>Gets or sets the fallback user's domain.</summary>
public string? FallbackDomain { get; init; }
/// <summary>Gets or sets the fallback password reference (secret:/env:/file:/dev:).</summary>
public string? FallbackPasswordSecretRef { get; init; }
}
/// <summary>Gateway configuration section.</summary>
@@ -0,0 +1,78 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>Where a <see cref="GalaxyAckCredential"/> came from.</summary>
internal enum GalaxyAckCredentialSource
{
/// <summary>The authenticating API key's own Galaxy ack identity (AdminUI <c>/api-keys</c>).</summary>
ApiKey,
/// <summary>The driver instance's configured fallback ack account.</summary>
Fallback,
}
/// <summary>
/// The Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). The gateway runs
/// <c>AuthenticateUser(domain\user, password)</c> and writes <c>&lt;alarm&gt;.AckMsg</c> with that user,
/// so the Historian records this account as the operator.
/// </summary>
/// <remarks>
/// ⚠️ Carries a password. A class, not a record, so the compiler-generated <c>ToString</c> /
/// <c>PrintMembers</c> can never print it; <see cref="ToString"/> is overridden to omit it. Never log,
/// format or put an instance in an exception message other than via <see cref="ToString"/>.
/// </remarks>
internal sealed class GalaxyAckCredential
{
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredential"/> class.</summary>
/// <param name="user">Galaxy user; must be non-empty (the gateway rejects an empty operator_user).</param>
/// <param name="domain">Domain, or empty.</param>
/// <param name="password">Password (may be empty — Galaxy accounts can have empty passwords).</param>
/// <param name="source">Where the credential came from.</param>
public GalaxyAckCredential(string user, string? domain, string? password, GalaxyAckCredentialSource source)
{
ArgumentException.ThrowIfNullOrWhiteSpace(user);
User = user.Trim();
Domain = domain?.Trim() ?? string.Empty;
Password = password ?? string.Empty;
Source = source;
}
/// <summary>Gets the Galaxy user name.</summary>
public string User { get; }
/// <summary>Gets the domain, or empty.</summary>
public string Domain { get; }
/// <summary>Gets the password. Secret.</summary>
public string Password { get; }
/// <summary>Gets where the credential came from.</summary>
public GalaxyAckCredentialSource Source { get; }
/// <summary>Gets the <c>DOMAIN\user</c> display form (no password).</summary>
public string DisplayName => Domain.Length == 0 ? User : $"{Domain}\\{User}";
/// <inheritdoc />
public override string ToString() => $"{DisplayName} ({Source}, password redacted)";
}
/// <summary>Outcome of resolving the Galaxy ack credential for one acknowledge.</summary>
/// <param name="Credential">The credential, or null when refused.</param>
/// <param name="RefusalReason">Why the acknowledge is refused (never contains a secret), or null.</param>
internal sealed record GalaxyAckCredentialResolution(GalaxyAckCredential? Credential, string? RefusalReason)
{
/// <summary>Creates a successful resolution.</summary>
/// <param name="credential">The resolved credential.</param>
/// <returns>The resolution.</returns>
public static GalaxyAckCredentialResolution Resolved(GalaxyAckCredential credential) => new(credential, null);
/// <summary>Creates a refusal.</summary>
/// <param name="reason">Why; must not contain a secret.</param>
/// <returns>The resolution.</returns>
public static GalaxyAckCredentialResolution Refused(string reason) => new(null, reason);
// Explicit so the generated record ToString never reaches GalaxyAckCredential's members by any
// other path than its own redacting ToString.
/// <inheritdoc />
public override string ToString() =>
Credential is not null ? $"Resolved {Credential}" : $"Refused: {RefusalReason}";
}
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Production <see cref="IGalaxyAckCredentialResolver"/>: the API key's own Galaxy ack identity
/// (Secrets store, <see cref="GalaxyAckIdentitySecrets"/>) → the driver's fallback account
/// (<see cref="GalaxyAlarmAckOptions"/>) → refused.
/// </summary>
/// <remarks>
/// Resolves on every acknowledge rather than caching, so rotating a password or remapping a key takes
/// effect on the next ack; the Secrets resolver caches decrypted values itself. Nothing here logs a
/// password: log lines and refusal reasons name only users, key ids and secret NAMES.
/// </remarks>
internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
{
private readonly string _driverInstanceId;
private readonly GalaxyAlarmAckOptions _options;
private readonly ISecretResolver _secrets;
private readonly ILogger _logger;
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
/// <param name="options">The driver's ack-credential options.</param>
/// <param name="secrets">The shared secret resolver.</param>
/// <param name="logger">Logger; never receives a secret.</param>
public GalaxyAckCredentialResolver(
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
{
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
{
string? keyNote = null;
if (!string.IsNullOrWhiteSpace(apiKeyId))
{
var keyResult = await ResolveKeyIdentityAsync(apiKeyId, cancellationToken).ConfigureAwait(false);
if (keyResult.Credential is not null) return keyResult;
// A key whose identity is half-configured (user without password, unreadable secret) is
// refused outright — silently acknowledging as the fallback account would record the wrong
// operator for a key the administrator explicitly mapped.
if (keyResult.RefusalReason is not null) return keyResult;
keyNote = $"API key '{apiKeyId}' has no Galaxy ack identity";
}
if (_options.HasFallback)
{
return await ResolveFallbackAsync(cancellationToken).ConfigureAwait(false);
}
return GalaxyAckCredentialResolution.Refused(
$"{keyNote ?? "the session is anonymous (no API key)"} and Galaxy driver '{_driverInstanceId}' has no " +
"AlarmAck.FallbackUser configured; the acknowledge was not sent. Map a Galaxy ack identity to the key " +
"in AdminUI /api-keys or configure the driver's fallback ack account.");
}
// Returns Resolved, Refused (mapped but broken), or (null, null) = "no identity mapped".
private async Task<GalaxyAckCredentialResolution> ResolveKeyIdentityAsync(string apiKeyId, CancellationToken ct)
{
string userName, passwordName;
try
{
userName = GalaxyAckIdentitySecrets.UserSecretName(apiKeyId);
passwordName = GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId);
}
catch (ArgumentException)
{
return GalaxyAckCredentialResolution.Refused($"API key id '{apiKeyId}' is malformed; the acknowledge was not sent.");
}
string? identity;
string? password;
try
{
identity = await _secrets.GetAsync(new SecretName(userName), ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(identity)) return new GalaxyAckCredentialResolution(null, null);
password = await _secrets.GetAsync(new SecretName(passwordName), ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The exception type only — a secrets-layer message is not ours to vouch for.
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: reading the Galaxy ack identity of API key {ApiKeyId} failed ({ExceptionType})",
_driverInstanceId, apiKeyId, ex.GetType().Name);
return GalaxyAckCredentialResolution.Refused(
$"the Galaxy ack identity of API key '{apiKeyId}' could not be read from the Secrets store; the acknowledge was not sent.");
}
if (password is null)
{
return GalaxyAckCredentialResolution.Refused(
$"API key '{apiKeyId}' has a Galaxy ack user but no password secret '{passwordName}'; the acknowledge was not sent.");
}
var (user, domain) = GalaxyAckIdentitySecrets.ParseIdentity(identity);
if (user.Length == 0)
{
return GalaxyAckCredentialResolution.Refused(
$"API key '{apiKeyId}' has a malformed Galaxy ack user secret '{userName}'; the acknowledge was not sent.");
}
return GalaxyAckCredentialResolution.Resolved(
new GalaxyAckCredential(user, domain, password, GalaxyAckCredentialSource.ApiKey));
}
private async Task<GalaxyAckCredentialResolution> ResolveFallbackAsync(CancellationToken ct)
{
var reference = _options.FallbackPasswordSecretRef;
if (string.IsNullOrWhiteSpace(reference))
{
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' has AlarmAck.FallbackUser but no AlarmAck.FallbackPasswordSecretRef; the acknowledge was not sent.");
}
try
{
var password = await ResolvePasswordRefAsync(reference.Trim(), ct).ConfigureAwait(false);
if (password is null)
{
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' did not resolve; the acknowledge was not sent.");
}
return GalaxyAckCredentialResolution.Resolved(new GalaxyAckCredential(
_options.FallbackUser!, _options.FallbackDomain, password, GalaxyAckCredentialSource.Fallback));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: resolving the fallback ack password reference {Reference} failed ({ExceptionType})",
_driverInstanceId, DescribeRef(reference), ex.GetType().Name);
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' could not be resolved; the acknowledge was not sent.");
}
}
/// <summary>
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
/// </summary>
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
{
reference = "secret:" + reference["${secret:".Length..^1];
}
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
}
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
return Environment.GetEnvironmentVariable(reference[4..]);
}
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = reference[5..];
return File.Exists(path)
? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
: null;
}
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
return reference[4..];
}
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
}
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
internal static string DescribeRef(string reference)
{
var r = reference.Trim();
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
return r;
}
return "<literal redacted>";
}
}
@@ -6,20 +6,35 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Production <see cref="IGalaxyAlarmAcknowledger"/> backed by the session-less
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The updated gateway routes
/// acknowledgement through its always-on central alarm monitor, so no worker
/// session is involved — the driver supplies only the alarm reference, comment,
/// and operator principal.
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The gateway routes acknowledgement
/// through its always-on central alarm monitor, so no worker session is involved.
/// </summary>
/// <remarks>
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess
/// (transport / dispatch failure) and is surfaced as a thrown exception. A non-zero
/// native ack return code (<c>hresult</c>) means MXAccess itself rejected the ack;
/// that is logged as a warning rather than thrown so a transient MXAccess hiccup
/// doesn't block the operator workflow — the operator can retry.
/// <para>
/// <b>Operator-authenticated ack (mxaccessgw 488649c+, lmxopcua#749).</b> The gateway runs
/// <c>AuthenticateUser</c> with <c>operator_user</c>/<c>operator_domain</c>/<c>operator_password</c>
/// and writes the comment to <c>&lt;alarm&gt;.AckMsg</c> as that user, so the request always
/// carries a real Galaxy account — never an empty <c>operator_user</c>, which the gateway rejects.
/// </para>
/// <para>
/// <b>The password never leaves the request.</b> It is not logged, and every message this type
/// builds from gateway output is scrubbed of it (the gateway contract already keeps it out of
/// <c>ProtocolStatus.Message</c>; the scrub is defence in depth). The 0.3.0 client redacts it from
/// its own <c>MxGatewayException</c>s.
/// </para>
/// <para>
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess (transport /
/// dispatch / validation failure) and is surfaced as a thrown exception. A non-zero native ack
/// return code (<c>hresult</c> — now the <c>AuthenticateUser</c> HRESULT or the AckMsg write status)
/// means MXAccess rejected the ack; that is logged as a warning rather than thrown so a transient
/// MXAccess hiccup doesn't block the operator workflow — the operator can retry.
/// </para>
/// </remarks>
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
{
/// <summary>Placeholder substituted for the password wherever gateway text is echoed.</summary>
internal const string Redacted = "***";
private readonly MxGatewayClient _client;
private readonly ILogger _logger;
@@ -36,38 +51,99 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
public async Task AcknowledgeAsync(
string alarmFullReference,
string comment,
string operatorUser,
GalaxyAckCredential credential,
CancellationToken cancellationToken)
{
var request = BuildRequest(alarmFullReference, comment, credential);
AcknowledgeAlarmReply reply;
try
{
reply = await _client.AcknowledgeAlarmAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException
&& credential.Password.Length > 0
&& ContainsSecret(ex, credential.Password))
{
// Defence in depth over the client's own redaction: never let an exception that echoes the
// password escape into the actor's warning log.
throw new InvalidOperationException(
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway ({ex.GetType().Name}); "
+ "details withheld because they echoed the operator credential.");
}
EvaluateReply(reply, alarmFullReference, credential, _logger);
}
/// <summary>Maps an acknowledge onto the gateway request. Pure — pinned by unit tests.</summary>
/// <param name="alarmFullReference">The alarm reference.</param>
/// <param name="comment">The ack comment.</param>
/// <param name="credential">The Galaxy account to acknowledge as.</param>
/// <returns>The request.</returns>
internal static AcknowledgeAlarmRequest BuildRequest(
string alarmFullReference, string comment, GalaxyAckCredential credential)
{
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
ArgumentNullException.ThrowIfNull(credential);
var reply = await _client.AcknowledgeAlarmAsync(
new AcknowledgeAlarmRequest
{
ClientCorrelationId = Guid.NewGuid().ToString("N"),
AlarmFullReference = alarmFullReference,
Comment = comment ?? string.Empty,
OperatorUser = operatorUser ?? string.Empty,
},
cancellationToken).ConfigureAwait(false);
return new AcknowledgeAlarmRequest
{
ClientCorrelationId = Guid.NewGuid().ToString("N"),
AlarmFullReference = alarmFullReference,
Comment = comment ?? string.Empty,
OperatorUser = credential.User,
OperatorDomain = credential.Domain,
OperatorPassword = credential.Password,
};
}
/// <summary>
/// Applies the reply semantics: non-OK protocol status throws; a non-zero native code warns.
/// Every message is scrubbed of the password.
/// </summary>
/// <param name="reply">The gateway reply.</param>
/// <param name="alarmFullReference">The alarm reference.</param>
/// <param name="credential">The credential the request carried.</param>
/// <param name="logger">The logger.</param>
internal static void EvaluateReply(
AcknowledgeAlarmReply reply, string alarmFullReference, GalaxyAckCredential credential, ILogger logger)
{
// Protocol status — the gateway failed before MXAccess saw the ack. This is a
// hard failure: the operator's request was not delivered at all.
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
{
throw new InvalidOperationException(
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway: "
+ $"{proto.Code} {proto.Message}");
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' as '{credential.DisplayName}' failed at the gateway: "
+ $"{proto.Code} {Scrub(proto.Message, credential.Password)}");
}
// hresult is the authoritative native ack return code (0 = success). It is
// absent only on a worker protocol violation; with an OK protocol status a
// missing value is treated as success.
// hresult is the authoritative native ack return code (0 = success) — the AuthenticateUser HRESULT
// or the AckMsg write status. It is absent only on a worker protocol violation; with an OK protocol
// status a missing value is treated as success.
if (reply.HasHresult && reply.Hresult != 0)
{
_logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} returned native ack failure code {Hresult}.",
alarmFullReference, reply.Hresult);
logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} returned native ack failure code {Hresult}.",
alarmFullReference, credential.DisplayName, reply.Hresult);
}
}
/// <summary>Replaces every occurrence of <paramref name="secret"/> in <paramref name="text"/>.</summary>
/// <param name="text">Text that may echo the secret.</param>
/// <param name="secret">The secret; empty means nothing to scrub.</param>
/// <returns>The scrubbed text.</returns>
internal static string Scrub(string? text, string secret) =>
string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secret)
? text ?? string.Empty
: text.Replace(secret, Redacted, StringComparison.Ordinal);
private static bool ContainsSecret(Exception ex, string secret)
{
for (var e = ex; e is not null; e = e.InnerException)
{
if (e.Message.Contains(secret, StringComparison.Ordinal)) return true;
}
return false;
}
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Resolves the Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). Seam so
/// <see cref="GalaxyDriver.AcknowledgeAsync"/> can be exercised without a Secrets store.
/// </summary>
internal interface IGalaxyAckCredentialResolver
{
/// <summary>
/// Resolves the credential for an acknowledge from a session that authenticated with
/// <paramref name="apiKeyId"/> (null = anonymous / no key). Never throws for a missing or
/// unreadable credential — that is a <see cref="GalaxyAckCredentialResolution.Refused"/>.
/// </summary>
/// <param name="apiKeyId">The authenticating API key id, or null.</param>
/// <param name="cancellationToken">Cancels secret resolution.</param>
/// <returns>The resolved credential or the refusal reason.</returns>
Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken);
}
@@ -1,5 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
@@ -11,23 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
internal interface IGalaxyAlarmAcknowledger
{
/// <summary>
/// Forward a single alarm acknowledgement to the gateway. The gateway
/// translates this to an MxAccess Acknowledge call against the worker's
/// session and returns the native MxStatus on the reply.
/// Forward a single alarm acknowledgement to the gateway. The gateway (488649c+)
/// authenticates <paramref name="credential"/> and writes <paramref name="comment"/>
/// to <c>&lt;alarm&gt;.AckMsg</c> as that user, so the Historian records it as the operator.
/// </summary>
/// <param name="alarmFullReference">
/// Fully-qualified alarm reference (e.g. <c>"Tank01.Level.HiHi"</c>).
/// </param>
/// <param name="comment">Operator-supplied comment forwarded to MxAccess.</param>
/// <param name="operatorUser">
/// Operator principal performing the acknowledgement. Resolved from the
/// OPC UA session by the server-side ACL layer before reaching the driver.
/// <param name="comment">Ack comment written to <c>AckMsg</c>.</param>
/// <param name="credential">
/// The Galaxy account to acknowledge as, resolved by <see cref="IGalaxyAckCredentialResolver"/>.
/// Carries a password — implementations must keep it out of logs and exception text.
/// </param>
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AcknowledgeAsync(
string alarmFullReference,
string comment,
string operatorUser,
GalaxyAckCredential credential,
CancellationToken cancellationToken);
}