feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
v2-ci / unit-tests (push) Failing after 1h8m43s
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:
@@ -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><alarm>.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><alarm>.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>";
|
||||
}
|
||||
}
|
||||
+103
-27
@@ -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><alarm>.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><alarm>.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user