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:
Joseph Doherty
2026-07-31 13:40:34 -04:00
parent dd939d3c97
commit 087a969ee6
10 changed files with 472 additions and 15 deletions
@@ -0,0 +1,141 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
/// <remarks>
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
/// page can reach is untestable, and minting credentials is not where that belongs.
/// </remarks>
public interface IApiKeyAdminService
{
/// <summary>Lists every key, newest first. Never returns a secret — there is none stored.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>All key rows.</returns>
Task<IReadOnlyList<ApiKey>> ListAsync(CancellationToken ct);
/// <summary>
/// Mints a new key. The returned plaintext is the ONLY time it exists — it is not recoverable
/// from what is stored, so a caller that fails to show it has effectively discarded the key.
/// </summary>
/// <param name="name">Human label, e.g. the consuming system.</param>
/// <param name="principal">The principal the key authenticates as.</param>
/// <param name="expiresUtc">Optional expiry.</param>
/// <param name="notes">Optional operator notes.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The presented key, shown once.</returns>
Task<string> CreateAsync(
string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct);
/// <summary>Enables or disables a key.</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="enabled">Whether the key may authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct);
/// <summary>Permanently deletes a key row.</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task DeleteAsync(string apiKeyId, CancellationToken ct);
}
/// <summary>EF-backed <see cref="IApiKeyAdminService"/>.</summary>
public sealed class ApiKeyAdminService : IApiKeyAdminService
{
/// <summary>Longest accepted principal name, matching the column width.</summary>
public const int MaxPrincipalLength = 256;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
private readonly string _pepper;
/// <summary>Initializes a new instance of the <see cref="ApiKeyAdminService"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
/// <param name="pepper">The server-side pepper used to hash key secrets.</param>
public ApiKeyAdminService(IDbContextFactory<OtOpcUaConfigDbContext> factory, string pepper)
{
_factory = factory;
_pepper = pepper;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> ListAsync(CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
return await db.ApiKeys.AsNoTracking().OrderByDescending(k => k.CreatedUtc).ToListAsync(ct)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<string> CreateAsync(
string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct)
{
name = (name ?? string.Empty).Trim();
principal = (principal ?? string.Empty).Trim();
if (name.Length == 0) throw new ArgumentException("A key name is required.", nameof(name));
if (principal.Length == 0)
throw new ArgumentException("A principal is required.", nameof(principal));
if (principal.Length > MaxPrincipalLength)
throw new ArgumentException(
$"Principal must be {MaxPrincipalLength} characters or fewer.", nameof(principal));
// Reserved: a key authenticating as 'anonymous' would silently inherit whatever anonymous was
// granted, and vice versa — two identities meant to be distinct sharing one grant set. The
// authenticator refuses it too; this is the authoring half so it can never be stored at all.
if (string.Equals(principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException(
$"'{OpcUaPrincipals.Anonymous}' is reserved for anonymous sessions and cannot be a key's principal.",
nameof(principal));
if (expiresUtc is { } exp && exp <= DateTime.UtcNow)
throw new ArgumentException("Expiry must be in the future.", nameof(expiresUtc));
// Generate BEFORE opening the context: this throws on an unconfigured pepper, and failing there
// is much clearer than a row that persists against a hash nothing can ever verify.
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(_pepper);
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
db.ApiKeys.Add(new ApiKey
{
ApiKeyId = apiKeyId,
Name = name,
SecretHash = hash,
Principal = principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
ExpiresUtc = expiresUtc,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(),
});
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return presented;
}
/// <inheritdoc />
public async Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct)
.ConfigureAwait(false);
if (key is null) return;
key.Enabled = enabled;
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task DeleteAsync(string apiKeyId, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct)
.ConfigureAwait(false);
if (key is null) return;
db.ApiKeys.Remove(key);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
}
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <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;
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
/// <remarks>
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
/// that claims it.
/// </remarks>
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 use this principal, or a credentialled client would silently
/// inherit whatever anonymous was granted (and vice versa). Refused at authoring AND at
/// authentication — belt and braces, because the two paths can be reached independently.
/// </remarks>
public const string Anonymous = "anonymous";
}