diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
index 82569aa..86a241d 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
@@ -66,7 +66,7 @@ public sealed class DashboardApiKeyManagementService(
cancellationToken)
.ConfigureAwait(false);
- await WriteDashboardAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
+ await WriteDashboardAuditAsync(user, keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
return DashboardApiKeyManagementResult.Success(
"API key created. Copy the key now; it will not be shown again.",
@@ -108,6 +108,7 @@ public sealed class DashboardApiKeyManagementService(
.ConfigureAwait(false);
await WriteDashboardAuditAsync(
+ user,
normalizedKeyId,
"dashboard-revoke-key",
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
@@ -150,6 +151,7 @@ public sealed class DashboardApiKeyManagementService(
bool succeeded = rotated.Token is not null;
await WriteDashboardAuditAsync(
+ user,
normalizedKeyId,
"dashboard-rotate-key",
succeeded ? "rotated" : "not-found",
@@ -194,6 +196,7 @@ public sealed class DashboardApiKeyManagementService(
.ConfigureAwait(false);
await WriteDashboardAuditAsync(
+ user,
normalizedKeyId,
"dashboard-delete-key",
deleted ? "deleted" : "not-found-or-active",
@@ -208,6 +211,35 @@ public sealed class DashboardApiKeyManagementService(
private string? RemoteAddress() =>
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
+ ///
+ /// Resolves the operator's username from the authenticated dashboard principal.
+ ///
+ ///
+ /// The passed is preferred over the ambient HTTP context because it
+ /// is already in scope at every call site (the callers gate on using
+ /// it) and is unambiguous. Falls back to for
+ /// defensive coverage, then to "unknown" when neither is available.
+ ///
+ private static string ResolveOperatorActor(ClaimsPrincipal user)
+ {
+ // ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name.
+ string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username);
+ if (!string.IsNullOrWhiteSpace(username))
+ {
+ return username;
+ }
+
+ // Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity
+ // (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name).
+ string? identityName = user.Identity?.Name;
+ if (!string.IsNullOrWhiteSpace(identityName))
+ {
+ return identityName;
+ }
+
+ return "unknown";
+ }
+
///
/// Emits the dashboard's own canonical for a dashboard-* op
/// directly through the best-effort (Task 2.3 #6). This is in
@@ -215,7 +247,13 @@ public sealed class DashboardApiKeyManagementService(
/// emits via the canonical-forwarding IApiKeyAuditStore adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical audit_event store.
///
+ ///
+ /// Phase 3 (Actor = operator principal): Actor is the LDAP operator who performed the
+ /// action (resolved from the principal); Target is the managed
+ /// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
+ ///
private async Task WriteDashboardAuditAsync(
+ ClaimsPrincipal user,
string keyId,
string action,
string? detail,
@@ -225,7 +263,7 @@ public sealed class DashboardApiKeyManagementService(
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
- Actor = keyId,
+ Actor = ResolveOperatorActor(user),
Action = action,
Outcome = AuditOutcome.Success,
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
index da8619a..806fe41 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Security.Audit;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -47,6 +48,7 @@ public static class DashboardServiceCollectionExtensions
services.AddHostedService();
services.AddHostedService();
services.AddHttpContextAccessor();
+ services.AddSingleton();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/HttpAuditActorAccessor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/HttpAuditActorAccessor.cs
new file mode 100644
index 0000000..74971a1
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/HttpAuditActorAccessor.cs
@@ -0,0 +1,51 @@
+using System.Security.Claims;
+using ZB.MOM.WW.Auth.AspNetCore;
+
+namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
+
+///
+/// HTTP-context-backed implementation of that reads the
+/// dashboard operator's identity from the current .
+///
+///
+/// Claim resolution order:
+///
+/// - ("zb:username") — the canonical LDAP login name.
+/// - . — framework fallback (= = = display name).
+/// - — explicit fallback matching the claim emitted by DashboardAuthenticator.CreatePrincipal.
+///
+/// Returns when there is no HTTP context or the user is not authenticated.
+///
+public sealed class HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor) : IAuditActorAccessor
+{
+ ///
+ public string? CurrentActor
+ {
+ get
+ {
+ ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
+ if (user?.Identity?.IsAuthenticated != true)
+ {
+ return null;
+ }
+
+ // Prefer the canonical login-username claim (set by DashboardAuthenticator).
+ string? username = user.FindFirstValue(ZbClaimTypes.Username);
+ if (!string.IsNullOrWhiteSpace(username))
+ {
+ return username;
+ }
+
+ // Framework fallback: Identity.Name is driven by the ClaimsIdentity nameClaimType,
+ // which DashboardAuthenticator sets to ZbClaimTypes.Name (= ClaimTypes.Name = display name).
+ string? identityName = user.Identity?.Name;
+ if (!string.IsNullOrWhiteSpace(identityName))
+ {
+ return identityName;
+ }
+
+ // Final explicit fallback — ZbClaimTypes.Name claim value directly.
+ return user.FindFirstValue(ZbClaimTypes.Name);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditActorAccessor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditActorAccessor.cs
new file mode 100644
index 0000000..1617dad
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditActorAccessor.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
+
+///
+/// Returns the current actor name for use in audit events.
+///
+///
+/// Implementations resolve the actor from the ambient request context. For the dashboard
+/// this is the authenticated LDAP operator; for non-HTTP contexts (gRPC, CLI) the caller
+/// provides the actor directly and this seam is not used.
+///
+public interface IAuditActorAccessor
+{
+ ///
+ /// Gets the current actor's username, or when there is no
+ /// authenticated principal in scope (e.g. an anonymous or unauthenticated request).
+ ///
+ string? CurrentActor { get; }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
index 58e3348..c27241d 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
@@ -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 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");
}
/// Verifies that creating a key whose id already exists is rejected.
@@ -107,9 +112,11 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
ApiKeyListItem key = Assert.Single(await ListAsync(services));
Assert.NotNull(key.RevokedUtc);
IReadOnlyList 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 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 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 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));
}
+ ///
+ /// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
+ /// the operator username as Actor and the managed keyId as Target.
+ ///
+ [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(),
+ services.GetRequiredService(),
+ 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();
}
+
+ /// In-memory that records every event.
+ private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
+ {
+ /// Gets the recorded canonical audit events.
+ public List Events { get; } = [];
+
+ ///
+ public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
+ {
+ Events.Add(auditEvent);
+ return Task.CompletedTask;
+ }
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/HttpAuditActorAccessorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/HttpAuditActorAccessorTests.cs
new file mode 100644
index 0000000..1db9332
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/HttpAuditActorAccessorTests.cs
@@ -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;
+
+///
+/// Tests — the HTTP-backed
+/// that reads the dashboard operator's username from the current HTTP context.
+///
+public sealed class HttpAuditActorAccessorTests
+{
+ ///
+ /// When the HTTP context carries an authenticated principal with ,
+ /// returns that username.
+ ///
+ [Fact]
+ public void CurrentActor_AuthenticatedUserWithUsernamelaim_ReturnsUsername()
+ {
+ HttpAuditActorAccessor accessor = CreateAccessor(
+ CreateAuthenticatedUser(username: "alice", displayName: "Alice Admin"));
+
+ Assert.Equal("alice", accessor.CurrentActor);
+ }
+
+ ///
+ /// When the principal has no but has a
+ /// claim (display name), the accessor falls back to
+ /// .
+ ///
+ [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);
+ }
+
+ ///
+ /// An unauthenticated (anonymous) principal yields .
+ ///
+ [Fact]
+ public void CurrentActor_UnauthenticatedUser_ReturnsNull()
+ {
+ HttpAuditActorAccessor accessor = CreateAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
+
+ Assert.Null(accessor.CurrentActor);
+ }
+
+ ///
+ /// When there is no HTTP context at all (e.g. a background thread), the accessor returns
+ /// rather than throwing.
+ ///
+ [Fact]
+ public void CurrentActor_NoHttpContext_ReturnsNull()
+ {
+ HttpContextAccessor contextAccessor = new() { HttpContext = null };
+ HttpAuditActorAccessor accessor = new(contextAccessor);
+
+ Assert.Null(accessor.CurrentActor);
+ }
+
+ ///
+ /// When the claim is present it is always preferred
+ /// over the (display-name) value.
+ ///
+ [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);
+ }
+}