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,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(