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,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Validation rules for minting API keys (#520). These run BEFORE any database work, so they are
/// covered without a ConfigDb: a null context factory proves the guard rejected the input rather
/// than reaching persistence (it would NullReferenceException if it got that far).
/// </summary>
public sealed class ApiKeyAdminServiceTests
{
private static ApiKeyAdminService Sut(string pepper = "unit-test-pepper") => new(null!, pepper);
[Fact]
public async Task The_reserved_anonymous_principal_cannot_be_minted_as_a_key()
{
// Otherwise a credentialled client silently inherits whatever anonymous was granted. Rejected
// here AND at authentication, because the two paths are reachable independently.
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("sneaky", OpcUaPrincipals.Anonymous, null, null, default));
ex.Message.ShouldContain("reserved");
}
[Fact]
public async Task The_reserved_principal_check_is_case_insensitive()
{
// "Anonymous" must not slip past a case-sensitive comparison and become a distinct principal
// that the evaluator would then match case-INsensitively against anonymous's grants.
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("sneaky", "ANONYMOUS", null, null, default));
}
[Theory]
[InlineData("", "principal")]
[InlineData(" ", "principal")]
public async Task A_blank_principal_is_rejected(string principal, string expectedParam)
{
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", principal, null, null, default));
ex.ParamName.ShouldBe(expectedParam);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task A_blank_name_is_rejected(string name)
{
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync(name, "some-principal", null, null, default));
ex.ParamName.ShouldBe("name");
}
[Fact]
public async Task A_principal_longer_than_the_column_is_rejected_rather_than_truncated()
{
// Silent truncation would create a key whose principal does not match the ACL row the operator
// authored, i.e. a key that authenticates and is granted nothing, with no visible cause.
var tooLong = new string('p', ApiKeyAdminService.MaxPrincipalLength + 1);
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", tooLong, null, null, default));
}
[Fact]
public async Task An_expiry_in_the_past_is_rejected()
{
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", "p", DateTime.UtcNow.AddMinutes(-1), null, default));
}
[Fact]
public async Task An_unconfigured_pepper_fails_before_anything_is_persisted()
{
// Generate() runs before the DbContext is opened precisely so this surfaces as a clear pepper
// error rather than a stored row whose hash nothing could ever verify.
await Should.ThrowAsync<ArgumentException>(() =>
Sut(pepper: "").CreateAsync("name", "p", null, null, default));
}
}