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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user