feat(audit): AuditEvent record + AuditOutcome + writer/redactor seams
Includes equality-as-normalized-instant remarks on OccurredAtUtc and a same-instant/different-offset equality regression test (code-review follow-up).
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, transport-agnostic audit record — who did what, when, with what outcome.
|
||||
/// Required core + optional common fields + a <see cref="DetailsJson"/> extension bag. Each
|
||||
/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types)
|
||||
/// map into <see cref="Action"/>/<see cref="Category"/>/<see cref="DetailsJson"/> and are not
|
||||
/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key uniquely identifying this audit event.</summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>When the audited action occurred. Normalized to UTC on assignment.</summary>
|
||||
/// <remarks>Participates in record value-equality as a normalized instant: two events whose
|
||||
/// <c>OccurredAtUtc</c> denote the same instant at different offsets (e.g. <c>12:00+05:00</c> and
|
||||
/// <c>07:00Z</c>) compare equal and share a hash code. Relevant to consumers that dedup/key on
|
||||
/// <see cref="AuditEvent"/> value-equality.</remarks>
|
||||
public required DateTimeOffset OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = value.ToUniversalTime();
|
||||
}
|
||||
private readonly DateTimeOffset _occurredAtUtc;
|
||||
|
||||
/// <summary>Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption).</summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>What was done — a verb/event-type string.</summary>
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>Normalized outcome.</summary>
|
||||
public required AuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>Optional subsystem/grouping for the action.</summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>Optional target of the action (resource/method/connection).</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>Optional node that emitted the event.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Optional correlation id joining this row to its originating request/workflow.</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Optional JSON extension carrying project-specific fields.</summary>
|
||||
public string? DetailsJson { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Normalized outcome of an audited action.</summary>
|
||||
public enum AuditOutcome
|
||||
{
|
||||
/// <summary>The action completed successfully.</summary>
|
||||
Success,
|
||||
/// <summary>The action failed due to an error.</summary>
|
||||
Failure,
|
||||
/// <summary>The action was rejected by authentication/authorization.</summary>
|
||||
Denied,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filters an <see cref="AuditEvent"/> between construction and persistence — truncates oversized
|
||||
/// fields and scrubs sensitive content. Pure function: returns a filtered COPY and MUST NOT throw
|
||||
/// (over-redact on internal failure). Shaped to mirror Telemetry's <c>ILogRedactor</c> so a future
|
||||
/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it.
|
||||
/// </summary>
|
||||
public interface IAuditRedactor
|
||||
{
|
||||
/// <summary>Apply the configured truncation/redaction policy and return a filtered copy.</summary>
|
||||
AuditEvent Apply(AuditEvent rawEvent);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort sink for <see cref="AuditEvent"/>s. Implementations MUST swallow/log internal
|
||||
/// failures rather than propagating them — a failed audit write must never abort the
|
||||
/// user-facing action that produced it.
|
||||
/// </summary>
|
||||
public interface IAuditWriter
|
||||
{
|
||||
/// <summary>Persist an audit event. Best-effort; must not throw to the caller.</summary>
|
||||
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class AuditEventTests
|
||||
{
|
||||
private static AuditEvent Minimal() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "alice",
|
||||
Action = "ConfigPublished",
|
||||
Outcome = AuditOutcome.Success,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Required_core_fields_round_trip()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var evt = Minimal() with { EventId = id, Actor = "svc", Action = "ApiCall", Outcome = AuditOutcome.Denied };
|
||||
Assert.Equal(id, evt.EventId);
|
||||
Assert.Equal("svc", evt.Actor);
|
||||
Assert.Equal("ApiCall", evt.Action);
|
||||
Assert.Equal(AuditOutcome.Denied, evt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccurredAtUtc_is_normalized_to_utc()
|
||||
{
|
||||
var local = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5));
|
||||
var evt = Minimal() with { OccurredAtUtc = local };
|
||||
Assert.Equal(TimeSpan.Zero, evt.OccurredAtUtc.Offset);
|
||||
Assert.Equal(local.UtcDateTime, evt.OccurredAtUtc.UtcDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optional_fields_default_to_null()
|
||||
{
|
||||
var evt = Minimal();
|
||||
Assert.Null(evt.Category);
|
||||
Assert.Null(evt.Target);
|
||||
Assert.Null(evt.SourceNode);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
Assert.Null(evt.DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Records_with_same_values_are_equal()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var when = DateTimeOffset.UtcNow;
|
||||
AuditEvent Make() => new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
Assert.Equal(Make(), Make());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_instant_at_different_offset_compares_equal()
|
||||
{
|
||||
// Guards the UTC-normalizing init-setter: if OccurredAtUtc is ever "simplified" back to a
|
||||
// plain auto-property, these two (same instant, different offset) would stop comparing equal.
|
||||
var id = Guid.NewGuid();
|
||||
var utc = new DateTimeOffset(2026, 6, 1, 7, 0, 0, TimeSpan.Zero);
|
||||
var plus5 = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5)); // same instant as utc
|
||||
AuditEvent With(DateTimeOffset when) =>
|
||||
new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
Assert.Equal(With(utc), With(plus5));
|
||||
Assert.Equal(With(utc).GetHashCode(), With(plus5).GetHashCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user