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. +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (_ackIdentities.ContainsKey(_ackEditKeyId)) + { + + } + +
+
+
+
+} +
Keys
@if (_keys is null) @@ -71,6 +112,7 @@ Principal Key id Status + Galaxy ack identity Created (UTC) Expires (UTC) @@ -97,6 +139,16 @@ active } + + @if (_ackIdentities.TryGetValue(k.ApiKeyId, out var ackId)) + { + @ackId + } + else + { + driver fallback + } + @k.CreatedUtc.ToString("yyyy-MM-dd HH:mm") @(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—") @@ -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); }