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

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

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

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

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

Closes #749
This commit is contained in:
Joseph Doherty
2026-11-06 16:23:00 -05:00
parent 7ec5b95d24
commit dc0a24f224
38 changed files with 1814 additions and 83 deletions
+2 -2
View File
@@ -220,8 +220,8 @@
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
+36
View File
@@ -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 `<alarm>.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/<keyId>/galaxy-ack-user` (`DOMAIN\user` or
`user`) and `otopcua/apikeys/<keyId>/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 —
`"<comment> [OPC UA: <principal>, key <keyId>]"` 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).
+8
View File
@@ -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/<keyId>/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
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class DriverOperationRefusedException : InvalidOperationException
{
/// <summary>Initializes a new instance of the <see cref="DriverOperationRefusedException"/> class.</summary>
/// <param name="message">Why the operation was refused. Must never contain a secret.</param>
public DriverOperationRefusedException(string message)
: base(message)
{
}
}
@@ -49,11 +49,17 @@ public interface IAlarmSubscriptionHandle
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system.</param>
/// <param name="OperatorUser">Authenticated principal performing the ack; null on the raw/non-OPC-UA path.</param>
/// <param name="ApiKeyId">
/// 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.
/// </param>
public sealed record AlarmAcknowledgeRequest(
string SourceNodeId,
string ConditionId,
string? Comment,
string? OperatorUser = null);
string? OperatorUser = null,
string? ApiKeyId = null);
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
@@ -149,7 +149,7 @@ public sealed class DriverResiliencePipelineBuilder
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(100),
MaxDelay = TimeSpan.FromSeconds(5),
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
ShouldHandle = new PredicateBuilder().Handle<Exception>(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<Exception>(ex => ex is not OperationCanceledException),
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException and not DriverOperationRefusedException),
};
if (tracker is not null || logger is not null)
{
@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// The Secrets-store naming convention for an API key's Galaxy alarm-ack identity (lmxopcua#749).
/// Shared by the AdminUI <c>/api-keys</c> page (writer) and the Galaxy driver (reader) so the two can
/// never disagree on a name.
/// </summary>
/// <remarks>
/// <para>
/// Both halves live in the encrypted <c>ZB.MOM.WW.Secrets</c> store — nothing about the identity is
/// written to the config DB or the deployment artifact. The identity secret holds the Galaxy account
/// as <c>DOMAIN\user</c> or bare <c>user</c>; the password secret holds the password (may be empty —
/// Galaxy accounts can have empty passwords; an <i>absent</i> password secret is a refusal).
/// </para>
/// <para>
/// ⚠️ Secrets are node-local (optionally Akka-replicated within one mesh). The secret must exist on
/// the node that serves the OPC UA session and runs the Galaxy driver; on a split admin/driver
/// topology create it on the driver nodes too (their <c>/admin/secrets</c> page or the
/// <c>secret</c> CLI) under exactly these names.
/// </para>
/// </remarks>
public static class GalaxyAckIdentitySecrets
{
/// <summary>Common prefix for every per-key secret.</summary>
public const string Prefix = "otopcua/apikeys/";
/// <summary>Secret holding the key's Galaxy account (<c>DOMAIN\user</c> or <c>user</c>).</summary>
/// <param name="apiKeyId">The public API key id.</param>
/// <returns>The secret name.</returns>
public static string UserSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-user";
/// <summary>Secret holding the key's Galaxy account password.</summary>
/// <param name="apiKeyId">The public API key id.</param>
/// <returns>The secret name.</returns>
public static string PasswordSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-password";
/// <summary>Formats a user + optional domain as the identity secret value.</summary>
/// <param name="user">The Galaxy user name.</param>
/// <param name="domain">The optional domain.</param>
/// <returns><c>DOMAIN\user</c> or <c>user</c>.</returns>
public static string FormatIdentity(string user, string? domain) =>
string.IsNullOrWhiteSpace(domain) ? user.Trim() : $"{domain.Trim()}\\{user.Trim()}";
/// <summary>Splits an identity secret value into user + domain on the first backslash.</summary>
/// <param name="identity">The stored identity value.</param>
/// <returns>The user (trimmed) and the domain (empty when none).</returns>
public static (string User, string Domain) ParseIdentity(string identity)
{
var value = (identity ?? string.Empty).Trim();
var slash = value.IndexOf('\\');
return slash < 0
? (value, string.Empty)
: (value[(slash + 1)..].Trim(), value[..slash].Trim());
}
private static string Normalize(string apiKeyId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(apiKeyId);
var id = apiKeyId.Trim().ToLowerInvariant();
foreach (var c in id)
{
// Key ids are lowercase hex (ApiKeySecret.Generate); refuse anything that could change the
// secret-name path shape (a '/', '..') rather than trusting the caller.
if (!char.IsAsciiLetterOrDigit(c))
throw new ArgumentException($"API key id '{apiKeyId}' is not a valid key id.", nameof(apiKeyId));
}
return id;
}
}
@@ -39,6 +39,47 @@ public sealed record GalaxyDriverOptions(
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Operator-credential policy for native alarm acknowledgement (lmxopcua#749). mxaccessgw
/// (488649c+) acknowledges by authenticating as the operator and writing <c>&lt;alarm&gt;.AckMsg</c>,
/// so every ack needs a Galaxy user + password. See <see cref="GalaxyAlarmAckOptions"/>.
/// </summary>
public GalaxyAlarmAckOptions AlarmAck { get; init; } = new();
}
/// <summary>
/// Where the Galaxy credential for a native alarm acknowledge comes from (lmxopcua#749).
/// </summary>
/// <remarks>
/// <para>
/// The OPC UA data plane is machine-to-machine (API key or anonymous), so the session carries no
/// Galaxy password. Resolution order per acknowledge:
/// <list type="number">
/// <item>The authenticating API key's own Galaxy ack identity, set in AdminUI <c>/api-keys</c> and
/// held in the Secrets store under <see cref="GalaxyAckIdentitySecrets"/> names.</item>
/// <item>This fallback account — used by keys with no identity and by anonymous sessions.</item>
/// <item>Neither → the acknowledge is refused; nothing is sent to the gateway (never an empty
/// <c>operator_user</c>).</item>
/// </list>
/// </para>
/// <para>
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
/// </para>
/// </remarks>
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
/// <param name="FallbackDomain">Optional domain for <paramref name="FallbackUser"/> (sent as <c>operator_domain</c>).</param>
/// <param name="FallbackPasswordSecretRef">Reference to the fallback account's password.</param>
public sealed record GalaxyAlarmAckOptions(
string? FallbackUser = null,
string? FallbackDomain = null,
string? FallbackPasswordSecretRef = null)
{
/// <summary>True when a fallback account is configured.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackUser);
}
/// <summary>
@@ -116,6 +116,9 @@ public sealed class GalaxyDriver
// wiring without a running gateway. This driver bridges the feed's OnAlarmTransition
// onto IAlarmSource.OnAlarmEvent.
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
// lmxopcua#749: resolves the Galaxy account each acknowledge authenticates as (API key identity →
// fallback account → refused). Built in the ctor — it needs only options + the secret resolver.
private readonly IGalaxyAckCredentialResolver _ackCredentialResolver;
private IGalaxyAlarmFeed? _alarmFeed;
private readonly Lock _alarmHandlersLock = new();
private readonly Lock _alarmFeedLock = new();
@@ -300,6 +303,10 @@ public sealed class GalaxyDriver
/// <see cref="InitializeAsync"/> seeds the watcher from it and every rediscovery re-seeds
/// (production builds a <see cref="GatewayGalaxyPlatformSource"/> over the owned repository client).
/// </param>
/// <param name="ackCredentialResolver">
/// Optional ack-credential seam (lmxopcua#749). Defaults to <see cref="GalaxyAckCredentialResolver"/>
/// over <see cref="GalaxyDriverOptions.AlarmAck"/> and <paramref name="secretResolver"/>.
/// </param>
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -312,7 +319,8 @@ public sealed class GalaxyDriver
ILogger<GalaxyDriver>? logger = null,
ISecretResolver? secretResolver = null,
IGalaxyDeployWatchSource? deployWatchSource = null,
IGalaxyPlatformSource? platformSource = null)
IGalaxyPlatformSource? platformSource = null,
IGalaxyAckCredentialResolver? ackCredentialResolver = null)
{
_deployWatchSource = deployWatchSource;
_platformSource = platformSource;
@@ -328,6 +336,8 @@ public sealed class GalaxyDriver
_subscriber = subscriber;
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
@@ -1927,16 +1937,55 @@ public sealed class GalaxyDriver
? ack.ConditionId
: ack.SourceNodeId;
var alarmFullReference = ToAttributeRef(conditionReference);
// lmxopcua#749: the gateway acknowledges as a real Galaxy account (AuthenticateUser +
// AckMsg write), and the OPC UA session has no Galaxy password. Resolve the account mapped
// to the session's API key, else the driver's fallback account; with neither, refuse —
// nothing is sent, and never with an empty operator_user.
var resolution = await _ackCredentialResolver
.ResolveAsync(ack.ApiKeyId, cancellationToken).ConfigureAwait(false);
if (resolution.Credential is not { } credential)
{
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} by OPC UA principal {Principal} (key {ApiKeyId}) refused: {Reason}",
_driverInstanceId, alarmFullReference, PrincipalOf(ack), ack.ApiKeyId ?? "none", resolution.RefusalReason);
throw new DriverOperationRefusedException(
$"Galaxy acknowledge of '{alarmFullReference}' refused: {resolution.RefusalReason}");
}
// The Historian records the Galaxy account; keep the OPC UA principal + key id with the ack
// so it isn't lost when the account is a shared fallback.
var comment = AnnotateAckComment(ack.Comment, ack.OperatorUser, ack.ApiKeyId);
_logger.LogInformation(
"Galaxy driver {DriverInstanceId}: acknowledging {AlarmRef} as Galaxy user {GalaxyUser} ({CredentialSource}) for OPC UA principal {Principal} (key {ApiKeyId})",
_driverInstanceId, alarmFullReference, credential.DisplayName, credential.Source,
PrincipalOf(ack), ack.ApiKeyId ?? "none");
await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference,
ack.Comment ?? string.Empty,
// Honor the authenticated principal carried on the request when present;
// fall back to empty (server-side ACL fills this from the OPC UA session).
operatorUser: ack.OperatorUser ?? string.Empty,
cancellationToken).ConfigureAwait(false);
alarmFullReference, comment, credential, cancellationToken).ConfigureAwait(false);
}
}
private static string PrincipalOf(AlarmAcknowledgeRequest ack) =>
string.IsNullOrWhiteSpace(ack.OperatorUser) ? "anonymous" : ack.OperatorUser;
/// <summary>
/// Appends the OPC UA identity to the ack comment written to <c>AckMsg</c> (lmxopcua#749), e.g.
/// <c>"shift handover [OPC UA: mes-line1, key 0a1b…]"</c> or <c>"[OPC UA: anonymous]"</c>. The
/// Historian's Operator column holds the Galaxy account (possibly a shared fallback), so this is
/// the durable record of which client acknowledged.
/// </summary>
/// <param name="comment">The operator's comment, or null.</param>
/// <param name="principal">The OPC UA principal, or null/empty for anonymous.</param>
/// <param name="apiKeyId">The authenticating key id, or null.</param>
/// <returns>The annotated comment.</returns>
internal static string AnnotateAckComment(string? comment, string? principal, string? apiKeyId)
{
var who = string.IsNullOrWhiteSpace(principal) ? "anonymous" : principal.Trim();
var tag = string.IsNullOrWhiteSpace(apiKeyId) ? $"[OPC UA: {who}]" : $"[OPC UA: {who}, key {apiKeyId}]";
return string.IsNullOrWhiteSpace(comment) ? tag : $"{comment.TrimEnd()} {tag}";
}
/// <summary>
/// Receives <see cref="GalaxyAlarmTransition"/> events from the gateway alarm
/// feed and reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side
@@ -102,11 +102,17 @@ public static class GalaxyDriverFactoryExtensions
// carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its
// RawPath → attributeRef table from these. Empty when the config omits them.
RawTags = dto.RawTags ?? [],
AlarmAck = new GalaxyAlarmAckOptions(
FallbackUser: NullIfBlank(dto.AlarmAck?.FallbackUser),
FallbackDomain: NullIfBlank(dto.AlarmAck?.FallbackDomain),
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef)),
};
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
}
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -131,6 +137,19 @@ public static class GalaxyDriverFactoryExtensions
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets or sets the native alarm-ack credential policy (lmxopcua#749).</summary>
public AlarmAckDto? AlarmAck { get; init; }
}
/// <summary>Native alarm-ack credential section (lmxopcua#749).</summary>
internal sealed class AlarmAckDto
{
/// <summary>Gets or sets the fallback Galaxy user.</summary>
public string? FallbackUser { get; init; }
/// <summary>Gets or sets the fallback user's domain.</summary>
public string? FallbackDomain { get; init; }
/// <summary>Gets or sets the fallback password reference (secret:/env:/file:/dev:).</summary>
public string? FallbackPasswordSecretRef { get; init; }
}
/// <summary>Gateway configuration section.</summary>
@@ -0,0 +1,78 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>Where a <see cref="GalaxyAckCredential"/> came from.</summary>
internal enum GalaxyAckCredentialSource
{
/// <summary>The authenticating API key's own Galaxy ack identity (AdminUI <c>/api-keys</c>).</summary>
ApiKey,
/// <summary>The driver instance's configured fallback ack account.</summary>
Fallback,
}
/// <summary>
/// The Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). The gateway runs
/// <c>AuthenticateUser(domain\user, password)</c> and writes <c>&lt;alarm&gt;.AckMsg</c> with that user,
/// so the Historian records this account as the operator.
/// </summary>
/// <remarks>
/// ⚠️ Carries a password. A class, not a record, so the compiler-generated <c>ToString</c> /
/// <c>PrintMembers</c> can never print it; <see cref="ToString"/> is overridden to omit it. Never log,
/// format or put an instance in an exception message other than via <see cref="ToString"/>.
/// </remarks>
internal sealed class GalaxyAckCredential
{
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredential"/> class.</summary>
/// <param name="user">Galaxy user; must be non-empty (the gateway rejects an empty operator_user).</param>
/// <param name="domain">Domain, or empty.</param>
/// <param name="password">Password (may be empty — Galaxy accounts can have empty passwords).</param>
/// <param name="source">Where the credential came from.</param>
public GalaxyAckCredential(string user, string? domain, string? password, GalaxyAckCredentialSource source)
{
ArgumentException.ThrowIfNullOrWhiteSpace(user);
User = user.Trim();
Domain = domain?.Trim() ?? string.Empty;
Password = password ?? string.Empty;
Source = source;
}
/// <summary>Gets the Galaxy user name.</summary>
public string User { get; }
/// <summary>Gets the domain, or empty.</summary>
public string Domain { get; }
/// <summary>Gets the password. Secret.</summary>
public string Password { get; }
/// <summary>Gets where the credential came from.</summary>
public GalaxyAckCredentialSource Source { get; }
/// <summary>Gets the <c>DOMAIN\user</c> display form (no password).</summary>
public string DisplayName => Domain.Length == 0 ? User : $"{Domain}\\{User}";
/// <inheritdoc />
public override string ToString() => $"{DisplayName} ({Source}, password redacted)";
}
/// <summary>Outcome of resolving the Galaxy ack credential for one acknowledge.</summary>
/// <param name="Credential">The credential, or null when refused.</param>
/// <param name="RefusalReason">Why the acknowledge is refused (never contains a secret), or null.</param>
internal sealed record GalaxyAckCredentialResolution(GalaxyAckCredential? Credential, string? RefusalReason)
{
/// <summary>Creates a successful resolution.</summary>
/// <param name="credential">The resolved credential.</param>
/// <returns>The resolution.</returns>
public static GalaxyAckCredentialResolution Resolved(GalaxyAckCredential credential) => new(credential, null);
/// <summary>Creates a refusal.</summary>
/// <param name="reason">Why; must not contain a secret.</param>
/// <returns>The resolution.</returns>
public static GalaxyAckCredentialResolution Refused(string reason) => new(null, reason);
// Explicit so the generated record ToString never reaches GalaxyAckCredential's members by any
// other path than its own redacting ToString.
/// <inheritdoc />
public override string ToString() =>
Credential is not null ? $"Resolved {Credential}" : $"Refused: {RefusalReason}";
}
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Production <see cref="IGalaxyAckCredentialResolver"/>: the API key's own Galaxy ack identity
/// (Secrets store, <see cref="GalaxyAckIdentitySecrets"/>) → the driver's fallback account
/// (<see cref="GalaxyAlarmAckOptions"/>) → refused.
/// </summary>
/// <remarks>
/// Resolves on every acknowledge rather than caching, so rotating a password or remapping a key takes
/// effect on the next ack; the Secrets resolver caches decrypted values itself. Nothing here logs a
/// password: log lines and refusal reasons name only users, key ids and secret NAMES.
/// </remarks>
internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
{
private readonly string _driverInstanceId;
private readonly GalaxyAlarmAckOptions _options;
private readonly ISecretResolver _secrets;
private readonly ILogger _logger;
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
/// <param name="options">The driver's ack-credential options.</param>
/// <param name="secrets">The shared secret resolver.</param>
/// <param name="logger">Logger; never receives a secret.</param>
public GalaxyAckCredentialResolver(
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
{
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
{
string? keyNote = null;
if (!string.IsNullOrWhiteSpace(apiKeyId))
{
var keyResult = await ResolveKeyIdentityAsync(apiKeyId, cancellationToken).ConfigureAwait(false);
if (keyResult.Credential is not null) return keyResult;
// A key whose identity is half-configured (user without password, unreadable secret) is
// refused outright — silently acknowledging as the fallback account would record the wrong
// operator for a key the administrator explicitly mapped.
if (keyResult.RefusalReason is not null) return keyResult;
keyNote = $"API key '{apiKeyId}' has no Galaxy ack identity";
}
if (_options.HasFallback)
{
return await ResolveFallbackAsync(cancellationToken).ConfigureAwait(false);
}
return GalaxyAckCredentialResolution.Refused(
$"{keyNote ?? "the session is anonymous (no API key)"} and Galaxy driver '{_driverInstanceId}' has no " +
"AlarmAck.FallbackUser configured; the acknowledge was not sent. Map a Galaxy ack identity to the key " +
"in AdminUI /api-keys or configure the driver's fallback ack account.");
}
// Returns Resolved, Refused (mapped but broken), or (null, null) = "no identity mapped".
private async Task<GalaxyAckCredentialResolution> ResolveKeyIdentityAsync(string apiKeyId, CancellationToken ct)
{
string userName, passwordName;
try
{
userName = GalaxyAckIdentitySecrets.UserSecretName(apiKeyId);
passwordName = GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId);
}
catch (ArgumentException)
{
return GalaxyAckCredentialResolution.Refused($"API key id '{apiKeyId}' is malformed; the acknowledge was not sent.");
}
string? identity;
string? password;
try
{
identity = await _secrets.GetAsync(new SecretName(userName), ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(identity)) return new GalaxyAckCredentialResolution(null, null);
password = await _secrets.GetAsync(new SecretName(passwordName), ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The exception type only — a secrets-layer message is not ours to vouch for.
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: reading the Galaxy ack identity of API key {ApiKeyId} failed ({ExceptionType})",
_driverInstanceId, apiKeyId, ex.GetType().Name);
return GalaxyAckCredentialResolution.Refused(
$"the Galaxy ack identity of API key '{apiKeyId}' could not be read from the Secrets store; the acknowledge was not sent.");
}
if (password is null)
{
return GalaxyAckCredentialResolution.Refused(
$"API key '{apiKeyId}' has a Galaxy ack user but no password secret '{passwordName}'; the acknowledge was not sent.");
}
var (user, domain) = GalaxyAckIdentitySecrets.ParseIdentity(identity);
if (user.Length == 0)
{
return GalaxyAckCredentialResolution.Refused(
$"API key '{apiKeyId}' has a malformed Galaxy ack user secret '{userName}'; the acknowledge was not sent.");
}
return GalaxyAckCredentialResolution.Resolved(
new GalaxyAckCredential(user, domain, password, GalaxyAckCredentialSource.ApiKey));
}
private async Task<GalaxyAckCredentialResolution> ResolveFallbackAsync(CancellationToken ct)
{
var reference = _options.FallbackPasswordSecretRef;
if (string.IsNullOrWhiteSpace(reference))
{
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' has AlarmAck.FallbackUser but no AlarmAck.FallbackPasswordSecretRef; the acknowledge was not sent.");
}
try
{
var password = await ResolvePasswordRefAsync(reference.Trim(), ct).ConfigureAwait(false);
if (password is null)
{
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' did not resolve; the acknowledge was not sent.");
}
return GalaxyAckCredentialResolution.Resolved(new GalaxyAckCredential(
_options.FallbackUser!, _options.FallbackDomain, password, GalaxyAckCredentialSource.Fallback));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: resolving the fallback ack password reference {Reference} failed ({ExceptionType})",
_driverInstanceId, DescribeRef(reference), ex.GetType().Name);
return GalaxyAckCredentialResolution.Refused(
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' could not be resolved; the acknowledge was not sent.");
}
}
/// <summary>
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
/// </summary>
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
{
reference = "secret:" + reference["${secret:".Length..^1];
}
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
}
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
return Environment.GetEnvironmentVariable(reference[4..]);
}
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = reference[5..];
return File.Exists(path)
? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
: null;
}
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
return reference[4..];
}
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
}
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
internal static string DescribeRef(string reference)
{
var r = reference.Trim();
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
return r;
}
return "<literal redacted>";
}
}
@@ -6,20 +6,35 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Production <see cref="IGalaxyAlarmAcknowledger"/> backed by the session-less
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The updated gateway routes
/// acknowledgement through its always-on central alarm monitor, so no worker
/// session is involved — the driver supplies only the alarm reference, comment,
/// and operator principal.
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The gateway routes acknowledgement
/// through its always-on central alarm monitor, so no worker session is involved.
/// </summary>
/// <remarks>
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess
/// (transport / dispatch failure) and is surfaced as a thrown exception. A non-zero
/// native ack return code (<c>hresult</c>) means MXAccess itself rejected the ack;
/// that is logged as a warning rather than thrown so a transient MXAccess hiccup
/// doesn't block the operator workflow — the operator can retry.
/// <para>
/// <b>Operator-authenticated ack (mxaccessgw 488649c+, lmxopcua#749).</b> The gateway runs
/// <c>AuthenticateUser</c> with <c>operator_user</c>/<c>operator_domain</c>/<c>operator_password</c>
/// and writes the comment to <c>&lt;alarm&gt;.AckMsg</c> as that user, so the request always
/// carries a real Galaxy account — never an empty <c>operator_user</c>, which the gateway rejects.
/// </para>
/// <para>
/// <b>The password never leaves the request.</b> It is not logged, and every message this type
/// builds from gateway output is scrubbed of it (the gateway contract already keeps it out of
/// <c>ProtocolStatus.Message</c>; the scrub is defence in depth). The 0.3.0 client redacts it from
/// its own <c>MxGatewayException</c>s.
/// </para>
/// <para>
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess (transport /
/// dispatch / validation failure) and is surfaced as a thrown exception. A non-zero native ack
/// return code (<c>hresult</c> — now the <c>AuthenticateUser</c> HRESULT or the AckMsg write status)
/// means MXAccess rejected the ack; that is logged as a warning rather than thrown so a transient
/// MXAccess hiccup doesn't block the operator workflow — the operator can retry.
/// </para>
/// </remarks>
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
{
/// <summary>Placeholder substituted for the password wherever gateway text is echoed.</summary>
internal const string Redacted = "***";
private readonly MxGatewayClient _client;
private readonly ILogger _logger;
@@ -36,38 +51,99 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
public async Task AcknowledgeAsync(
string alarmFullReference,
string comment,
string operatorUser,
GalaxyAckCredential credential,
CancellationToken cancellationToken)
{
var request = BuildRequest(alarmFullReference, comment, credential);
AcknowledgeAlarmReply reply;
try
{
reply = await _client.AcknowledgeAlarmAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException
&& credential.Password.Length > 0
&& ContainsSecret(ex, credential.Password))
{
// Defence in depth over the client's own redaction: never let an exception that echoes the
// password escape into the actor's warning log.
throw new InvalidOperationException(
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway ({ex.GetType().Name}); "
+ "details withheld because they echoed the operator credential.");
}
EvaluateReply(reply, alarmFullReference, credential, _logger);
}
/// <summary>Maps an acknowledge onto the gateway request. Pure — pinned by unit tests.</summary>
/// <param name="alarmFullReference">The alarm reference.</param>
/// <param name="comment">The ack comment.</param>
/// <param name="credential">The Galaxy account to acknowledge as.</param>
/// <returns>The request.</returns>
internal static AcknowledgeAlarmRequest BuildRequest(
string alarmFullReference, string comment, GalaxyAckCredential credential)
{
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
ArgumentNullException.ThrowIfNull(credential);
var reply = await _client.AcknowledgeAlarmAsync(
new AcknowledgeAlarmRequest
{
ClientCorrelationId = Guid.NewGuid().ToString("N"),
AlarmFullReference = alarmFullReference,
Comment = comment ?? string.Empty,
OperatorUser = operatorUser ?? string.Empty,
},
cancellationToken).ConfigureAwait(false);
return new AcknowledgeAlarmRequest
{
ClientCorrelationId = Guid.NewGuid().ToString("N"),
AlarmFullReference = alarmFullReference,
Comment = comment ?? string.Empty,
OperatorUser = credential.User,
OperatorDomain = credential.Domain,
OperatorPassword = credential.Password,
};
}
/// <summary>
/// Applies the reply semantics: non-OK protocol status throws; a non-zero native code warns.
/// Every message is scrubbed of the password.
/// </summary>
/// <param name="reply">The gateway reply.</param>
/// <param name="alarmFullReference">The alarm reference.</param>
/// <param name="credential">The credential the request carried.</param>
/// <param name="logger">The logger.</param>
internal static void EvaluateReply(
AcknowledgeAlarmReply reply, string alarmFullReference, GalaxyAckCredential credential, ILogger logger)
{
// Protocol status — the gateway failed before MXAccess saw the ack. This is a
// hard failure: the operator's request was not delivered at all.
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
{
throw new InvalidOperationException(
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway: "
+ $"{proto.Code} {proto.Message}");
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' as '{credential.DisplayName}' failed at the gateway: "
+ $"{proto.Code} {Scrub(proto.Message, credential.Password)}");
}
// hresult is the authoritative native ack return code (0 = success). It is
// absent only on a worker protocol violation; with an OK protocol status a
// missing value is treated as success.
// hresult is the authoritative native ack return code (0 = success) — the AuthenticateUser HRESULT
// or the AckMsg write status. It is absent only on a worker protocol violation; with an OK protocol
// status a missing value is treated as success.
if (reply.HasHresult && reply.Hresult != 0)
{
_logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} returned native ack failure code {Hresult}.",
alarmFullReference, reply.Hresult);
logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} returned native ack failure code {Hresult}.",
alarmFullReference, credential.DisplayName, reply.Hresult);
}
}
/// <summary>Replaces every occurrence of <paramref name="secret"/> in <paramref name="text"/>.</summary>
/// <param name="text">Text that may echo the secret.</param>
/// <param name="secret">The secret; empty means nothing to scrub.</param>
/// <returns>The scrubbed text.</returns>
internal static string Scrub(string? text, string secret) =>
string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secret)
? text ?? string.Empty
: text.Replace(secret, Redacted, StringComparison.Ordinal);
private static bool ContainsSecret(Exception ex, string secret)
{
for (var e = ex; e is not null; e = e.InnerException)
{
if (e.Message.Contains(secret, StringComparison.Ordinal)) return true;
}
return false;
}
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Resolves the Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). Seam so
/// <see cref="GalaxyDriver.AcknowledgeAsync"/> can be exercised without a Secrets store.
/// </summary>
internal interface IGalaxyAckCredentialResolver
{
/// <summary>
/// Resolves the credential for an acknowledge from a session that authenticated with
/// <paramref name="apiKeyId"/> (null = anonymous / no key). Never throws for a missing or
/// unreadable credential — that is a <see cref="GalaxyAckCredentialResolution.Refused"/>.
/// </summary>
/// <param name="apiKeyId">The authenticating API key id, or null.</param>
/// <param name="cancellationToken">Cancels secret resolution.</param>
/// <returns>The resolved credential or the refusal reason.</returns>
Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken);
}
@@ -1,5 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
@@ -11,23 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
internal interface IGalaxyAlarmAcknowledger
{
/// <summary>
/// Forward a single alarm acknowledgement to the gateway. The gateway
/// translates this to an MxAccess Acknowledge call against the worker's
/// session and returns the native MxStatus on the reply.
/// Forward a single alarm acknowledgement to the gateway. The gateway (488649c+)
/// authenticates <paramref name="credential"/> and writes <paramref name="comment"/>
/// to <c>&lt;alarm&gt;.AckMsg</c> as that user, so the Historian records it as the operator.
/// </summary>
/// <param name="alarmFullReference">
/// Fully-qualified alarm reference (e.g. <c>"Tank01.Level.HiHi"</c>).
/// </param>
/// <param name="comment">Operator-supplied comment forwarded to MxAccess.</param>
/// <param name="operatorUser">
/// Operator principal performing the acknowledgement. Resolved from the
/// OPC UA session by the server-side ACL layer before reaching the driver.
/// <param name="comment">Ack comment written to <c>AckMsg</c>.</param>
/// <param name="credential">
/// The Galaxy account to acknowledge as, resolved by <see cref="IGalaxyAckCredentialResolver"/>.
/// Carries a password — implementations must keep it out of logs and exception text.
/// </param>
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AcknowledgeAsync(
string alarmFullReference,
string comment,
string operatorUser,
GalaxyAckCredential credential,
CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// Sets, lists and clears the Galaxy alarm-ack identity mapped to an OPC UA API key (lmxopcua#749).
/// The identity (<c>DOMAIN\user</c>) and its password are written to the encrypted Secrets store under
/// <see cref="GalaxyAckIdentitySecrets"/> names — never to the config DB or the deployment artifact.
/// </summary>
/// <remarks>
/// A service rather than page logic because this repo has no bUnit; the page only binds to it.
/// </remarks>
public interface IApiKeyGalaxyAckIdentityService
{
/// <summary>
/// Lists the keys that have an identity, as key id → <c>DOMAIN\user</c>. Decrypts only the user
/// secrets; never reads a password.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The mapped identities.</returns>
Task<IReadOnlyDictionary<string, string>> ListAsync(CancellationToken ct);
/// <summary>Sets (or replaces) a key's Galaxy ack identity.</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="user">Galaxy user; required.</param>
/// <param name="domain">Optional domain.</param>
/// <param name="password">Password; may be empty (Galaxy accounts can have empty passwords).</param>
/// <param name="actor">The admin performing the change, for audit.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task SetAsync(string apiKeyId, string user, string? domain, string? password, string actor, CancellationToken ct);
/// <summary>Removes a key's Galaxy ack identity (no-op when none).</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="actor">The admin performing the change, for audit.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task ClearAsync(string apiKeyId, string actor, CancellationToken ct);
}
/// <summary>Secrets-store backed <see cref="IApiKeyGalaxyAckIdentityService"/>.</summary>
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;
/// <summary>Initializes a new instance of the <see cref="ApiKeyGalaxyAckIdentityService"/> class.</summary>
/// <param name="store">The secret store.</param>
/// <param name="cipher">The secret cipher.</param>
/// <param name="audit">The audit writer.</param>
/// <param name="invalidator">Optional resolver-cache invalidator so a change applies to the next ack.</param>
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;
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, string>> ListAsync(CancellationToken ct)
{
var result = new Dictionary<string, string>(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;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
@@ -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
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -50,6 +52,45 @@
</div>
</section>
@if (_ackEditKeyId is not null)
{
<section class="panel mb-3">
<div class="panel-head">Galaxy alarm-ack identity — key <span class="mono">@_ackEditKeyId</span></div>
<div style="padding:1rem">
<div class="form-text mb-2">
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
(<span class="mono">otopcua/apikeys/@(_ackEditKeyId)/galaxy-ack-*</span>), 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.
</div>
<div class="row g-2">
<div class="col-md-3">
<label class="form-label" for="ack-user">Galaxy user</label>
<input id="ack-user" class="form-control form-control-sm" @bind="_ackUser" autocomplete="off" />
</div>
<div class="col-md-3">
<label class="form-label" for="ack-domain">Domain (optional)</label>
<input id="ack-domain" class="form-control form-control-sm" @bind="_ackDomain" autocomplete="off" />
</div>
<div class="col-md-3">
<label class="form-label" for="ack-password">Password</label>
<input id="ack-password" type="password" class="form-control form-control-sm" @bind="_ackPassword"
autocomplete="new-password" />
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<button class="btn btn-sm btn-primary" @onclick="SaveAckIdentityAsync" disabled="@_busy">Save</button>
@if (_ackIdentities.ContainsKey(_ackEditKeyId))
{
<button class="btn btn-sm btn-outline-danger" @onclick="ClearAckIdentityAsync" disabled="@_busy">Remove</button>
}
<button class="btn btn-sm btn-secondary" @onclick="CloseAckEditor" disabled="@_busy">Cancel</button>
</div>
</div>
</div>
</section>
}
<section class="panel">
<div class="panel-head">Keys</div>
@if (_keys is null)
@@ -71,6 +112,7 @@
<th>Principal</th>
<th>Key id</th>
<th>Status</th>
<th>Galaxy ack identity</th>
<th>Created (UTC)</th>
<th>Expires (UTC)</th>
<th></th>
@@ -97,6 +139,16 @@
<span class="badge bg-success">active</span>
}
</td>
<td>
@if (_ackIdentities.TryGetValue(k.ApiKeyId, out var ackId))
{
<span class="mono">@ackId</span>
}
else
{
<span class="text-muted">driver fallback</span>
}
</td>
<td>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
<td class="text-end">
@@ -105,6 +157,8 @@
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
@(k.Enabled ? "Revoke" : "Re-enable")
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => OpenAckEditor(k.ApiKeyId)" disabled="@_busy">Ack identity</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => RequestDelete(k)" disabled="@_busy">Delete</button>
</div>
@@ -121,7 +175,17 @@
</PageErrorBoundary>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private IReadOnlyList<ApiKey>? _keys;
private IReadOnlyDictionary<string, string> _ackIdentities = new Dictionary<string, string>();
// 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<string, string>(); _error = $"Galaxy ack identities unavailable: {ex.Message}"; }
// The one-time banner is only meaningful while its key exists; the credential is
// otua_<keyId>_<secret>, 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<string> ActorAsync()
{
var user = AuthState is null ? null : (await AuthState).User;
return user?.Identity?.Name is { Length: > 0 } name ? name : "unknown";
}
}
@@ -84,6 +84,35 @@
</div>
</section>
@* Native alarm acknowledge credential (lmxopcua#749) *@
<section class="panel rise mt-3" style="animation-delay:.09s">
<div class="panel-head">Alarm acknowledge — fallback Galaxy account</div>
<div style="padding:1rem">
<div class="form-text mb-2">
The gateway acknowledges by authenticating as a Galaxy user and writing <code>AckMsg</code>, so the Historian
records that user. An API key with its own Galaxy ack identity (set on <a href="/apikeys">API keys</a>) uses
that; keys without one and anonymous sessions use this account. With neither, acknowledges are refused.
</div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Fallback user</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackUser" @bind-Value:after="EmitAsync" class="form-control form-control-sm"
placeholder="(none — refuse)" />
</div>
<div class="col-md-3">
<label class="form-label">Fallback domain (optional)</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackDomain" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-6">
<label class="form-label">Fallback password secret ref</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
</div>
</div>
</div>
</section>
@* Repository *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Galaxy repository</div>
@@ -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()),
};
}
}
@@ -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<Acls.IAclScopeService, Acls.AclScopeService>();
// lmxopcua#749: per-API-key Galaxy alarm-ack identity, held in the Secrets store (never the DB).
services.AddScoped<ApiKeyAck.IApiKeyGalaxyAckIdentityService>(sp => new ApiKeyAck.ApiKeyGalaxyAckIdentityService(
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretStore>(),
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretCipher>(),
sp.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
sp.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>()));
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
@@ -257,7 +257,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
if (_actorRegistry.TryGet<DriverHostActorKey>(out var driverHost))
{
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser));
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser, nativeAck.ApiKeyId));
}
else
{
@@ -17,4 +17,6 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// used to resolve the ack back to its backing driver.</param>
/// <param name="Comment">The operator's acknowledge comment text, or <c>null</c> when none was supplied.</param>
/// <param name="OperatorUser">The authenticated operator's display name (empty when none resolves).</param>
public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
/// <param name="ApiKeyId">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).</param>
public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
@@ -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).
@@ -605,7 +605,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="ConditionNodeId">The folder-scoped condition NodeId the operator acknowledged.</param>
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
/// <param name="ApiKeyId">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).</param>
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
/// <summary>Self-sent by the 03/S15 audit timer, <see cref="_commsRecoveryAuditDelay"/> after a driver
/// reconnected: report any of its refs still latched <c>BadNoCommunication</c>. 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));
}
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
@@ -76,7 +76,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// (= the equipment tag's <c>FullName</c>/AlarmFullReference).</param>
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser);
/// <param name="ApiKeyId">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).</param>
public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser, string? ApiKeyId = null);
public sealed record Subscribe(IReadOnlyList<string> FullReferences, TimeSpan PublishingInterval);
/// <summary>
/// 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",
@@ -171,6 +171,39 @@ public sealed class DriverResiliencePipelineBuilderTests
}));
}
/// <summary>
/// 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 (<see cref="CircuitBreaker_Opens_AfterFailureThreshold_OnTierA"/>).
/// </summary>
[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<DriverOperationRefusedException>(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");
}
/// <summary>Verifies that timeout cancels slow operations.</summary>
[Fact]
public async Task Timeout_Cancels_SlowOperation()
@@ -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
}
/// <summary>
/// Verifies that AcknowledgeAsync forwards the request's authenticated principal
/// (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) 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 (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>,
/// <see cref="AlarmAcknowledgeRequest.ApiKeyId"/>) 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).
/// </summary>
[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]");
}
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
@@ -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
/// <summary>Records an acknowledge call.</summary>
/// <param name="alarmFullReference">The alarm full reference.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user.</param>
/// <param name="credential">The resolved Galaxy credential.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
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;
}
}
@@ -41,6 +41,27 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
/// <summary>lmxopcua#749: the AlarmAck fallback section binds; absent means no fallback.</summary>
[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");
}
/// <summary>Verifies that defaults are overridden from full config.</summary>
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>Logger that records every formatted message (and exception text) for leak assertions.</summary>
internal sealed class CapturingLogger : ILogger
{
/// <summary>Gets the formatted entries, each including the exception's ToString when present.</summary>
public List<(LogLevel Level, string Text)> Entries { get; } = [];
/// <summary>Gets every entry's text joined, for a single contains-check.</summary>
public string AllText => string.Join("\n", Entries.Select(e => e.Text));
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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<KeyValuePair<string, object?>> kvs
? string.Join(";", kvs.Select(kv => $"{kv.Key}={kv.Value}"))
: string.Empty;
Entries.Add((logLevel, $"{formatter(state, exception)} | {values} | {exception}"));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() => 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<InvalidOperationException>(
() => 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<ArgumentException>(() => 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<DriverOperationRefusedException>(() => 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<GalaxyDriver>(logger), secretResolver: secrets);
private sealed class TypedLogger<T>(ILogger inner) : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => inner.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel);
public void Log<TState>(LogLevel l, EventId id, TState s, Exception? e, Func<TState, Exception?, string> 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<string, string>, ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(TryGetValue(name.Value, out var v) ? v : null);
}
private sealed class ThrowingSecretResolver(string leak) : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
throw new InvalidOperationException($"store exploded near {leak}");
}
}
@@ -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<AlarmEventArgs>();
@@ -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;
}
}
@@ -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<IOException>(
() => 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]");
}
/// <summary>
@@ -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<InvalidOperationException>(
() => 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");
@@ -0,0 +1,33 @@
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// <see cref="IGalaxyAckCredentialResolver"/> test double that resolves every acknowledge to one fixed
/// credential (or refuses all), recording the key ids it was asked about.
/// </summary>
internal sealed class FixedAckCredentialResolver : IGalaxyAckCredentialResolver
{
private readonly GalaxyAckCredential? _credential;
private FixedAckCredentialResolver(GalaxyAckCredential? credential) => _credential = credential;
/// <summary>Gets the key ids the driver resolved for, in order.</summary>
public List<string?> RequestedKeyIds { get; } = [];
/// <summary>A resolver that always returns a fallback account <c>ACKDOM\ack-op</c> / <c>pw</c>.</summary>
public static FixedAckCredentialResolver Fallback() =>
new(new GalaxyAckCredential("ack-op", "ACKDOM", "pw", GalaxyAckCredentialSource.Fallback));
/// <summary>A resolver that refuses every acknowledge.</summary>
public static FixedAckCredentialResolver RefuseAll() => new(null);
/// <inheritdoc />
public Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
{
RequestedKeyIds.Add(apiKeyId);
return Task.FromResult(_credential is null
? GalaxyAckCredentialResolution.Refused("test refusal")
: GalaxyAckCredentialResolution.Resolved(_credential));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() => Service().SetAsync(KeyId, user, null, Password, "admin", default));
_store.Rows.ShouldBeEmpty();
}
[Fact]
public async Task Set_rejects_a_malformed_key_id()
{
await Should.ThrowAsync<ArgumentException>(() => Service().SetAsync("../x", "alice", null, Password, "admin", default));
_store.Rows.ShouldBeEmpty();
}
/// <summary>Reversible stand-in for the AES-GCM envelope cipher — enough to prove the value is not stored in clear.</summary>
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<AuditEvent> Events { get; } = [];
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
}
private sealed class RecordingInvalidator : ISecretCacheInvalidator
{
public List<string> Invalidated { get; } = [];
public void Invalidate(SecretName name) => Invalidated.Add(name.Value);
}
private sealed class InMemorySecretStore : ISecretStore
{
public Dictionary<string, StoredSecret> Rows { get; } = new();
public List<string> UpsertOrder { get; } = [];
public List<string> GetCalls { get; } = [];
public Task<StoredSecret?> 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<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
Task.FromResult(Rows.Remove(name.Value));
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretMetadata>>(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<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
}
}
@@ -234,4 +234,29 @@ public sealed class GalaxyDriverPageFormSerializationTests
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
form.ProbeTimeoutSeconds.ShouldBe(20);
}
/// <summary>
/// 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.
/// </summary>
[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<GalaxyDriverOptions>(json, _pageOpts)!.AlarmAck.ShouldBe(original.AlarmAck);
}
}
@@ -644,6 +644,37 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
/// <summary>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.</summary>
[Fact]
public async Task Native_OnAcknowledge_carries_the_sessions_ApiKeyId()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var native = new List<NativeAlarmAck>();
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();
}
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
@@ -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);
}