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
@@ -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;
}