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); + } +}