Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor
T
Joseph Doherty dc0a24f224
v2-ci / unit-tests (push) Failing after 1h8m43s
feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
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
2026-11-06 16:23:00 -05:00

361 lines
14 KiB
Plaintext

@page "/apikeys"
@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
@rendermode RenderMode.InteractiveServer
@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">
<h4 class="mb-0">OPC UA API keys</h4>
</div>
@if (_created is not null)
{
<div class="alert alert-success" role="alert">
<strong>Copy this key now — it is not stored and cannot be shown again.</strong>
<pre class="mono mt-2 mb-0" style="white-space:pre-wrap;word-break:break-all">@_created</pre>
<button class="btn btn-sm btn-secondary mt-2" @onclick="() => _created = null">Dismiss</button>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="alert alert-danger" role="alert">@_error</div>
}
<section class="panel mb-3">
<div class="panel-head">Create a key</div>
<div style="padding:1rem">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label" for="k-name">Name</label>
<input id="k-name" class="form-control form-control-sm" @bind="_newName"
placeholder="ScadaBridge DCL" />
</div>
<div class="col-md-3">
<label class="form-label" for="k-principal">Principal</label>
<input id="k-principal" class="form-control form-control-sm mono" @bind="_newPrincipal"
placeholder="scadabridge-dcl" />
</div>
<div class="col-md-3">
<label class="form-label" for="k-expires">Expires (optional, UTC)</label>
<input id="k-expires" type="date" class="form-control form-control-sm" @bind="_newExpires" />
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-sm btn-primary" @onclick="CreateAsync" disabled="@_busy">Create key</button>
</div>
</div>
</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)
{
<div style="padding:1rem" class="text-muted">Loading…</div>
}
else if (_keys.Count == 0)
{
<div style="padding:1rem" class="text-muted">
No keys yet. Until one exists, only anonymous sessions can connect.
</div>
}
else
{
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Name</th>
<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>
</tr>
</thead>
<tbody>
@foreach (var k in _keys)
{
<tr>
<td>@k.Name</td>
<td><span class="mono">@k.Principal</span></td>
<td><span class="mono">@k.ApiKeyId</span></td>
<td>
@if (!k.Enabled)
{
<span class="badge bg-secondary">revoked</span>
}
else if (k.ExpiresUtc is { } e && e <= DateTime.UtcNow)
{
<span class="badge bg-warning text-dark">expired</span>
}
else
{
<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">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-outline-secondary"
@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>
</td>
</tr>
}
</tbody>
</table>
}
</section>
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
</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;
private string? _created;
private string? _error;
private bool _busy;
// Delete is irreversible (the key stops authenticating at once), so it goes through a confirm dialog
// that names the key rather than firing from the row button.
private bool _confirmDeleteVisible;
private string? _confirmDeleteId;
private string _confirmDeleteMessage = "";
protected override async Task OnInitializedAsync() => await ReloadAsync();
private async Task ReloadAsync()
{
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
&& !_keys.Any(k => _created.StartsWith($"otua_{k.ApiKeyId}_", StringComparison.Ordinal)))
{
_created = null;
}
}
catch (Exception ex)
{
_error = ex.Message;
_keys = [];
}
}
private async Task CreateAsync()
{
_busy = true;
_error = null;
_created = null;
try
{
// Shown once, held only in this component's state. There is no way to recover it afterwards
// — the server stores a hash — so it is deliberately not written anywhere else.
_created = await Keys.CreateAsync(
_newName,
_newPrincipal,
// A date-only input means midnight; treat it as UTC rather than local so an operator
// near a timezone boundary does not get a key that expired before it was issued.
_newExpires is { } d ? DateTime.SpecifyKind(d, DateTimeKind.Utc) : null,
notes: null,
default);
_newName = "";
_newPrincipal = "";
_newExpires = null;
await ReloadAsync();
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_busy = false;
}
}
private async Task SetEnabledAsync(string apiKeyId, bool enabled)
{
_busy = true;
_error = null;
try
{
await Keys.SetEnabledAsync(apiKeyId, enabled, default);
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private void RequestDelete(ApiKey key)
{
_confirmDeleteId = key.ApiKeyId;
_confirmDeleteMessage =
$"Permanently delete API key '{key.Name}' ({key.ApiKeyId})? Clients using it will stop authenticating immediately. This cannot be undone.";
_confirmDeleteVisible = true;
}
private async Task ConfirmDeleteAsync()
{
if (_confirmDeleteId is { } id) { await DeleteAsync(id); }
}
private async Task DeleteAsync(string apiKeyId)
{
_busy = true;
_error = null;
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";
}
}