feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
v2-ci / unit-tests (push) Failing after 1h8m43s
v2-ci / unit-tests (push) Failing after 1h8m43s
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:
1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
held ONLY in the encrypted Secrets store
(otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
operator_user. Refusals are excluded from the resilience retry/breaker.
- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
acknowledger scrubs it from every gateway message it echoes and withholds
any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
path is fire-and-forget after the Part 9 method returns).
Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.
Closes #749
This commit is contained in:
@@ -0,0 +1,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";
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user