diff --git a/Directory.Packages.props b/Directory.Packages.props
index d63e819e..642f4b0f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -220,8 +220,8 @@
-
-
+
+
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index 49206d59..adc94d74 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -85,6 +85,42 @@ Project root files:
- **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric).
- **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`.
- **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag).
+- **`AlarmAck`** (optional, lmxopcua#749) — `FallbackUser`, `FallbackDomain`, `FallbackPasswordSecretRef`. See [Alarm acknowledge credential](#alarm-acknowledge-credential).
+
+### Alarm acknowledge credential
+
+mxaccessgw `488649c`+ (client/contracts `0.3.0`) acknowledges a native alarm by running
+`AuthenticateUser` as the operator and writing the comment to `.AckMsg`, so the Historian
+`ACK_ALM` row records a real Galaxy account. `AcknowledgeAlarmRequest` therefore carries
+`operator_user` + `operator_domain` + `operator_password`, and the gateway rejects an empty
+`operator_user`. The OPC UA data plane is machine-to-machine (API key or anonymous) and carries no
+Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowledge:
+
+1. **The API key's own Galaxy ack identity** — set on AdminUI `/apikeys` ("Ack identity"). Stored only
+ in the encrypted Secrets store as `otopcua/apikeys//galaxy-ack-user` (`DOMAIN\user` or
+ `user`) and `otopcua/apikeys//galaxy-ack-password` (`GalaxyAckIdentitySecrets`) — never in
+ the config DB or the deployment artifact. A key with a user but no password secret is **refused**,
+ not silently downgraded to the fallback.
+2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` /
+ `FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous
+ sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`,
+ `file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig.
+3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the
+ gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway
+ health). ⚠️ The native ack path is fire-and-forget after the Part 9 method returns, so the OPC UA
+ call itself still returns `Good` and the refusal surfaces as a `DriverInstance` warning naming the
+ fix — a synchronous status / revert is #760.
+
+The comment written to `AckMsg` is suffixed with the OPC UA identity —
+`" [OPC UA: , key ]"` or `"[OPC UA: anonymous]"` — so the client is not
+lost when the Historian's Operator is a shared fallback account. The password is never logged: the
+acknowledger scrubs it from every gateway message it echoes and `GalaxyAckCredential.ToString()`
+omits it.
+
+⚠️ **Secrets are node-local** (optionally Akka-replicated within one mesh). The per-key secrets and
+the fallback secret must exist on the node that serves the OPC UA session and hosts the Galaxy
+driver; on a split admin/driver topology create them on the driver nodes too (`/admin/secrets` or
+the `secret` CLI) under exactly those names.
Full per-field descriptions live in `Config/GalaxyDriverOptions.cs`. The full JSON skeleton is reproduced in [docs/v2/driver-specs.md §1](../v2/driver-specs.md).
diff --git a/docs/security.md b/docs/security.md
index c6e44483..c4d3f052 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -345,6 +345,14 @@ of a credential was wrong.
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
`Enabled` takes effect on a node only once a new artifact reaches it.
+**Galaxy alarm-ack identity per key (lmxopcua#749).** mxaccessgw acknowledges Galaxy alarms as a real
+Galaxy account, so a key can carry an optional Galaxy ack identity (AdminUI `/apikeys` → "Ack
+identity"). It is held only in the encrypted Secrets store
+(`otopcua/apikeys//galaxy-ack-{user,password}`), never in the ConfigDb or the artifact; keys
+without one and anonymous sessions use the Galaxy driver's `AlarmAck` fallback account, and with
+neither the ack is refused before it reaches the gateway. Deleting a key on `/apikeys` removes its
+identity. See [docs/drivers/Galaxy.md § Alarm acknowledge credential](drivers/Galaxy.md#alarm-acknowledge-credential).
+
### Anonymous is a normal principal
Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the
diff --git a/nuget-packages/ZB.MOM.WW.MxGateway.Client.0.3.0.nupkg b/nuget-packages/ZB.MOM.WW.MxGateway.Client.0.3.0.nupkg
new file mode 100644
index 00000000..d4da5178
Binary files /dev/null and b/nuget-packages/ZB.MOM.WW.MxGateway.Client.0.3.0.nupkg differ
diff --git a/nuget-packages/ZB.MOM.WW.MxGateway.Contracts.0.3.0.nupkg b/nuget-packages/ZB.MOM.WW.MxGateway.Contracts.0.3.0.nupkg
new file mode 100644
index 00000000..6b483d45
Binary files /dev/null and b/nuget-packages/ZB.MOM.WW.MxGateway.Contracts.0.3.0.nupkg differ
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverOperationRefusedException.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverOperationRefusedException.cs
new file mode 100644
index 00000000..667d7c52
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverOperationRefusedException.cs
@@ -0,0 +1,21 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+///
+/// Thrown by a driver that deliberately refuses an operation before contacting its backend — a
+/// configuration / authorization verdict (e.g. no Galaxy ack credential is available for the
+/// caller, lmxopcua#749), not a transport or device fault.
+///
+///
+/// The resilience pipeline neither retries it nor counts it toward the circuit breaker: a refusal
+/// says nothing about the backend's health, so letting five misconfigured acks open the breaker
+/// would block every correctly-configured caller on the same host.
+///
+public sealed class DriverOperationRefusedException : InvalidOperationException
+{
+ /// Initializes a new instance of the class.
+ /// Why the operation was refused. Must never contain a secret.
+ public DriverOperationRefusedException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
index 4184f867..084e54a7 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
@@ -49,11 +49,17 @@ public interface IAlarmSubscriptionHandle
/// Stable id correlating raise / ack / clear of the same condition.
/// Operator-supplied comment forwarded to the upstream alarm system.
/// Authenticated principal performing the ack; null on the raw/non-OPC-UA path.
+///
+/// Public id of the API key the OPC UA session authenticated with; null for anonymous / non-key paths.
+/// Not a secret. Lets a driver that must acknowledge upstream as a real account (Galaxy, lmxopcua#749)
+/// look up the identity mapped to that key.
+///
public sealed record AlarmAcknowledgeRequest(
string SourceNodeId,
string ConditionId,
string? Comment,
- string? OperatorUser = null);
+ string? OperatorUser = null,
+ string? ApiKeyId = null);
/// Event payload for .
/// Subscription this event belongs to.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
index fe5b6cff..60b86365 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
@@ -149,7 +149,7 @@ public sealed class DriverResiliencePipelineBuilder
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(100),
MaxDelay = TimeSpan.FromSeconds(5),
- ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException),
+ ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException and not DriverOperationRefusedException),
};
if (tracker is not null || logger is not null)
{
@@ -173,7 +173,7 @@ public sealed class DriverResiliencePipelineBuilder
MinimumThroughput = Math.Max(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold, policy.BreakerFailureThreshold),
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
- ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException),
+ ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException and not DriverOperationRefusedException),
};
if (tracker is not null || logger is not null)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyAckIdentitySecrets.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyAckIdentitySecrets.cs
new file mode 100644
index 00000000..4bbd1a25
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyAckIdentitySecrets.cs
@@ -0,0 +1,69 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+
+///
+/// The Secrets-store naming convention for an API key's Galaxy alarm-ack identity (lmxopcua#749).
+/// Shared by the AdminUI /api-keys page (writer) and the Galaxy driver (reader) so the two can
+/// never disagree on a name.
+///
+///
+///
+/// Both halves live in the encrypted ZB.MOM.WW.Secrets store — nothing about the identity is
+/// written to the config DB or the deployment artifact. The identity secret holds the Galaxy account
+/// as DOMAIN\user or bare user; the password secret holds the password (may be empty —
+/// Galaxy accounts can have empty passwords; an absent password secret is a refusal).
+///
+///
+/// ⚠️ 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 /admin/secrets page or the
+/// secret CLI) under exactly these names.
+///
+///
+public static class GalaxyAckIdentitySecrets
+{
+ /// Common prefix for every per-key secret.
+ public const string Prefix = "otopcua/apikeys/";
+
+ /// Secret holding the key's Galaxy account (DOMAIN\user or user).
+ /// The public API key id.
+ /// The secret name.
+ public static string UserSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-user";
+
+ /// Secret holding the key's Galaxy account password.
+ /// The public API key id.
+ /// The secret name.
+ public static string PasswordSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-password";
+
+ /// Formats a user + optional domain as the identity secret value.
+ /// The Galaxy user name.
+ /// The optional domain.
+ /// DOMAIN\user or user.
+ public static string FormatIdentity(string user, string? domain) =>
+ string.IsNullOrWhiteSpace(domain) ? user.Trim() : $"{domain.Trim()}\\{user.Trim()}";
+
+ /// Splits an identity secret value into user + domain on the first backslash.
+ /// The stored identity value.
+ /// The user (trimmed) and the domain (empty when none).
+ 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;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs
index 18973165..56fff75a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs
@@ -39,6 +39,47 @@ public sealed record GalaxyDriverOptions(
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
///
public IReadOnlyList RawTags { get; init; } = [];
+
+ ///
+ /// Operator-credential policy for native alarm acknowledgement (lmxopcua#749). mxaccessgw
+ /// (488649c+) acknowledges by authenticating as the operator and writing <alarm>.AckMsg,
+ /// so every ack needs a Galaxy user + password. See .
+ ///
+ public GalaxyAlarmAckOptions AlarmAck { get; init; } = new();
+}
+
+///
+/// Where the Galaxy credential for a native alarm acknowledge comes from (lmxopcua#749).
+///
+///
+///
+/// The OPC UA data plane is machine-to-machine (API key or anonymous), so the session carries no
+/// Galaxy password. Resolution order per acknowledge:
+///
+/// The authenticating API key's own Galaxy ack identity, set in AdminUI /api-keys and
+/// held in the Secrets store under names.
+/// This fallback account — used by keys with no identity and by anonymous sessions.
+/// Neither → the acknowledge is refused; nothing is sent to the gateway (never an empty
+/// operator_user).
+///
+///
+///
+/// must be a reference — secret:NAME (production),
+/// env:NAME, file:PATH or dev:VALUE (rigs only). A bare literal is rejected so a
+/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
+///
+///
+/// Galaxy user the fallback acknowledges as; null/empty = no fallback.
+/// Optional domain for (sent as operator_domain).
+/// Reference to the fallback account's password.
+public sealed record GalaxyAlarmAckOptions(
+ string? FallbackUser = null,
+ string? FallbackDomain = null,
+ string? FallbackPasswordSecretRef = null)
+{
+ /// True when a fallback account is configured.
+ [System.Text.Json.Serialization.JsonIgnore]
+ public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackUser);
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index d0fac54a..56111541 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -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
/// seeds the watcher from it and every rediscovery re-seeds
/// (production builds a over the owned repository client).
///
+ ///
+ /// Optional ack-credential seam (lmxopcua#749). Defaults to
+ /// over and .
+ ///
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -312,7 +319,8 @@ public sealed class GalaxyDriver
ILogger? 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;
+
+ ///
+ /// Appends the OPC UA identity to the ack comment written to AckMsg (lmxopcua#749), e.g.
+ /// "shift handover [OPC UA: mes-line1, key 0a1b…]" or "[OPC UA: anonymous]". The
+ /// Historian's Operator column holds the Galaxy account (possibly a shared fallback), so this is
+ /// the durable record of which client acknowledged.
+ ///
+ /// The operator's comment, or null.
+ /// The OPC UA principal, or null/empty for anonymous.
+ /// The authenticating key id, or null.
+ /// The annotated comment.
+ 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}";
+ }
+
///
/// Receives events from the gateway alarm
/// feed and reshapes them into for OPC UA-side
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
index 877ed0bc..d1cdd98d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
@@ -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());
}
+ 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 .
///
public List? RawTags { get; init; }
+ /// Gets or sets the native alarm-ack credential policy (lmxopcua#749).
+ public AlarmAckDto? AlarmAck { get; init; }
+ }
+
+ /// Native alarm-ack credential section (lmxopcua#749).
+ internal sealed class AlarmAckDto
+ {
+ /// Gets or sets the fallback Galaxy user.
+ public string? FallbackUser { get; init; }
+ /// Gets or sets the fallback user's domain.
+ public string? FallbackDomain { get; init; }
+ /// Gets or sets the fallback password reference (secret:/env:/file:/dev:).
+ public string? FallbackPasswordSecretRef { get; init; }
}
/// Gateway configuration section.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredential.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredential.cs
new file mode 100644
index 00000000..b124e62b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredential.cs
@@ -0,0 +1,78 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+/// Where a came from.
+internal enum GalaxyAckCredentialSource
+{
+ /// The authenticating API key's own Galaxy ack identity (AdminUI /api-keys).
+ ApiKey,
+
+ /// The driver instance's configured fallback ack account.
+ Fallback,
+}
+
+///
+/// The Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). The gateway runs
+/// AuthenticateUser(domain\user, password) and writes <alarm>.AckMsg with that user,
+/// so the Historian records this account as the operator.
+///
+///
+/// ⚠️ Carries a password. A class, not a record, so the compiler-generated ToString /
+/// PrintMembers can never print it; is overridden to omit it. Never log,
+/// format or put an instance in an exception message other than via .
+///
+internal sealed class GalaxyAckCredential
+{
+ /// Initializes a new instance of the class.
+ /// Galaxy user; must be non-empty (the gateway rejects an empty operator_user).
+ /// Domain, or empty.
+ /// Password (may be empty — Galaxy accounts can have empty passwords).
+ /// Where the credential came from.
+ 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;
+ }
+
+ /// Gets the Galaxy user name.
+ public string User { get; }
+
+ /// Gets the domain, or empty.
+ public string Domain { get; }
+
+ /// Gets the password. Secret.
+ public string Password { get; }
+
+ /// Gets where the credential came from.
+ public GalaxyAckCredentialSource Source { get; }
+
+ /// Gets the DOMAIN\user display form (no password).
+ public string DisplayName => Domain.Length == 0 ? User : $"{Domain}\\{User}";
+
+ ///
+ public override string ToString() => $"{DisplayName} ({Source}, password redacted)";
+}
+
+/// Outcome of resolving the Galaxy ack credential for one acknowledge.
+/// The credential, or null when refused.
+/// Why the acknowledge is refused (never contains a secret), or null.
+internal sealed record GalaxyAckCredentialResolution(GalaxyAckCredential? Credential, string? RefusalReason)
+{
+ /// Creates a successful resolution.
+ /// The resolved credential.
+ /// The resolution.
+ public static GalaxyAckCredentialResolution Resolved(GalaxyAckCredential credential) => new(credential, null);
+
+ /// Creates a refusal.
+ /// Why; must not contain a secret.
+ /// The resolution.
+ 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.
+ ///
+ public override string ToString() =>
+ Credential is not null ? $"Resolved {Credential}" : $"Refused: {RefusalReason}";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs
new file mode 100644
index 00000000..c20295d3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs
@@ -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;
+
+///
+/// Production : the API key's own Galaxy ack identity
+/// (Secrets store, ) → the driver's fallback account
+/// () → refused.
+///
+///
+/// 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.
+///
+internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
+{
+ private readonly string _driverInstanceId;
+ private readonly GalaxyAlarmAckOptions _options;
+ private readonly ISecretResolver _secrets;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ /// The owning driver instance id (for messages).
+ /// The driver's ack-credential options.
+ /// The shared secret resolver.
+ /// Logger; never receives a secret.
+ 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));
+ }
+
+ ///
+ public async Task 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 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 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.");
+ }
+ }
+
+ ///
+ /// Resolves a password reference. Accepts secret:NAME, ${secret:NAME}, env:NAME,
+ /// file:PATH and dev:VALUE; returns null when the referenced value is absent; throws
+ /// for a bare literal (a cleartext password in DriverConfig).
+ ///
+ internal async Task 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:";
+ if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
+ || r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
+ || r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
+ || r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
+ {
+ return r;
+ }
+
+ return "";
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmAcknowledger.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmAcknowledger.cs
index c0427d53..242c90cc 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmAcknowledger.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmAcknowledger.cs
@@ -6,20 +6,35 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
///
/// Production backed by the session-less
-/// MxGatewayClient.AcknowledgeAlarmAsync 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.
+/// MxGatewayClient.AcknowledgeAlarmAsync RPC. The gateway routes acknowledgement
+/// through its always-on central alarm monitor, so no worker session is involved.
///
///
-/// A non-OK means the gateway never reached MXAccess
-/// (transport / dispatch failure) and is surfaced as a thrown exception. A non-zero
-/// native ack return code (hresult) 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.
+///
+/// Operator-authenticated ack (mxaccessgw 488649c+, lmxopcua#749). The gateway runs
+/// AuthenticateUser with operator_user/operator_domain/operator_password
+/// and writes the comment to <alarm>.AckMsg as that user, so the request always
+/// carries a real Galaxy account — never an empty operator_user, which the gateway rejects.
+///
+///
+/// The password never leaves the request. 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
+/// ProtocolStatus.Message; the scrub is defence in depth). The 0.3.0 client redacts it from
+/// its own MxGatewayExceptions.
+///
+///
+/// A non-OK means the gateway never reached MXAccess (transport /
+/// dispatch / validation failure) and is surfaced as a thrown exception. A non-zero native ack
+/// return code (hresult — now the AuthenticateUser 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.
+///
///
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
{
+ /// Placeholder substituted for the password wherever gateway text is echoed.
+ 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);
+ }
+
+ /// Maps an acknowledge onto the gateway request. Pure — pinned by unit tests.
+ /// The alarm reference.
+ /// The ack comment.
+ /// The Galaxy account to acknowledge as.
+ /// The request.
+ 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,
+ };
+ }
+ ///
+ /// Applies the reply semantics: non-OK protocol status throws; a non-zero native code warns.
+ /// Every message is scrubbed of the password.
+ ///
+ /// The gateway reply.
+ /// The alarm reference.
+ /// The credential the request carried.
+ /// The logger.
+ 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);
}
}
+
+ /// Replaces every occurrence of in .
+ /// Text that may echo the secret.
+ /// The secret; empty means nothing to scrub.
+ /// The scrubbed text.
+ 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;
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAckCredentialResolver.cs
new file mode 100644
index 00000000..4e43fdef
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAckCredentialResolver.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+///
+/// Resolves the Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). Seam so
+/// can be exercised without a Secrets store.
+///
+internal interface IGalaxyAckCredentialResolver
+{
+ ///
+ /// Resolves the credential for an acknowledge from a session that authenticated with
+ /// (null = anonymous / no key). Never throws for a missing or
+ /// unreadable credential — that is a .
+ ///
+ /// The authenticating API key id, or null.
+ /// Cancels secret resolution.
+ /// The resolved credential or the refusal reason.
+ Task ResolveAsync(string? apiKeyId, CancellationToken cancellationToken);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAlarmAcknowledger.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAlarmAcknowledger.cs
index dc57f456..2e5272fc 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAlarmAcknowledger.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyAlarmAcknowledger.cs
@@ -1,5 +1,3 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
///
@@ -11,23 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
internal interface IGalaxyAlarmAcknowledger
{
///
- /// 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 and writes
+ /// to <alarm>.AckMsg as that user, so the Historian records it as the operator.
///
///
/// Fully-qualified alarm reference (e.g. "Tank01.Level.HiHi").
///
- /// Operator-supplied comment forwarded to MxAccess.
- ///
- /// Operator principal performing the acknowledgement. Resolved from the
- /// OPC UA session by the server-side ACL layer before reaching the driver.
+ /// Ack comment written to AckMsg.
+ ///
+ /// The Galaxy account to acknowledge as, resolved by .
+ /// Carries a password — implementations must keep it out of logs and exception text.
///
/// Cancels the gateway RPC.
/// A task that represents the asynchronous operation.
Task AcknowledgeAsync(
string alarmFullReference,
string comment,
- string operatorUser,
+ GalaxyAckCredential credential,
CancellationToken cancellationToken);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ApiKeyAck/ApiKeyGalaxyAckIdentityService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ApiKeyAck/ApiKeyGalaxyAckIdentityService.cs
new file mode 100644
index 00000000..e5411473
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ApiKeyAck/ApiKeyGalaxyAckIdentityService.cs
@@ -0,0 +1,163 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck;
+
+///
+/// Sets, lists and clears the Galaxy alarm-ack identity mapped to an OPC UA API key (lmxopcua#749).
+/// The identity (DOMAIN\user) and its password are written to the encrypted Secrets store under
+/// names — never to the config DB or the deployment artifact.
+///
+///
+/// A service rather than page logic because this repo has no bUnit; the page only binds to it.
+///
+public interface IApiKeyGalaxyAckIdentityService
+{
+ ///
+ /// Lists the keys that have an identity, as key id → DOMAIN\user. Decrypts only the user
+ /// secrets; never reads a password.
+ ///
+ /// The cancellation token.
+ /// The mapped identities.
+ Task> ListAsync(CancellationToken ct);
+
+ /// Sets (or replaces) a key's Galaxy ack identity.
+ /// The public key id.
+ /// Galaxy user; required.
+ /// Optional domain.
+ /// Password; may be empty (Galaxy accounts can have empty passwords).
+ /// The admin performing the change, for audit.
+ /// The cancellation token.
+ /// A task representing the operation.
+ Task SetAsync(string apiKeyId, string user, string? domain, string? password, string actor, CancellationToken ct);
+
+ /// Removes a key's Galaxy ack identity (no-op when none).
+ /// The public key id.
+ /// The admin performing the change, for audit.
+ /// The cancellation token.
+ /// A task representing the operation.
+ Task ClearAsync(string apiKeyId, string actor, CancellationToken ct);
+}
+
+/// Secrets-store backed .
+public sealed class ApiKeyGalaxyAckIdentityService : IApiKeyGalaxyAckIdentityService
+{
+ private const string UserSuffix = "/galaxy-ack-user";
+
+ private readonly ISecretStore _store;
+ private readonly ISecretCipher _cipher;
+ private readonly IAuditWriter _audit;
+ private readonly ISecretCacheInvalidator? _invalidator;
+
+ /// Initializes a new instance of the class.
+ /// The secret store.
+ /// The secret cipher.
+ /// The audit writer.
+ /// Optional resolver-cache invalidator so a change applies to the next ack.
+ public ApiKeyGalaxyAckIdentityService(
+ ISecretStore store, ISecretCipher cipher, IAuditWriter audit, ISecretCacheInvalidator? invalidator = null)
+ {
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
+ _audit = audit ?? throw new ArgumentNullException(nameof(audit));
+ _invalidator = invalidator;
+ }
+
+ ///
+ public async Task> ListAsync(CancellationToken ct)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var rows = await _store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
+ foreach (var meta in rows)
+ {
+ var name = meta.Name.Value;
+ if (!name.StartsWith(GalaxyAckIdentitySecrets.Prefix, StringComparison.Ordinal)
+ || !name.EndsWith(UserSuffix, StringComparison.Ordinal)) continue;
+
+ var keyId = name[GalaxyAckIdentitySecrets.Prefix.Length..^UserSuffix.Length];
+ if (keyId.Length == 0 || keyId.Contains('/')) continue;
+
+ var row = await _store.GetAsync(meta.Name, ct).ConfigureAwait(false);
+ if (row is null || row.IsDeleted) continue;
+ try
+ {
+ result[keyId] = _cipher.Decrypt(row);
+ }
+ catch (SecretDecryptionException)
+ {
+ result[keyId] = "(unreadable)";
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ public async Task SetAsync(
+ string apiKeyId, string user, string? domain, string? password, string actor, CancellationToken ct)
+ {
+ user = (user ?? string.Empty).Trim();
+ if (user.Length == 0) throw new ArgumentException("A Galaxy user is required.", nameof(user));
+ if (user.Contains('\\')) throw new ArgumentException("Enter the domain in its own field, not as DOMAIN\\user.", nameof(user));
+ if (domain is not null && domain.Contains('\\')) throw new ArgumentException("The domain must not contain '\\'.", nameof(domain));
+
+ var userName = new SecretName(GalaxyAckIdentitySecrets.UserSecretName(apiKeyId));
+ var passwordName = new SecretName(GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId));
+
+ // Password FIRST: the driver refuses a user without a password, so writing in this order means a
+ // concurrent ack never sees a half-written identity it would act on.
+ await UpsertAsync(passwordName, password ?? string.Empty, $"Galaxy ack password for API key {apiKeyId}", actor, ct)
+ .ConfigureAwait(false);
+ await UpsertAsync(userName, GalaxyAckIdentitySecrets.FormatIdentity(user, domain),
+ $"Galaxy ack user for API key {apiKeyId}", actor, ct)
+ .ConfigureAwait(false);
+
+ await WriteAuditAsync("apikey.galaxy-ack-identity.set", apiKeyId, actor, ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task ClearAsync(string apiKeyId, string actor, CancellationToken ct)
+ {
+ var userName = new SecretName(GalaxyAckIdentitySecrets.UserSecretName(apiKeyId));
+ var passwordName = new SecretName(GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId));
+
+ // User first, so the identity disappears before its password does.
+ var removedUser = await _store.DeleteAsync(userName, actor, ct).ConfigureAwait(false);
+ _invalidator?.Invalidate(userName);
+ var removedPassword = await _store.DeleteAsync(passwordName, actor, ct).ConfigureAwait(false);
+ _invalidator?.Invalidate(passwordName);
+
+ if (removedUser || removedPassword)
+ {
+ await WriteAuditAsync("apikey.galaxy-ack-identity.clear", apiKeyId, actor, ct).ConfigureAwait(false);
+ }
+ }
+
+ private async Task UpsertAsync(SecretName name, string value, string description, string actor, CancellationToken ct)
+ {
+ var row = _cipher.Encrypt(name, value, SecretContentType.Text) with
+ {
+ Description = description,
+ CreatedBy = actor,
+ UpdatedBy = actor,
+ };
+ await _store.UpsertAsync(row, ct).ConfigureAwait(false);
+ _invalidator?.Invalidate(name);
+ }
+
+ private Task WriteAuditAsync(string action, string apiKeyId, string actor, CancellationToken ct) =>
+ _audit.WriteAsync(
+ new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = actor,
+ Action = action,
+ Outcome = AuditOutcome.Success,
+ Category = "ApiKeys",
+ // The key id only — never the user's password, and not the account either.
+ Target = apiKeyId,
+ },
+ ct);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor
index bbf436e8..de3d3cf0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor
@@ -4,7 +4,9 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
+@using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck
@inject IApiKeyAdminService Keys
+@inject IApiKeyGalaxyAckIdentityService AckIdentities
@@ -50,6 +52,45 @@
+@if (_ackEditKeyId is not null)
+{
+
+
Galaxy alarm-ack identity — key @_ackEditKeyId
+
+
+ Native Galaxy alarm acknowledges from sessions using this key authenticate as this Galaxy account, and
+ the Historian records it as the operator. Stored encrypted in the Secrets store
+ (otopcua/apikeys/@(_ackEditKeyId)/galaxy-ack-*), never in the database. Keys
+ without an identity use the Galaxy driver's fallback account; with neither, acknowledges are refused.
+ On a split admin/driver topology the two secrets must also exist on the driver nodes.
+
@@ -105,6 +157,8 @@
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
@(k.Enabled ? "Revoke" : "Re-enable")
+
@@ -121,7 +175,17 @@
@code {
+ [CascadingParameter] private Task? AuthState { get; set; }
+
private IReadOnlyList? _keys;
+ private IReadOnlyDictionary _ackIdentities = new Dictionary();
+
+ // Galaxy ack identity editor (lmxopcua#749). The password lives in component state only while the
+ // editor is open and is cleared on save/cancel; it is never rendered back.
+ private string? _ackEditKeyId;
+ private string _ackUser = "";
+ private string _ackDomain = "";
+ private string _ackPassword = "";
private string _newName = "";
private string _newPrincipal = "";
private DateTime? _newExpires;
@@ -142,6 +206,8 @@
try
{
_keys = await Keys.ListAsync(default);
+ try { _ackIdentities = await AckIdentities.ListAsync(default); }
+ catch (Exception ex) { _ackIdentities = new Dictionary(); _error = $"Galaxy ack identities unavailable: {ex.Message}"; }
// The one-time banner is only meaningful while its key exists; the credential is
// otua__, so drop it once no listed key matches (deleted here or elsewhere).
if (_created is not null
@@ -222,9 +288,73 @@
try
{
await Keys.DeleteAsync(apiKeyId, default);
+ // A deleted key's ack identity must not linger in the Secrets store.
+ await AckIdentities.ClearAsync(apiKeyId, await ActorAsync(), default);
+ if (_ackEditKeyId == apiKeyId) CloseAckEditor();
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
+
+ private void OpenAckEditor(string apiKeyId)
+ {
+ _ackEditKeyId = apiKeyId;
+ _ackPassword = "";
+ if (_ackIdentities.TryGetValue(apiKeyId, out var current))
+ {
+ var slash = current.IndexOf('\\');
+ _ackDomain = slash < 0 ? "" : current[..slash];
+ _ackUser = slash < 0 ? current : current[(slash + 1)..];
+ }
+ else
+ {
+ _ackUser = "";
+ _ackDomain = "";
+ }
+ }
+
+ private void CloseAckEditor()
+ {
+ _ackEditKeyId = null;
+ _ackUser = "";
+ _ackDomain = "";
+ _ackPassword = "";
+ }
+
+ private async Task SaveAckIdentityAsync()
+ {
+ if (_ackEditKeyId is not { } id) return;
+ _busy = true;
+ _error = null;
+ try
+ {
+ await AckIdentities.SetAsync(id, _ackUser, _ackDomain, _ackPassword, await ActorAsync(), default);
+ CloseAckEditor();
+ await ReloadAsync();
+ }
+ catch (Exception ex) { _error = ex.Message; }
+ finally { _busy = false; }
+ }
+
+ private async Task ClearAckIdentityAsync()
+ {
+ if (_ackEditKeyId is not { } id) return;
+ _busy = true;
+ _error = null;
+ try
+ {
+ await AckIdentities.ClearAsync(id, await ActorAsync(), default);
+ CloseAckEditor();
+ await ReloadAsync();
+ }
+ catch (Exception ex) { _error = ex.Message; }
+ finally { _busy = false; }
+ }
+
+ private async Task ActorAsync()
+ {
+ var user = AuthState is null ? null : (await AuthState).User;
+ return user?.Identity?.Name is { Length: > 0 } name ? name : "unknown";
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
index b27d5dcc..656cdf38 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
@@ -84,6 +84,35 @@
+@* Native alarm acknowledge credential (lmxopcua#749) *@
+
+
Alarm acknowledge — fallback Galaxy account
+
+
+ The gateway acknowledges by authenticating as a Galaxy user and writing AckMsg, so the Historian
+ records that user. An API key with its own Galaxy ack identity (set on API keys) uses
+ that; keys without one and anonymous sessions use this account. With neither, acknowledges are refused.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Forms: secret:NAME (production), env:NAME, file:PATH, dev:VALUE. A bare literal is refused.
+
+
+
+
+
@* Repository *@
Galaxy repository
@@ -237,12 +266,18 @@
// GalaxyDriverOptions top-level
public int ProbeTimeoutSeconds { get; set; } = 30;
+ // GalaxyAlarmAckOptions (lmxopcua#749)
+ public string? AckFallbackUser { get; set; }
+ public string? AckFallbackDomain { get; set; }
+ public string? AckFallbackPasswordSecretRef { get; set; }
+
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
+ var ack = r.AlarmAck ?? new GalaxyAlarmAckOptions();
return new()
{
GatewayEndpoint = gw.Endpoint,
@@ -262,6 +297,9 @@
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
+ AckFallbackUser = ack.FallbackUser,
+ AckFallbackDomain = ack.FallbackDomain,
+ AckFallbackPasswordSecretRef = ack.FallbackPasswordSecretRef,
};
}
@@ -288,6 +326,10 @@
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
{
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
+ AlarmAck = new GalaxyAlarmAckOptions(
+ FallbackUser: string.IsNullOrWhiteSpace(AckFallbackUser) ? null : AckFallbackUser.Trim(),
+ FallbackDomain: string.IsNullOrWhiteSpace(AckFallbackDomain) ? null : AckFallbackDomain.Trim(),
+ FallbackPasswordSecretRef: string.IsNullOrWhiteSpace(AckFallbackPasswordSecretRef) ? null : AckFallbackPasswordSecretRef.Trim()),
};
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
index 389cdf72..f085754c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
@@ -83,6 +83,12 @@ public static class EndpointRouteBuilderExtensions
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
// services above.
services.AddScoped();
+ // lmxopcua#749: per-API-key Galaxy alarm-ack identity, held in the Secrets store (never the DB).
+ services.AddScoped(sp => new ApiKeyAck.ApiKeyGalaxyAckIdentityService(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetService()));
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped();
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 609b0087..a14a576b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -257,7 +257,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
if (_actorRegistry.TryGet(out var driverHost))
{
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
- nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser));
+ nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser, nativeAck.ApiKeyId));
}
else
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs
index a3a9842b..fb3a3985 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs
@@ -17,4 +17,6 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// used to resolve the ack back to its backing driver.
/// The operator's acknowledge comment text, or null when none was supplied.
/// The authenticated operator's display name (empty when none resolves).
-public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
+/// Public id of the API key the session authenticated with; null for anonymous. Not a
+/// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).
+public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 6b6f5970..b8e36fb9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -2149,7 +2149,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Comment: comment?.Text,
- OperatorUser: AlarmOperatorName(identity!)));
+ OperatorUser: AlarmOperatorName(identity!),
+ ApiKeyId: identity!.ApiKeyId));
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
// condition event (the SDK reports none of its own).
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index fd29c0b8..48def4ed 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -605,7 +605,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// The folder-scoped condition NodeId the operator acknowledged.
/// Operator-supplied comment forwarded to the upstream alarm system; null when none.
/// The authenticated principal performing the acknowledge.
- public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
+ /// Public id of the API key the session authenticated with; null for anonymous. Not a
+ /// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).
+ public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
/// Self-sent by the 03/S15 audit timer, after a driver
/// reconnected: report any of its refs still latched BadNoCommunication. See
@@ -2320,7 +2322,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
- entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser));
+ entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser, msg.ApiKeyId));
}
/// Whether this node should service a Primary-only data-plane operation right now — the single
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index 287e0763..8fda0205 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -76,7 +76,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// (= the equipment tag's FullName/AlarmFullReference).
/// Operator-supplied comment forwarded to the upstream alarm system; null when none.
/// The authenticated principal performing the acknowledge.
- public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser);
+ /// Public id of the API key the session authenticated with; null for anonymous. Not a
+ /// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).
+ public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser, string? ApiKeyId = null);
public sealed record Subscribe(IReadOnlyList FullReferences, TimeSpan PublishingInterval);
///
/// Sets the set of references this driver should keep subscribed for the lifetime of the
@@ -1266,7 +1268,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SourceNodeId: msg.ConditionId,
ConditionId: msg.ConditionId,
Comment: msg.Comment,
- OperatorUser: msg.OperatorUser),
+ OperatorUser: msg.OperatorUser,
+ ApiKeyId: msg.ApiKeyId),
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
@@ -1280,6 +1283,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_log.Info("DriverInstance {Id}: acknowledged native condition {Cond} by {User}",
_driverInstanceId, msg.ConditionId, msg.OperatorUser);
}
+ catch (DriverOperationRefusedException ex)
+ {
+ // A deliberate refusal (e.g. no Galaxy ack credential for this session, lmxopcua#749) — a
+ // configuration verdict, not a backend fault, so no stack trace; the message names the fix.
+ _log.Warning("DriverInstance {Id}: native-alarm acknowledge of {Cond} by {User} refused — {Reason}",
+ _driverInstanceId, msg.ConditionId, msg.OperatorUser, ex.Message);
+ }
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: native-alarm acknowledge of {Cond} failed",
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
index 8fef9521..4d6c8784 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
@@ -171,6 +171,39 @@ public sealed class DriverResiliencePipelineBuilderTests
}));
}
+ ///
+ /// lmxopcua#749: a deliberate driver refusal (e.g. no Galaxy ack credential) is a configuration verdict,
+ /// not a backend fault — it must not count toward the breaker, or a few misconfigured acks would block
+ /// every correctly-configured caller on the host. Positive control: the same number of ordinary
+ /// failures DOES open it ().
+ ///
+ [Fact]
+ public async Task DriverOperationRefused_DoesNotOpenBreaker()
+ {
+ var builder = new DriverResiliencePipelineBuilder();
+ var pipeline = builder.GetOrCreate("drv-test", "host-r", DriverCapability.AlarmAcknowledge, TierAOptions);
+
+ var threshold = TierAOptions.Resolve(DriverCapability.AlarmAcknowledge).BreakerFailureThreshold;
+ threshold.ShouldBeGreaterThan(0);
+ for (var i = 0; i < threshold * 2; i++)
+ {
+ await Should.ThrowAsync(async () =>
+ await pipeline.ExecuteAsync(async _ =>
+ {
+ await Task.Yield();
+ throw new DriverOperationRefusedException("no credential");
+ }));
+ }
+
+ var ran = false;
+ await pipeline.ExecuteAsync(async _ =>
+ {
+ await Task.Yield();
+ ran = true;
+ });
+ ran.ShouldBeTrue("the breaker must stay closed after refusals");
+ }
+
/// Verifies that timeout cancels slow operations.
[Fact]
public async Task Timeout_Cancels_SlowOperation()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
index 15e88571..54bb2324 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs
@@ -191,8 +191,9 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls.Count.ShouldBe(2);
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
- ack.Calls[0].Comment.ShouldBe("shift handover");
- ack.Calls[0].Operator.ShouldBe(string.Empty); // null OperatorUser must fall back to empty string
+ ack.Calls[0].Comment.ShouldBe("shift handover [OPC UA: anonymous]");
+ // lmxopcua#749: the gateway is sent the resolved Galaxy account, never an empty operator.
+ ack.Calls[0].Operator.ShouldBe("ack-op");
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
}
@@ -212,23 +213,24 @@ public sealed class GalaxyDriverAlarmSourceTests
}
///
- /// Verifies that AcknowledgeAsync forwards the request's authenticated principal
- /// () to the acknowledger so the
- /// gateway records WHO acked, rather than the generic empty-string fallback.
+ /// Verifies that the request's OPC UA principal + key id (,
+ /// ) are recorded in the ack comment, and the key id is what
+ /// the credential resolver is asked about — the gateway operator is the resolved Galaxy account (#749).
///
[Fact]
- public async Task AcknowledgeAsync_forwards_OperatorUser()
+ public async Task AcknowledgeAsync_records_OpcUa_principal_in_comment()
{
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(feed, ack);
await driver.AcknowledgeAsync(
- [new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")],
+ [new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice", ApiKeyId: "0a1b2c")],
CancellationToken.None);
ack.Calls.ShouldHaveSingleItem();
- ack.Calls[0].Operator.ShouldBe("alice");
+ ack.Calls[0].Operator.ShouldBe("ack-op");
+ ack.Calls[0].Comment.ShouldBe("cmt [OPC UA: alice, key 0a1b2c]");
}
/// Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.
@@ -257,7 +259,8 @@ public sealed class GalaxyDriverAlarmSourceTests
options: options,
hierarchySource: null,
alarmAcknowledger: alarmAcknowledger,
- alarmFeed: alarmFeed);
+ alarmFeed: alarmFeed,
+ ackCredentialResolver: ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime.FixedAckCredentialResolver.Fallback());
}
private static GalaxyAlarmTransition NewTransition(
@@ -311,12 +314,12 @@ public sealed class GalaxyDriverAlarmSourceTests
/// Records an acknowledge call.
/// The alarm full reference.
/// The acknowledgment comment.
- /// The operator user.
+ /// The resolved Galaxy credential.
/// Cancellation token.
/// A completed task.
- public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
+ public Task AcknowledgeAsync(string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
{
- Calls.Add((alarmFullReference, comment, operatorUser));
+ Calls.Add((alarmFullReference, comment, credential.User));
return Task.CompletedTask;
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
index 66edc998..5272b205 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
@@ -41,6 +41,27 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
+ /// lmxopcua#749: the AlarmAck fallback section binds; absent means no fallback.
+ [Fact]
+ public void CreateInstance_BindsAlarmAckFallback()
+ {
+ GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig).Options.AlarmAck.HasFallback.ShouldBeFalse();
+
+ const string json = """
+ {
+ "Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
+ "MxAccess": { "ClientName": "OtOpcUa-A" },
+ "alarmAck": { "fallbackUser": "ack-svc", "fallbackDomain": "PLANT", "fallbackPasswordSecretRef": "secret:otopcua/galaxy/ack" }
+ }
+ """;
+ var ack = GalaxyDriverFactoryExtensions.CreateInstance("g", json).Options.AlarmAck;
+
+ ack.HasFallback.ShouldBeTrue();
+ ack.FallbackUser.ShouldBe("ack-svc");
+ ack.FallbackDomain.ShouldBe("PLANT");
+ ack.FallbackPasswordSecretRef.ShouldBe("secret:otopcua/galaxy/ack");
+ }
+
/// Verifies that defaults are overridden from full config.
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/CapturingLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/CapturingLogger.cs
new file mode 100644
index 00000000..31754d0b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/CapturingLogger.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
+
+/// Logger that records every formatted message (and exception text) for leak assertions.
+internal sealed class CapturingLogger : ILogger
+{
+ /// Gets the formatted entries, each including the exception's ToString when present.
+ public List<(LogLevel Level, string Text)> Entries { get; } = [];
+
+ /// Gets every entry's text joined, for a single contains-check.
+ public string AllText => string.Join("\n", Entries.Select(e => e.Text));
+
+ ///
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ ///
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ ///
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ // Include the structured state values too — a password passed as a template argument would
+ // reach a structured sink even if the template text omitted it.
+ var values = state is IEnumerable> kvs
+ ? string.Join(";", kvs.Select(kv => $"{kv.Key}={kv.Value}"))
+ : string.Empty;
+ Entries.Add((logLevel, $"{formatter(state, exception)} | {values} | {exception}"));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs
new file mode 100644
index 00000000..14610088
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs
@@ -0,0 +1,417 @@
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.MxGateway.Client;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
+
+///
+/// lmxopcua#749 — operator-authenticated Galaxy ack (mxaccessgw 488649c+): the request carries a real
+/// Galaxy account + password resolved from the API key's identity or the driver's fallback account,
+/// refuses with neither, and never lets the password reach a log line or exception text.
+///
+public sealed class GalaxyAckCredentialTests
+{
+ private const string KeyId = "0a1b2c3d4e5f6071";
+ private const string KeyPassword = "key-pw-S3cr3t!";
+ private const string FallbackPassword = "fallback-pw-S3cr3t!";
+
+ // ------------------------------------------------------------------ request mapping
+
+ [Fact]
+ public void BuildRequest_carries_user_domain_and_password()
+ {
+ var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
+
+ var request = GatewayGalaxyAlarmAcknowledger.BuildRequest("Tank01.Level.HiHi", "investigating", credential);
+
+ request.AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
+ request.Comment.ShouldBe("investigating");
+ request.OperatorUser.ShouldBe("alice");
+ request.OperatorDomain.ShouldBe("CORP");
+ request.OperatorPassword.ShouldBe(KeyPassword);
+ request.ClientCorrelationId.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public void BuildRequest_allows_empty_password_but_never_empty_user()
+ {
+ // Galaxy accounts can have empty passwords (gateway contract) — the field is sent empty.
+ var request = GatewayGalaxyAlarmAcknowledger.BuildRequest(
+ "A.B", "c", new GalaxyAckCredential("bob", null, null, GalaxyAckCredentialSource.Fallback));
+ request.OperatorPassword.ShouldBe(string.Empty);
+ request.OperatorDomain.ShouldBe(string.Empty);
+ request.OperatorUser.ShouldBe("bob");
+
+ // The credential type itself cannot be built with an empty user — the gateway rejects one.
+ Should.Throw(() => new GalaxyAckCredential(" ", "D", "p", GalaxyAckCredentialSource.Fallback));
+ }
+
+ [Fact]
+ public void Credential_and_resolution_ToString_omit_the_password()
+ {
+ var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
+ credential.ToString().ShouldNotContain(KeyPassword);
+ credential.ToString().ShouldContain("CORP\\alice");
+ GalaxyAckCredentialResolution.Resolved(credential).ToString().ShouldNotContain(KeyPassword);
+ }
+
+ // ------------------------------------------------------------------ reply handling / redaction
+
+ [Fact]
+ public void EvaluateReply_protocol_failure_throws_with_password_scrubbed()
+ {
+ var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
+ var reply = new AcknowledgeAlarmReply
+ {
+ ProtocolStatus = new ProtocolStatus
+ {
+ Code = ProtocolStatusCode.InvalidRequest,
+ // Pathological gateway echo — the scrub is defence in depth over the gateway's contract.
+ Message = $"auth failed for alice/{KeyPassword}",
+ },
+ };
+ var logger = new CapturingLogger();
+
+ var ex = Should.Throw(
+ () => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
+
+ ex.ToString().ShouldNotContain(KeyPassword);
+ ex.Message.ShouldContain(GatewayGalaxyAlarmAcknowledger.Redacted);
+ ex.Message.ShouldContain("CORP\\alice");
+ logger.AllText.ShouldNotContain(KeyPassword);
+ }
+
+ [Fact]
+ public void EvaluateReply_native_failure_warns_without_password()
+ {
+ var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
+ var reply = new AcknowledgeAlarmReply
+ {
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ Hresult = unchecked((int)0x80070005),
+ };
+ var logger = new CapturingLogger();
+
+ GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
+
+ logger.Entries.ShouldHaveSingleItem().Level.ShouldBe(LogLevel.Warning);
+ logger.AllText.ShouldContain("CORP\\alice");
+ logger.AllText.ShouldNotContain(KeyPassword);
+ }
+
+ [Fact]
+ public async Task Acknowledger_transport_failure_exception_text_has_no_password()
+ {
+ var client = MxGatewayClient.Create(new MxGatewayClientOptions
+ {
+ Endpoint = new Uri("http://127.0.0.1:1", UriKind.Absolute),
+ ApiKey = "test-key",
+ UseTls = false,
+ ConnectTimeout = TimeSpan.FromMilliseconds(200),
+ DefaultCallTimeout = TimeSpan.FromMilliseconds(200),
+ });
+ await using (client)
+ {
+ var logger = new CapturingLogger();
+ var acknowledger = new GatewayGalaxyAlarmAcknowledger(client, logger);
+ var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
+
+ var ex = await Record.ExceptionAsync(
+ () => acknowledger.AcknowledgeAsync("A.B", "c", credential, CancellationToken.None));
+
+ ex.ShouldNotBeNull();
+ ex.ToString().ShouldNotContain(KeyPassword);
+ logger.AllText.ShouldNotContain(KeyPassword);
+ }
+ }
+
+ [Fact]
+ public void Scrub_replaces_every_occurrence_and_ignores_empty_secret()
+ {
+ GatewayGalaxyAlarmAcknowledger.Scrub("a pw b pw", "pw").ShouldBe("a *** b ***");
+ GatewayGalaxyAlarmAcknowledger.Scrub("unchanged", string.Empty).ShouldBe("unchanged");
+ GatewayGalaxyAlarmAcknowledger.Scrub(null, "pw").ShouldBe(string.Empty);
+ }
+
+ // ------------------------------------------------------------------ resolver paths
+
+ [Fact]
+ public async Task Resolver_key_mapped_identity_wins_over_fallback()
+ {
+ var secrets = new DictSecretResolver
+ {
+ [GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
+ [GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
+ ["otopcua/galaxy/ack-fallback"] = FallbackPassword,
+ };
+ var logger = new CapturingLogger();
+ var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, logger);
+
+ var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
+
+ var credential = result.Credential.ShouldNotBeNull();
+ credential.Source.ShouldBe(GalaxyAckCredentialSource.ApiKey);
+ credential.User.ShouldBe("alice");
+ credential.Domain.ShouldBe("CORP");
+ credential.Password.ShouldBe(KeyPassword);
+ logger.AllText.ShouldNotContain(KeyPassword);
+ }
+
+ [Fact]
+ public async Task Resolver_key_without_identity_uses_fallback()
+ {
+ var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
+ var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
+
+ var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
+
+ credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
+ credential.User.ShouldBe("ack-svc");
+ credential.Domain.ShouldBe("PLANT");
+ credential.Password.ShouldBe(FallbackPassword);
+ }
+
+ [Fact]
+ public async Task Resolver_anonymous_uses_fallback()
+ {
+ var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
+ var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
+
+ var result = await resolver.ResolveAsync(apiKeyId: null, CancellationToken.None);
+
+ result.Credential.ShouldNotBeNull().Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(KeyId)]
+ public async Task Resolver_refuses_with_no_identity_and_no_fallback(string? apiKeyId)
+ {
+ var resolver = new GalaxyAckCredentialResolver(
+ "drv-x", new GalaxyAlarmAckOptions(), new DictSecretResolver(), new CapturingLogger());
+
+ var result = await resolver.ResolveAsync(apiKeyId, CancellationToken.None);
+
+ result.Credential.ShouldBeNull();
+ result.RefusalReason.ShouldNotBeNull();
+ result.RefusalReason.ShouldContain("drv-x");
+ result.RefusalReason.ShouldContain("FallbackUser");
+ }
+
+ [Fact]
+ public async Task Resolver_refuses_key_with_user_but_no_password_even_when_fallback_exists()
+ {
+ // A key the admin explicitly mapped must not silently ack as the shared fallback account.
+ var secrets = new DictSecretResolver
+ {
+ [GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
+ ["otopcua/galaxy/ack-fallback"] = FallbackPassword,
+ };
+ var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
+
+ var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
+
+ result.Credential.ShouldBeNull();
+ result.RefusalReason.ShouldNotBeNull().ShouldContain("no password secret");
+ }
+
+ [Fact]
+ public async Task Resolver_key_with_empty_password_is_allowed()
+ {
+ var secrets = new DictSecretResolver
+ {
+ [GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
+ [GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = string.Empty,
+ };
+ var resolver = new GalaxyAckCredentialResolver("drv", new GalaxyAlarmAckOptions(), secrets, new CapturingLogger());
+
+ var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
+ credential.Password.ShouldBe(string.Empty);
+ credential.Domain.ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public async Task Resolver_refuses_literal_fallback_password_without_echoing_it()
+ {
+ var logger = new CapturingLogger();
+ var resolver = new GalaxyAckCredentialResolver(
+ "drv", new GalaxyAlarmAckOptions("ack-svc", null, FallbackPassword), new DictSecretResolver(), logger);
+
+ var result = await resolver.ResolveAsync(null, CancellationToken.None);
+
+ result.Credential.ShouldBeNull();
+ result.RefusalReason.ShouldNotBeNull().ShouldNotContain(FallbackPassword);
+ logger.AllText.ShouldNotContain(FallbackPassword);
+ }
+
+ [Fact]
+ public async Task Resolver_refuses_when_fallback_secret_absent()
+ {
+ var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), new DictSecretResolver(), new CapturingLogger());
+
+ var result = await resolver.ResolveAsync(null, CancellationToken.None);
+
+ result.Credential.ShouldBeNull();
+ result.RefusalReason.ShouldNotBeNull().ShouldContain("secret:otopcua/galaxy/ack-fallback");
+ }
+
+ [Theory]
+ [InlineData("${secret:otopcua/galaxy/ack-fallback}")]
+ [InlineData("secret:otopcua/galaxy/ack-fallback")]
+ public async Task Resolver_accepts_both_secret_reference_spellings(string reference)
+ {
+ var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
+ var resolver = new GalaxyAckCredentialResolver(
+ "drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, new CapturingLogger());
+
+ (await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
+ .Password.ShouldBe(FallbackPassword);
+ }
+
+ [Fact]
+ public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
+ {
+ var logger = new CapturingLogger();
+ var resolver = new GalaxyAckCredentialResolver(
+ "drv", WithFallback(), new ThrowingSecretResolver(KeyPassword), logger);
+
+ var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
+
+ result.Credential.ShouldBeNull();
+ result.RefusalReason.ShouldNotBeNull().ShouldNotContain(KeyPassword);
+ logger.AllText.ShouldNotContain(KeyPassword);
+ }
+
+ [Fact]
+ public void Identity_secret_names_and_parsing()
+ {
+ GalaxyAckIdentitySecrets.UserSecretName("0A1B").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-user");
+ GalaxyAckIdentitySecrets.PasswordSecretName("0a1b").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-password");
+ Should.Throw(() => GalaxyAckIdentitySecrets.UserSecretName("../x"));
+ GalaxyAckIdentitySecrets.ParseIdentity("CORP\\alice").ShouldBe(("alice", "CORP"));
+ GalaxyAckIdentitySecrets.ParseIdentity("alice").ShouldBe(("alice", string.Empty));
+ GalaxyAckIdentitySecrets.FormatIdentity("alice", "CORP").ShouldBe("CORP\\alice");
+ GalaxyAckIdentitySecrets.FormatIdentity("alice", " ").ShouldBe("alice");
+ }
+
+ // ------------------------------------------------------------------ driver paths
+
+ [Fact]
+ public async Task Driver_key_mapped_path_sends_the_keys_account()
+ {
+ var ack = new CredentialRecordingAcknowledger();
+ var secrets = new DictSecretResolver
+ {
+ [GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
+ [GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
+ };
+ var logger = new CapturingLogger();
+ using var driver = NewDriver(Opts(), ack, secrets, logger);
+
+ await driver.AcknowledgeAsync(
+ [new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None);
+
+ var call = ack.Calls.ShouldHaveSingleItem();
+ call.Credential.User.ShouldBe("alice");
+ call.Credential.Password.ShouldBe(KeyPassword);
+ call.Comment.ShouldBe($"cmt [OPC UA: mes-line1, key {KeyId}]");
+ logger.AllText.ShouldNotContain(KeyPassword);
+ logger.AllText.ShouldContain("mes-line1");
+ }
+
+ [Fact]
+ public async Task Driver_fallback_path_sends_the_fallback_account()
+ {
+ var ack = new CredentialRecordingAcknowledger();
+ var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
+ var logger = new CapturingLogger();
+ using var driver = NewDriver(Opts() with { AlarmAck = WithFallback() }, ack, secrets, logger);
+
+ await driver.AcknowledgeAsync(
+ [new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", null, OperatorUser: "anonymous")], CancellationToken.None);
+
+ var call = ack.Calls.ShouldHaveSingleItem();
+ call.Credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
+ call.Credential.User.ShouldBe("ack-svc");
+ call.Credential.Password.ShouldBe(FallbackPassword);
+ call.Comment.ShouldBe("[OPC UA: anonymous]");
+ logger.AllText.ShouldNotContain(FallbackPassword);
+ }
+
+ [Fact]
+ public async Task Driver_refused_path_sends_nothing_and_throws_refusal()
+ {
+ var ack = new CredentialRecordingAcknowledger();
+ var logger = new CapturingLogger();
+ using var driver = NewDriver(Opts(), ack, new DictSecretResolver(), logger);
+
+ var ex = await Should.ThrowAsync(() => driver.AcknowledgeAsync(
+ [new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None));
+
+ ack.Calls.ShouldBeEmpty(); // never reaches the gateway — and so never with an empty operator_user
+ ex.Message.ShouldContain("Tank01.Level.HiHi");
+ logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Text.Contains("refused"));
+ }
+
+ [Fact]
+ public void Annotate_comment_forms()
+ {
+ GalaxyDriver.AnnotateAckComment("x", "p", "k").ShouldBe("x [OPC UA: p, key k]");
+ GalaxyDriver.AnnotateAckComment(" ", null, null).ShouldBe("[OPC UA: anonymous]");
+ GalaxyDriver.AnnotateAckComment(null, "p", null).ShouldBe("[OPC UA: p]");
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private static GalaxyAlarmAckOptions WithFallback() =>
+ new("ack-svc", "PLANT", "secret:otopcua/galaxy/ack-fallback");
+
+ private static GalaxyDriverOptions Opts() => new(
+ new GalaxyGatewayOptions("https://mxgw.test:5001", "dev:key"),
+ new GalaxyMxAccessOptions("OtOpcUa-A"),
+ new GalaxyRepositoryOptions(),
+ new GalaxyReconnectOptions());
+
+ private static GalaxyDriver NewDriver(
+ GalaxyDriverOptions options, IGalaxyAlarmAcknowledger ack, ISecretResolver secrets, ILogger logger) =>
+ new("drv", options, hierarchySource: null, alarmAcknowledger: ack,
+ logger: new TypedLogger(logger), secretResolver: secrets);
+
+ private sealed class TypedLogger(ILogger inner) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => inner.BeginScope(state);
+ public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel);
+ public void Log(LogLevel l, EventId id, TState s, Exception? e, Func f)
+ => inner.Log(l, id, s, e, f);
+ }
+
+ private sealed class CredentialRecordingAcknowledger : IGalaxyAlarmAcknowledger
+ {
+ public List<(string AlarmRef, string Comment, GalaxyAckCredential Credential)> Calls { get; } = [];
+
+ public Task AcknowledgeAsync(
+ string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
+ {
+ Calls.Add((alarmFullReference, comment, credential));
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class DictSecretResolver : Dictionary, ISecretResolver
+ {
+ public Task GetAsync(SecretName name, CancellationToken ct) =>
+ Task.FromResult(TryGetValue(name.Value, out var v) ? v : null);
+ }
+
+ private sealed class ThrowingSecretResolver(string leak) : ISecretResolver
+ {
+ public Task GetAsync(SecretName name, CancellationToken ct) =>
+ throw new InvalidOperationException($"store exploded near {leak}");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs
index 4fec9f80..89d49f73 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs
@@ -124,7 +124,8 @@ public sealed class GalaxyDriverRawPathKeyingTests
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = new GalaxyDriver(
- "g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed);
+ "g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed,
+ ackCredentialResolver: FixedAckCredentialResolver.Fallback());
var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None);
var observed = new List();
@@ -251,9 +252,9 @@ public sealed class GalaxyDriverRawPathKeyingTests
{
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
- public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
+ public Task AcknowledgeAsync(string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
{
- Calls.Add((alarmFullReference, comment, operatorUser));
+ Calls.Add((alarmFullReference, comment, credential.User));
return Task.CompletedTask;
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmAcknowledgerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmAcknowledgerTests.cs
index dad23968..00f0f217 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmAcknowledgerTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmAcknowledgerTests.cs
@@ -45,10 +45,10 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
public Task AcknowledgeAsync(
string alarmFullReference,
string comment,
- string operatorUser,
+ GalaxyAckCredential credential,
CancellationToken cancellationToken)
{
- Calls.Add((alarmFullReference, comment, operatorUser));
+ Calls.Add((alarmFullReference, comment, credential.User));
return _outcomes.Count > 0 ? _outcomes.Dequeue()() : Task.CompletedTask;
}
}
@@ -59,6 +59,9 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
+ private static readonly GalaxyAckCredential Alice =
+ new("alice", "CORP", "alice-pw", GalaxyAckCredentialSource.ApiKey);
+
private static AlarmAcknowledgeRequest Ack(string conditionId, string comment = "c", string source = "")
=> new(SourceNodeId: source, ConditionId: conditionId, Comment: comment);
@@ -79,7 +82,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
fake.Enqueue(() => Task.CompletedTask);
var driver = new GalaxyDriver("g", Opts(),
- hierarchySource: null, alarmAcknowledger: fake);
+ hierarchySource: null, alarmAcknowledger: fake,
+ ackCredentialResolver: FixedAckCredentialResolver.Fallback());
await Should.ThrowAsync(
() => driver.AcknowledgeAsync(
@@ -93,8 +97,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
fake.Calls.Count.ShouldBe(2);
fake.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
- fake.Calls[0].Comment.ShouldBe("investigating");
- fake.Calls[1].Comment.ShouldBe("resolved");
+ fake.Calls[0].Comment.ShouldBe("investigating [OPC UA: anonymous]");
+ fake.Calls[1].Comment.ShouldBe("resolved [OPC UA: anonymous]");
}
///
@@ -109,7 +113,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
fake.Enqueue(() => Task.CompletedTask);
var driver = new GalaxyDriver("g", Opts(),
- hierarchySource: null, alarmAcknowledger: fake);
+ hierarchySource: null, alarmAcknowledger: fake,
+ ackCredentialResolver: FixedAckCredentialResolver.Fallback());
await Should.ThrowAsync(
() => driver.AcknowledgeAsync(
@@ -131,14 +136,15 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
var fake = new FakeAcknowledger();
var driver = new GalaxyDriver("g", Opts(),
- hierarchySource: null, alarmAcknowledger: fake);
+ hierarchySource: null, alarmAcknowledger: fake,
+ ackCredentialResolver: FixedAckCredentialResolver.Fallback());
await driver.AcknowledgeAsync(
[Ack("Pump01.Flow.Low", "operator comment")], CancellationToken.None);
fake.Calls.ShouldHaveSingleItem();
fake.Calls[0].AlarmRef.ShouldBe("Pump01.Flow.Low");
- fake.Calls[0].Comment.ShouldBe("operator comment");
+ fake.Calls[0].Comment.ShouldBe("operator comment [OPC UA: anonymous]");
}
// ------------------------------------------------------------------
@@ -179,10 +185,10 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
var ex1 = await Record.ExceptionAsync(
() => acknowledger.AcknowledgeAsync(
- "Tank01.Level.HiHi", "c1", "alice", CancellationToken.None));
+ "Tank01.Level.HiHi", "c1", Alice, CancellationToken.None));
var ex2 = await Record.ExceptionAsync(
() => acknowledger.AcknowledgeAsync(
- "Tank01.Level.HiHi", "c2", "alice", CancellationToken.None));
+ "Tank01.Level.HiHi", "c2", Alice, CancellationToken.None));
// Both calls must fault — not a success.
ex1.ShouldNotBeNull("first call to unreachable endpoint should throw");
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/TestAckCredentials.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/TestAckCredentials.cs
new file mode 100644
index 00000000..90f3d46c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/TestAckCredentials.cs
@@ -0,0 +1,33 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
+
+///
+/// test double that resolves every acknowledge to one fixed
+/// credential (or refuses all), recording the key ids it was asked about.
+///
+internal sealed class FixedAckCredentialResolver : IGalaxyAckCredentialResolver
+{
+ private readonly GalaxyAckCredential? _credential;
+
+ private FixedAckCredentialResolver(GalaxyAckCredential? credential) => _credential = credential;
+
+ /// Gets the key ids the driver resolved for, in order.
+ public List RequestedKeyIds { get; } = [];
+
+ /// A resolver that always returns a fallback account ACKDOM\ack-op / pw.
+ public static FixedAckCredentialResolver Fallback() =>
+ new(new GalaxyAckCredential("ack-op", "ACKDOM", "pw", GalaxyAckCredentialSource.Fallback));
+
+ /// A resolver that refuses every acknowledge.
+ public static FixedAckCredentialResolver RefuseAll() => new(null);
+
+ ///
+ public Task ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
+ {
+ RequestedKeyIds.Add(apiKeyId);
+ return Task.FromResult(_credential is null
+ ? GalaxyAckCredentialResolution.Refused("test refusal")
+ : GalaxyAckCredentialResolution.Resolved(_credential));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ApiKeyAck/ApiKeyGalaxyAckIdentityServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ApiKeyAck/ApiKeyGalaxyAckIdentityServiceTests.cs
new file mode 100644
index 00000000..a53f9457
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ApiKeyAck/ApiKeyGalaxyAckIdentityServiceTests.cs
@@ -0,0 +1,156 @@
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ApiKeyAck;
+
+///
+/// lmxopcua#749 — the AdminUI half of the per-key Galaxy ack identity: written ENCRYPTED to the Secrets
+/// store under the names the driver reads, listed without touching the password, cleared on request, and
+/// audited without the credential.
+///
+public sealed class ApiKeyGalaxyAckIdentityServiceTests
+{
+ private const string KeyId = "0a1b2c3d4e5f6071";
+ private const string Password = "pw-S3cr3t!";
+
+ private readonly InMemorySecretStore _store = new();
+ private readonly XorCipher _cipher = new();
+ private readonly RecordingAudit _audit = new();
+ private readonly RecordingInvalidator _invalidator = new();
+
+ private ApiKeyGalaxyAckIdentityService Service() => new(_store, _cipher, _audit, _invalidator);
+
+ [Fact]
+ public async Task Set_writes_both_secrets_encrypted_under_the_driver_names()
+ {
+ await Service().SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
+
+ var user = _store.Rows[GalaxyAckIdentitySecrets.UserSecretName(KeyId)];
+ var pw = _store.Rows[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)];
+ _cipher.Decrypt(user).ShouldBe("CORP\\alice");
+ _cipher.Decrypt(pw).ShouldBe(Password);
+
+ // At rest it is ciphertext, not the password.
+ Encoding.UTF8.GetString(pw.Ciphertext).ShouldNotContain(Password);
+ pw.Description.ShouldNotBeNull().ShouldNotContain(Password);
+
+ // Password written before the user (a reader never sees a user without its password).
+ _store.UpsertOrder.ShouldBe([GalaxyAckIdentitySecrets.PasswordSecretName(KeyId), GalaxyAckIdentitySecrets.UserSecretName(KeyId)]);
+ _invalidator.Invalidated.ShouldContain(GalaxyAckIdentitySecrets.PasswordSecretName(KeyId));
+ }
+
+ [Fact]
+ public async Task List_returns_identity_and_never_decrypts_a_password()
+ {
+ var svc = Service();
+ await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
+ await svc.SetAsync("ffff0000ffff0000", "bob", null, "", "admin", default);
+ _store.GetCalls.Clear();
+
+ var list = await svc.ListAsync(default);
+
+ list[KeyId].ShouldBe("CORP\\alice");
+ list["ffff0000ffff0000"].ShouldBe("bob");
+ _store.GetCalls.ShouldAllBe(n => n.EndsWith("/galaxy-ack-user"));
+ }
+
+ [Fact]
+ public async Task Clear_removes_both_and_audits_without_the_credential()
+ {
+ var svc = Service();
+ await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
+
+ await svc.ClearAsync(KeyId, "admin", default);
+
+ _store.Rows.ShouldBeEmpty();
+ (await svc.ListAsync(default)).ShouldBeEmpty();
+ _audit.Events.Select(e => e.Action).ShouldBe(["apikey.galaxy-ack-identity.set", "apikey.galaxy-ack-identity.clear"]);
+ _audit.Events.ShouldAllBe(e => e.Target == KeyId && e.Actor == "admin");
+ string.Join("|", _audit.Events.Select(e => e.ToString())).ShouldNotContain(Password);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("CORP\\alice")]
+ public async Task Set_rejects_missing_or_domain_qualified_user(string user)
+ {
+ await Should.ThrowAsync(() => Service().SetAsync(KeyId, user, null, Password, "admin", default));
+ _store.Rows.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Set_rejects_a_malformed_key_id()
+ {
+ await Should.ThrowAsync(() => Service().SetAsync("../x", "alice", null, Password, "admin", default));
+ _store.Rows.ShouldBeEmpty();
+ }
+
+ /// Reversible stand-in for the AES-GCM envelope cipher — enough to prove the value is not stored in clear.
+ private sealed class XorCipher : ISecretCipher
+ {
+ public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) => new()
+ {
+ Name = name, ContentType = contentType,
+ Ciphertext = Encoding.UTF8.GetBytes(plaintext).Select(b => (byte)(b ^ 0x5A)).ToArray(),
+ Nonce = [], Tag = [], WrappedDek = [], WrapNonce = [], WrapTag = [],
+ KekId = "test", Revision = 0, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow,
+ };
+
+ public string Decrypt(StoredSecret secret) =>
+ Encoding.UTF8.GetString(secret.Ciphertext.Select(b => (byte)(b ^ 0x5A)).ToArray());
+
+ public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class RecordingAudit : IAuditWriter
+ {
+ public List Events { get; } = [];
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
+ }
+
+ private sealed class RecordingInvalidator : ISecretCacheInvalidator
+ {
+ public List Invalidated { get; } = [];
+ public void Invalidate(SecretName name) => Invalidated.Add(name.Value);
+ }
+
+ private sealed class InMemorySecretStore : ISecretStore
+ {
+ public Dictionary Rows { get; } = new();
+ public List UpsertOrder { get; } = [];
+ public List GetCalls { get; } = [];
+
+ public Task GetAsync(SecretName name, CancellationToken ct)
+ {
+ GetCalls.Add(name.Value);
+ return Task.FromResult(Rows.TryGetValue(name.Value, out var r) ? r : null);
+ }
+
+ public Task UpsertAsync(StoredSecret row, CancellationToken ct)
+ {
+ UpsertOrder.Add(row.Name.Value);
+ Rows[row.Name.Value] = row;
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
+ Task.FromResult(Rows.Remove(name.Value));
+
+ public Task> ListAsync(bool includeDeleted, CancellationToken ct) =>
+ Task.FromResult>(Rows.Values.Select(r => new SecretMetadata
+ {
+ Name = r.Name, ContentType = r.ContentType, KekId = r.KekId, Revision = r.Revision,
+ CreatedUtc = r.CreatedUtc, UpdatedUtc = r.UpdatedUtc,
+ }).ToList());
+
+ public Task> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
+ public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
+ public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
index bb9fe3d3..68239b43 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
@@ -234,4 +234,29 @@ public sealed class GalaxyDriverPageFormSerializationTests
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
form.ProbeTimeoutSeconds.ShouldBe(20);
}
+
+ ///
+ /// lmxopcua#749: the form must carry the AlarmAck fallback through FromRecord/ToRecord, or saving the
+ /// Galaxy driver page would silently drop the fallback account and every anonymous ack would refuse.
+ ///
+ [Fact]
+ public void FormModel_RoundTrips_AlarmAckFallback()
+ {
+ var original = new GalaxyDriverOptions(
+ new GalaxyGatewayOptions("https://gw:5001", "env:K"),
+ new GalaxyMxAccessOptions("OtOpcUa"),
+ new GalaxyRepositoryOptions(),
+ new GalaxyReconnectOptions())
+ {
+ AlarmAck = new GalaxyAlarmAckOptions("ack-svc", "PLANT", "secret:otopcua/galaxy/ack"),
+ };
+
+ var back = GalaxyDriverForm.GalaxyFormModel.FromRecord(original).ToRecord();
+ back.AlarmAck.ShouldBe(original.AlarmAck);
+
+ var json = JsonSerializer.Serialize(back, _pageOpts);
+ json.ShouldContain("\"fallbackPasswordSecretRef\":\"secret:otopcua/galaxy/ack\"");
+ json.ShouldNotContain("hasFallback");
+ JsonSerializer.Deserialize(json, _pageOpts)!.AlarmAck.ShouldBe(original.AlarmAck);
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
index 2254609e..ff40ea1b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -644,6 +644,37 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
+ /// lmxopcua#749 — a native Acknowledge carries the session's API key id (not a secret) so the
+ /// Galaxy driver can resolve the key's Galaxy ack identity; an anonymous / key-less session carries null.
+ [Fact]
+ public async Task Native_OnAcknowledge_carries_the_sessions_ApiKeyId()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ var native = new List();
+ nm.NativeAlarmAckRouter = native.Add;
+
+ nm.EnsureFolder("eq-nak749", parentNodeId: null, displayName: "Equipment 749", realm: AddressSpaceRealm.Uns);
+ nm.MaterialiseAlarmCondition("alm-nak749", "eq-nak749", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
+ var condition = nm.TryGetAlarmCondition("alm-nak749")!;
+
+ var keyed = new ServerSystemContext(server.CurrentInstance)
+ {
+ UserIdentity = new RoleCarryingUserIdentity(
+ new UserNameIdentityToken { UserName = "apikey" }, [OpcUaDataPlaneRoles.AlarmAck],
+ principal: "mes-line1", apiKeyId: "0a1b2c3d4e5f6071"),
+ };
+ condition.OnAcknowledge!(keyed, condition, EventIdBytes(), new LocalizedText("c1")).ShouldBe(ServiceResult.Good);
+ condition.OnAcknowledge!(SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck), condition, EventIdBytes(), new LocalizedText("c2"));
+
+ native.Count.ShouldBe(2);
+ native[0].ApiKeyId.ShouldBe("0a1b2c3d4e5f6071");
+ native[0].OperatorUser.ShouldBe("mes-line1");
+ native[1].ApiKeyId.ShouldBeNull();
+
+ await host.DisposeAsync();
+ }
+
/// Builds a (an )
/// carrying a with the given name + roles — the exact seam the
/// gate reads via (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
index 08d2f449..3be2afc9 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
@@ -60,9 +60,10 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
- actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
+ actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice", ApiKeyId: "0a1b2c"));
- // The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
+ // The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal
+ // and the session's API key id (lmxopcua#749 — the Galaxy driver resolves the key's ack identity).
AwaitAssert(() =>
{
recorder.Acks.Count.ShouldBe(1);
@@ -70,6 +71,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
recorder.Acks[0].Comment.ShouldBe("cmt");
recorder.Acks[0].OperatorUser.ShouldBe("alice");
+ recorder.Acks[0].ApiKeyId.ShouldBe("0a1b2c");
}, duration: Timeout);
}