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,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;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -38,7 +38,7 @@ public sealed class ConstraintEnforcerTests
[Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore);
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
WriteSubtrees = ["Area1/*"],
@@ -71,10 +71,35 @@ public sealed class ConstraintEnforcerTests
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries);
Assert.Equal("operator01", entry.KeyId);
Assert.Equal("constraint-denied", entry.EventType);
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal("operator01", auditEvent.Actor);
Assert.Equal("constraint-denied", auditEvent.Action);
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>
@@ -134,10 +159,10 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_historized_only", failure.ConstraintName);
}
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
{
auditStore = new FakeAuditStore();
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore);
auditWriter = new FakeAuditWriter();
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditWriter);
}
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
@@ -242,22 +267,16 @@ public sealed class ConstraintEnforcerTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeAuditStore : IApiKeyAuditStore
private sealed class FakeAuditWriter : IAuditWriter
{
/// <summary>Gets the recorded audit entries.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <summary>Gets the recorded canonical audit events.</summary>
public List<AuditEvent> Events { get; } = [];
/// <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;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditEntry>>([]);
}
}
}