feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6)

This commit is contained in:
Joseph Doherty
2026-06-02 10:13:54 -04:00
parent a5944bbe5d
commit 7ea8358c06
4 changed files with 124 additions and 52 deletions
@@ -1,7 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin; using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
@@ -11,7 +14,7 @@ public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization, DashboardApiKeyAuthorization authorization,
ApiKeyAdminCommands adminCommands, ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore, IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore, IAuditWriter auditWriter,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{ {
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
@@ -50,8 +53,10 @@ public sealed class DashboardApiKeyManagementService(
{ {
// The shared command set generates the secret, hashes it with the pepper, persists the // The shared command set generates the secret, hashes it with the pepper, persists the
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own // record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
// "create-key" audit entry; the dashboard layers a "dashboard-create-key" entry with the // "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
// caller's remote address on top to preserve the dashboard audit vocabulary. // adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
// (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
// dashboard audit vocabulary — both rows land in the canonical audit_event store.
CreateKeyResult created = await adminCommands.CreateKeyAsync( CreateKeyResult created = await adminCommands.CreateKeyAsync(
keyId, keyId,
request.DisplayName.Trim(), request.DisplayName.Trim(),
@@ -61,7 +66,7 @@ public sealed class DashboardApiKeyManagementService(
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); await WriteDashboardAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
return DashboardApiKeyManagementResult.Success( return DashboardApiKeyManagementResult.Success(
"API key created. Copy the key now; it will not be shown again.", "API key created. Copy the key now; it will not be shown again.",
@@ -102,7 +107,7 @@ public sealed class DashboardApiKeyManagementService(
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync( await WriteDashboardAuditAsync(
normalizedKeyId, normalizedKeyId,
"dashboard-revoke-key", "dashboard-revoke-key",
result.Succeeded ? "revoked" : "not-found-or-already-revoked", result.Succeeded ? "revoked" : "not-found-or-already-revoked",
@@ -144,7 +149,7 @@ public sealed class DashboardApiKeyManagementService(
bool succeeded = rotated.Token is not null; bool succeeded = rotated.Token is not null;
await AppendAuditAsync( await WriteDashboardAuditAsync(
normalizedKeyId, normalizedKeyId,
"dashboard-rotate-key", "dashboard-rotate-key",
succeeded ? "rotated" : "not-found", succeeded ? "rotated" : "not-found",
@@ -188,7 +193,7 @@ public sealed class DashboardApiKeyManagementService(
.DeleteAsync(normalizedKeyId, cancellationToken) .DeleteAsync(normalizedKeyId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync( await WriteDashboardAuditAsync(
normalizedKeyId, normalizedKeyId,
"dashboard-delete-key", "dashboard-delete-key",
deleted ? "deleted" : "not-found-or-active", deleted ? "deleted" : "not-found-or-active",
@@ -203,23 +208,51 @@ public sealed class DashboardApiKeyManagementService(
private string? RemoteAddress() => private string? RemoteAddress() =>
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private async Task AppendAuditAsync( /// <summary>
string? keyId, /// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
string eventType, /// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
string? details, /// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
/// 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>
private async Task WriteDashboardAuditAsync(
string keyId,
string action,
string? detail,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await auditStore.AppendAsync( AuditEvent auditEvent = new()
new ApiKeyAuditEntry( {
KeyId: keyId, EventId = Guid.NewGuid(),
EventType: eventType, OccurredAtUtc = DateTimeOffset.UtcNow,
RemoteAddress: RemoteAddress(), Actor = keyId,
CreatedUtc: DateTimeOffset.UtcNow, Action = action,
Details: details), Outcome = AuditOutcome.Success,
cancellationToken) Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
.ConfigureAwait(false); Target = keyId,
SourceNode = RemoteAddress(),
CorrelationId = ParseCorrelationId(),
DetailsJson = WrapDetail(detail),
};
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
/// </summary>
private Guid? ParseCorrelationId() =>
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
? correlationId
: null;
private static string? WrapDetail(string? detail) =>
detail is null
? null
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
private static bool IsPepperUnavailable(InvalidOperationException exception) => private static bool IsPepperUnavailable(InvalidOperationException exception) =>
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase); exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
@@ -1,6 +1,8 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Sessions;
@@ -12,7 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class ConstraintEnforcer( public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache, IGalaxyHierarchyCache cache,
IApiKeyAuditStore auditStore) : IConstraintEnforcer IAuditWriter auditWriter) : IConstraintEnforcer
{ {
/// <summary>Checks read constraints on a tag address.</summary> /// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param> /// <param name="identity">The API key identity to check constraints for.</param>
@@ -126,15 +128,33 @@ public sealed class ConstraintEnforcer(
ConstraintFailure failure, ConstraintFailure failure,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await auditStore.AppendAsync( // Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
new ApiKeyAuditEntry( // (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
KeyId: identity?.KeyId, // envelope carrying constraint/message/commandKind/target.
EventType: "constraint-denied", // TODO(Task 2.3): CorrelationId is left null here. Threading the per-request
RemoteAddress: null, // ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer
CreatedUtc: DateTimeOffset.UtcNow, // signature change across the gRPC call path; that is deferred to a follow-up.
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"), AuditEvent auditEvent = new()
cancellationToken) {
.ConfigureAwait(false); EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = identity?.KeyId ?? "anonymous",
Action = "constraint-denied",
Outcome = AuditOutcome.Denied,
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
Target = $"{commandKind}:{target}",
SourceNode = null,
CorrelationId = null,
DetailsJson = JsonSerializer.Serialize(new Dictionary<string, string>
{
["constraint"] = failure.ConstraintName,
["message"] = failure.Message,
["commandKind"] = commandKind,
["target"] = target,
}),
};
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
} }
private ConstraintFailure? CheckReadTarget( private ConstraintFailure? CheckReadTarget(
@@ -250,7 +250,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
new DashboardApiKeyAuthorization(), new DashboardApiKeyAuthorization(),
services.GetRequiredService<ApiKeyAdminCommands>(), services.GetRequiredService<ApiKeyAdminCommands>(),
services.GetRequiredService<IApiKeyAdminStore>(), services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(), services.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
new HttpContextAccessor { HttpContext = httpContext }); new HttpContextAccessor { HttpContext = httpContext });
} }
@@ -1,4 +1,4 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -38,7 +38,7 @@ public sealed class ConstraintEnforcerTests
[Fact] [Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{ {
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore); ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{ {
WriteSubtrees = ["Area1/*"], WriteSubtrees = ["Area1/*"],
@@ -71,10 +71,35 @@ public sealed class ConstraintEnforcerTests
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries); AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal("operator01", entry.KeyId); Assert.Equal("operator01", auditEvent.Actor);
Assert.Equal("constraint-denied", entry.EventType); Assert.Equal("constraint-denied", auditEvent.Action);
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
Assert.Equal("ApiKey", auditEvent.Category);
// Target is the structured "<commandKind>:<target>" form.
Assert.Equal("Write:42", auditEvent.Target);
Assert.NotNull(auditEvent.DetailsJson);
Assert.Contains("max_write_classification", auditEvent.DetailsJson, StringComparison.Ordinal);
Assert.Null(auditEvent.CorrelationId);
}
/// <summary>A denial with no identity records the canonical "anonymous" actor.</summary>
[Fact]
public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
await enforcer.RecordDenialAsync(
identity: null,
"Read",
"Secret.Tag",
new ConstraintFailure("read_scope", "Tag is outside the API key read scope."),
CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal("anonymous", auditEvent.Actor);
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
Assert.Equal("Read:Secret.Tag", auditEvent.Target);
} }
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary> /// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
@@ -134,10 +159,10 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_historized_only", failure.ConstraintName); Assert.Equal("read_historized_only", failure.ConstraintName);
} }
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
{ {
auditStore = new FakeAuditStore(); auditWriter = new FakeAuditWriter();
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore); return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditWriter);
} }
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints) private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
@@ -242,22 +267,16 @@ public sealed class ConstraintEnforcerTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
private sealed class FakeAuditStore : IApiKeyAuditStore private sealed class FakeAuditWriter : IAuditWriter
{ {
/// <summary>Gets the recorded audit entries.</summary> /// <summary>Gets the recorded canonical audit events.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = []; public List<AuditEvent> Events { get; } = [];
/// <inheritdoc /> /// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{ {
Entries.Add(entry); Events.Add(auditEvent);
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditEntry>>([]);
}
} }
} }