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

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

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

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

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

Closes #749
This commit is contained in:
Joseph Doherty
2026-11-06 16:23:00 -05:00
parent 7ec5b95d24
commit dc0a24f224
38 changed files with 1814 additions and 83 deletions
@@ -0,0 +1,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",