c1771dc271
The /apikeys page claimed revocation "takes effect on a node only after the next deployment". The live gate showed that is wrong for the node the operator is most likely looking at: revoking through the UI denied the key on the VERY NEXT request against the fused admin+driver node, because ConfigDbApiKeyStore reads per request. The deployment delay applies only to driver-only nodes, whose keys ride the artifact. Reworded to say both, and to tell an operator to assume the slower behaviour when revoking a compromised key — which is the decision the warning exists to inform. Found by doing the thing rather than reasoning about it: the previous wording looked obviously correct until the revoked key was actually re-presented. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
210 lines
7.5 KiB
Plaintext
210 lines
7.5 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
|
|
@inject IApiKeyAdminService Keys
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">OPC UA API keys</h4>
|
|
</div>
|
|
|
|
<div class="alert alert-info" role="alert">
|
|
The OPC UA data plane is machine-to-machine. A client connects with an OPC UA
|
|
<strong>UserName</strong> token whose username is the literal <span class="mono">apikey</span> and
|
|
whose password is the key below. The key resolves to a <strong>principal</strong>; ACL rows naming
|
|
that principal are what grant it permissions.
|
|
<br />
|
|
<strong>Revocation is not uniform across the fleet.</strong> A node with a config database (a fused
|
|
admin+driver node such as this one) applies it <em>immediately</em>, because it reads keys per
|
|
request. A <strong>driver-only</strong> node has no config database, so its keys travel inside the
|
|
deployment artifact and a revocation reaches it only on the <strong>next deployment</strong>.
|
|
Assume the slower behaviour when revoking a compromised key.
|
|
</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>
|
|
|
|
<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>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>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
|
|
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
|
|
<td class="text-end">
|
|
<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-danger"
|
|
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</section>
|
|
|
|
@code {
|
|
private IReadOnlyList<ApiKey>? _keys;
|
|
private string _newName = "";
|
|
private string _newPrincipal = "";
|
|
private DateTime? _newExpires;
|
|
private string? _created;
|
|
private string? _error;
|
|
private bool _busy;
|
|
|
|
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
|
|
|
private async Task ReloadAsync()
|
|
{
|
|
try
|
|
{
|
|
_keys = await Keys.ListAsync(default);
|
|
}
|
|
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 async Task DeleteAsync(string apiKeyId)
|
|
{
|
|
_busy = true;
|
|
_error = null;
|
|
try
|
|
{
|
|
await Keys.DeleteAsync(apiKeyId, default);
|
|
await ReloadAsync();
|
|
}
|
|
catch (Exception ex) { _error = ex.Message; }
|
|
finally { _busy = false; }
|
|
}
|
|
}
|