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:
+73
-6
@@ -4,8 +4,10 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||
@@ -70,7 +72,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||
Assert.Contains(audit, entry => entry.EventType == "dashboard-create-key" && entry.KeyId == "operator01");
|
||||
// Phase 3: Actor = operator username ("alice"), Target = managed keyId ("operator01").
|
||||
Assert.Contains(audit, entry =>
|
||||
entry.EventType == "dashboard-create-key"
|
||||
&& entry.KeyId == "alice");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
|
||||
@@ -107,9 +112,11 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
ApiKeyListItem key = Assert.Single(await ListAsync(services));
|
||||
Assert.NotNull(key.RevokedUtc);
|
||||
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||
// Phase 3: Actor = operator username; the dashboard-revoke-key event surfaces KeyId = "alice"
|
||||
// (the operator) and Details = "revoked".
|
||||
Assert.Contains(audit, entry =>
|
||||
entry.EventType == "dashboard-revoke-key"
|
||||
&& entry.KeyId == "operator01"
|
||||
&& entry.KeyId == "alice"
|
||||
&& entry.Details == "revoked");
|
||||
}
|
||||
|
||||
@@ -138,9 +145,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||
// Phase 3: Actor = operator username ("alice").
|
||||
Assert.Contains(audit, entry =>
|
||||
entry.EventType == "dashboard-rotate-key"
|
||||
&& entry.KeyId == "operator01"
|
||||
&& entry.KeyId == "alice"
|
||||
&& entry.Details == "rotated");
|
||||
}
|
||||
|
||||
@@ -161,9 +169,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Empty(await ListAsync(services));
|
||||
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||
// Phase 3: Actor = operator username ("alice").
|
||||
Assert.Contains(audit, entry =>
|
||||
entry.EventType == "dashboard-delete-key"
|
||||
&& entry.KeyId == "operator01"
|
||||
&& entry.KeyId == "alice"
|
||||
&& entry.Details == "deleted");
|
||||
}
|
||||
|
||||
@@ -190,7 +199,8 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||
ApiKeyAuditEntry deleteEntry = Assert.Single(
|
||||
audit, entry => entry.EventType == "dashboard-delete-key");
|
||||
Assert.Equal("operator01", deleteEntry.KeyId);
|
||||
// Phase 3: Actor = operator username ("alice"), not the managed keyId.
|
||||
Assert.Equal("alice", deleteEntry.KeyId);
|
||||
Assert.Equal("not-found-or-active", deleteEntry.Details);
|
||||
}
|
||||
|
||||
@@ -241,6 +251,44 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
Assert.Empty(await ListAsync(services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
|
||||
/// the operator username as Actor and the managed keyId as Target.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
|
||||
{
|
||||
await using ServiceProvider services = BuildServices();
|
||||
|
||||
// Wire a recording writer so we can inspect the canonical AuditEvent directly (bypassing
|
||||
// the CanonicalForwardingApiKeyAuditStore round-trip that ListAuditAsync uses).
|
||||
RecordingAuditWriter recordingWriter = new();
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||
|
||||
DashboardApiKeyManagementService service = new(
|
||||
new DashboardApiKeyAuthorization(),
|
||||
services.GetRequiredService<ApiKeyAdminCommands>(),
|
||||
services.GetRequiredService<IApiKeyAdminStore>(),
|
||||
recordingWriter,
|
||||
new HttpContextAccessor { HttpContext = httpContext });
|
||||
|
||||
await service.CreateAsync(
|
||||
CreateAuthorizedUser(),
|
||||
CreateRequest(),
|
||||
CancellationToken.None);
|
||||
|
||||
// The dashboard-create-key event emitted directly by the service (not the library's
|
||||
// create-key event forwarded via the adapter) must have Actor = operator username and
|
||||
// Target = managed keyId.
|
||||
ZB.MOM.WW.Audit.AuditEvent dashboardEvent = Assert.Single(
|
||||
recordingWriter.Events,
|
||||
e => e.Action == "dashboard-create-key");
|
||||
Assert.Equal("alice", dashboardEvent.Actor);
|
||||
Assert.Equal("operator01", dashboardEvent.Target);
|
||||
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, dashboardEvent.Outcome);
|
||||
}
|
||||
|
||||
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
|
||||
{
|
||||
DefaultHttpContext httpContext = new();
|
||||
@@ -307,8 +355,13 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
|
||||
private static ClaimsPrincipal CreateAuthorizedUser()
|
||||
{
|
||||
// Phase 3: include ZbClaimTypes.Username so ResolveOperatorActor picks up the LDAP
|
||||
// login name ("alice") as the audit Actor. The keyId ("operator01") is the Target.
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
[
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||
new Claim(ZbClaimTypes.Username, "alice"),
|
||||
],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
@@ -326,4 +379,18 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||
|
||||
_tempDirectories.Clear();
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> that records every event.</summary>
|
||||
private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
|
||||
{
|
||||
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(auditEvent);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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