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,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.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Sessions;
@@ -12,7 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache,
IApiKeyAuditStore auditStore) : IConstraintEnforcer
IAuditWriter auditWriter) : IConstraintEnforcer
{
/// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
@@ -126,15 +128,33 @@ public sealed class ConstraintEnforcer(
ConstraintFailure failure,
CancellationToken cancellationToken)
{
await auditStore.AppendAsync(
new ApiKeyAuditEntry(
KeyId: identity?.KeyId,
EventType: "constraint-denied",
RemoteAddress: null,
CreatedUtc: DateTimeOffset.UtcNow,
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"),
cancellationToken)
.ConfigureAwait(false);
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
// envelope carrying constraint/message/commandKind/target.
// TODO(Task 2.3): CorrelationId is left null here. Threading the per-request
// ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer
// signature change across the gRPC call path; that is deferred to a follow-up.
AuditEvent auditEvent = new()
{
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(