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