feat(acl): API key minting — AdminUI page + admin service (#520)
Pulled forward from stage 7 because without it the feature is unusable and un-live-gateable: there was no way to create a key short of hand-inserting a row and computing an HMAC by hand. The minting logic is an IApiKeyAdminService in Core.Authorization, NOT logic in the Razor page — this repo has no bUnit, so anything only a page can reach is untestable, and minting credentials is not where that belongs. The page is a thin shell over it. Moved to Core.Authorization so both ends can reach them: ApiKeyOptions (was in Host) and OpcUaPrincipals (was in OpcUaServer.Security). The OPC UA server resolves principals; the AdminUI must refuse to mint a key claiming a reserved one. OpcUaServer already gained a Core reference in stage 1b, so no new edge — AdminUI gains one. ApiKeyOptions is now bound UNCONDITIONALLY rather than in the driver branch: a driver node needs the pepper to VERIFY keys and an admin node needs it to MINT them, and getting that wrong on either side is a silent authentication failure rather than a startup error. Validation rules, all covered: the reserved 'anonymous' principal is refused case-insensitively (so "ANONYMOUS" cannot slip past into a distinct principal that the evaluator would then match case-INsensitively against anonymous's grants); blank name/principal; a principal longer than the column, rejected rather than truncated, because silent truncation yields a key that authenticates and is granted nothing with no visible cause; a past expiry. Generation runs BEFORE the DbContext is opened so an unconfigured pepper surfaces as a clear error instead of a stored row whose hash nothing can verify. The page shows the plaintext ONCE and says so; it is held in component state and written nowhere else, because the server keeps only a hash. Revocation is flagged in the UI as taking effect only after the next deployment — driver-only nodes have no ConfigDb, so keys ride the artifact. FleetAdmin-only (Administrator), not ConfigEditor: this mints credentials. Core.Tests 68 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
<NavRailItem Href="/apikeys" Text="API keys" />
|
||||
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Scripting" Key="scripting">
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
@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>Revoking a key takes effect on a node only after the next deployment</strong> — driver-only
|
||||
nodes have no config database, so keys travel inside the deployment artifact.
|
||||
</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; }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<!-- #520: API-key minting + the reserved-principal rule live in Core.Authorization, because
|
||||
both ends need them — the OPC UA server resolves principals, the AdminUI authors keys. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
|
||||
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptions
|
||||
{
|
||||
/// <summary>The configuration section name.</summary>
|
||||
public const string SectionName = "Security:ApiKeys";
|
||||
|
||||
/// <summary>
|
||||
/// The static username every key-bearing client presents. The username carries no information —
|
||||
/// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed
|
||||
/// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent
|
||||
/// "any username works".
|
||||
/// </summary>
|
||||
public string Username { get; set; } = "apikey";
|
||||
|
||||
/// <summary>
|
||||
/// Server-side pepper for the stored key hashes. Supply via <c>Security__ApiKeys__Pepper</c> or a
|
||||
/// <c>${secret:}</c> reference — <b>never commit it</b>. Rotating it invalidates every existing key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a
|
||||
/// working-looking deployment with none of the protection it believes it has, so
|
||||
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
||||
/// </remarks>
|
||||
public string Pepper { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
@@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
@@ -132,6 +134,11 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
|
||||
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
|
||||
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
|
||||
// #520 — bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it
|
||||
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
|
||||
// startup error.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
|
||||
if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
||||
else
|
||||
@@ -344,7 +351,6 @@ if (hasDriver)
|
||||
// (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
|
||||
// registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
|
||||
// Only IOpcUaUserAuthenticator moves.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
if (hasAdmin)
|
||||
{
|
||||
// ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin.
|
||||
@@ -353,6 +359,11 @@ if (hasDriver)
|
||||
// Safe direction, and anonymous sessions are unaffected.
|
||||
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||
|
||||
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
||||
builder.Services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
@@ -47,19 +49,6 @@ public interface IOpcUaUserAuthenticator
|
||||
Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
|
||||
public static class OpcUaPrincipals
|
||||
{
|
||||
/// <summary>
|
||||
/// The principal an anonymous session authenticates as. <c>NodeAcl</c> rows naming it grant
|
||||
/// anonymous clients permissions exactly as they would a key's principal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reserved: an API key must not be created with this principal, or a key would silently inherit
|
||||
/// whatever anonymous was granted (and vice versa). Enforced at authoring time.
|
||||
/// </remarks>
|
||||
public const string Anonymous = "anonymous";
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
public sealed record OpcUaUserAuthResult(
|
||||
|
||||
Reference in New Issue
Block a user