feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6)
This commit is contained in:
@@ -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>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user