feat(audit): MxGateway IAuditActorAccessor + dashboard audit Actor = operator principal (keyId→Target) (Phase 3)

Introduce IAuditActorAccessor seam + HttpAuditActorAccessor impl (reads ZbClaimTypes.Username
from IHttpContextAccessor; falls back to Identity.Name / ZbClaimTypes.Name; null when
unauthenticated). Register in DI via DashboardServiceCollectionExtensions.

Wire DashboardApiKeyManagementService: WriteDashboardAuditAsync now accepts the ClaimsPrincipal
user already in scope at each call site; ResolveOperatorActor extracts ZbClaimTypes.Username
(preferred) or Identity.Name. All four dashboard-* events now emit Actor = LDAP operator
username and Target = managed keyId, fixing the semantic gap where both fields held the keyId.

ConstraintEnforcer (gRPC / API-key actor) and CanonicalForwardingApiKeyAuditStore (CLI /
"system"/"cli" fallback) are unchanged.

Tests: DashboardApiKeyManagementServiceTests updated — CreateAuthorizedUser adds ZbClaimTypes.Username
("alice"), all dashboard-* audit assertions updated to Actor = "alice" / Target = "operator01";
new CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget verifies the
canonical AuditEvent directly. New HttpAuditActorAccessorTests (4 cases: username claim, Identity.Name
fallback, unauthenticated → null, no context → null). ConstraintEnforcer tests still assert API-key/anonymous actor.
This commit is contained in:
Joseph Doherty
2026-06-02 15:25:39 -04:00
parent 7ea8358c06
commit 0859d47f75
6 changed files with 291 additions and 8 deletions
@@ -0,0 +1,107 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
/// <summary>
/// Tests <see cref="HttpAuditActorAccessor"/> — the HTTP-backed <see cref="IAuditActorAccessor"/>
/// that reads the dashboard operator's username from the current HTTP context.
/// </summary>
public sealed class HttpAuditActorAccessorTests
{
/// <summary>
/// When the HTTP context carries an authenticated principal with <see cref="ZbClaimTypes.Username"/>,
/// <see cref="IAuditActorAccessor.CurrentActor"/> returns that username.
/// </summary>
[Fact]
public void CurrentActor_AuthenticatedUserWithUsernamelaim_ReturnsUsername()
{
HttpAuditActorAccessor accessor = CreateAccessor(
CreateAuthenticatedUser(username: "alice", displayName: "Alice Admin"));
Assert.Equal("alice", accessor.CurrentActor);
}
/// <summary>
/// When the principal has no <see cref="ZbClaimTypes.Username"/> but has a
/// <see cref="ClaimTypes.Name"/> claim (display name), the accessor falls back to
/// <see cref="System.Security.Principal.IIdentity.Name"/>.
/// </summary>
[Fact]
public void CurrentActor_AuthenticatedUserWithoutUsernameClaim_FallsBackToIdentityName()
{
// Build a principal with display-name but no zb:username.
ClaimsIdentity identity = new(
[new Claim(ClaimTypes.Name, "Alice Admin")],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
HttpAuditActorAccessor accessor = CreateAccessor(new ClaimsPrincipal(identity));
Assert.Equal("Alice Admin", accessor.CurrentActor);
}
/// <summary>
/// An unauthenticated (anonymous) principal yields <see langword="null"/>.
/// </summary>
[Fact]
public void CurrentActor_UnauthenticatedUser_ReturnsNull()
{
HttpAuditActorAccessor accessor = CreateAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.Null(accessor.CurrentActor);
}
/// <summary>
/// When there is no HTTP context at all (e.g. a background thread), the accessor returns
/// <see langword="null"/> rather than throwing.
/// </summary>
[Fact]
public void CurrentActor_NoHttpContext_ReturnsNull()
{
HttpContextAccessor contextAccessor = new() { HttpContext = null };
HttpAuditActorAccessor accessor = new(contextAccessor);
Assert.Null(accessor.CurrentActor);
}
/// <summary>
/// When the <see cref="ZbClaimTypes.Username"/> claim is present it is always preferred
/// over the <see cref="ClaimTypes.Name"/> (display-name) value.
/// </summary>
[Fact]
public void CurrentActor_UsernameClaimPreferredOverDisplayName()
{
// login username = "jsmith"; display name = "John Smith"
HttpAuditActorAccessor accessor = CreateAccessor(
CreateAuthenticatedUser(username: "jsmith", displayName: "John Smith"));
Assert.Equal("jsmith", accessor.CurrentActor);
}
// ── helpers ─────────────────────────────────────────────────────────────
private static HttpAuditActorAccessor CreateAccessor(ClaimsPrincipal user)
{
DefaultHttpContext httpContext = new() { User = user };
return new HttpAuditActorAccessor(new HttpContextAccessor { HttpContext = httpContext });
}
private static ClaimsPrincipal CreateAuthenticatedUser(string username, string displayName)
{
ClaimsIdentity identity = new(
[
new Claim(ZbClaimTypes.Username, username),
new Claim(ZbClaimTypes.Name, displayName),
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
],
DashboardAuthenticationDefaults.AuthenticationScheme,
ZbClaimTypes.Name,
ZbClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
}