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
@@ -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();
/// <summary>
/// Resolves the operator's username from the authenticated dashboard principal.
/// </summary>
/// <remarks>
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
/// </remarks>
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";
}
/// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
@@ -215,7 +247,13 @@ public sealed class DashboardApiKeyManagementService(
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary>
/// <remarks>
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
/// </remarks>
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,
@@ -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<Hubs.DashboardSnapshotPublisher>();
services.AddHostedService<Hubs.AlarmsHubPublisher>();
services.AddHttpContextAccessor();
services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
@@ -0,0 +1,51 @@
using System.Security.Claims;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// HTTP-context-backed implementation of <see cref="IAuditActorAccessor"/> that reads the
/// dashboard operator's identity from the current <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// Claim resolution order:
/// <list type="number">
/// <item><see cref="ZbClaimTypes.Username"/> ("zb:username") — the canonical LDAP login name.</item>
/// <item><see cref="ClaimsPrincipal.Identity"/>.<see cref="System.Security.Principal.IIdentity.Name"/> — framework fallback (= <see cref="ZbClaimTypes.Name"/> = <see cref="ClaimTypes.Name"/> = display name).</item>
/// <item><see cref="ZbClaimTypes.Name"/> — explicit fallback matching the claim emitted by <c>DashboardAuthenticator.CreatePrincipal</c>.</item>
/// </list>
/// Returns <see langword="null"/> when there is no HTTP context or the user is not authenticated.
/// </remarks>
public sealed class HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor) : IAuditActorAccessor
{
/// <inheritdoc />
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);
}
}
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// Returns the current actor name for use in audit events.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IAuditActorAccessor
{
/// <summary>
/// Gets the current actor's username, or <see langword="null"/> when there is no
/// authenticated principal in scope (e.g. an anonymous or unauthenticated request).
/// </summary>
string? CurrentActor { get; }
}
@@ -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);
}
}