diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs new file mode 100644 index 0000000..5de8e2d --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Canonical, transport-agnostic audit record — who did what, when, with what outcome. +/// Required core + optional common fields + a extension bag. Each +/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types) +/// map into // and are not +/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md. +/// +public sealed record AuditEvent +{ + /// Idempotency key uniquely identifying this audit event. + public required Guid EventId { get; init; } + + /// When the audited action occurred. Normalized to UTC on assignment. + /// Participates in record value-equality as a normalized instant: two events whose + /// OccurredAtUtc denote the same instant at different offsets (e.g. 12:00+05:00 and + /// 07:00Z) compare equal and share a hash code. Relevant to consumers that dedup/key on + /// value-equality. + public required DateTimeOffset OccurredAtUtc + { + get => _occurredAtUtc; + init => _occurredAtUtc = value.ToUniversalTime(); + } + private readonly DateTimeOffset _occurredAtUtc; + + /// Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption). + public required string Actor { get; init; } + + /// What was done — a verb/event-type string. + public required string Action { get; init; } + + /// Normalized outcome. + public required AuditOutcome Outcome { get; init; } + + /// Optional subsystem/grouping for the action. + public string? Category { get; init; } + + /// Optional target of the action (resource/method/connection). + public string? Target { get; init; } + + /// Optional node that emitted the event. + public string? SourceNode { get; init; } + + /// Optional correlation id joining this row to its originating request/workflow. + public Guid? CorrelationId { get; init; } + + /// Optional JSON extension carrying project-specific fields. + public string? DetailsJson { get; init; } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs new file mode 100644 index 0000000..536561e --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Normalized outcome of an audited action. +public enum AuditOutcome +{ + /// The action completed successfully. + Success, + /// The action failed due to an error. + Failure, + /// The action was rejected by authentication/authorization. + Denied, +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs new file mode 100644 index 0000000..f8f5f2c --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Filters an 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 ILogRedactor so a future +/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it. +/// +public interface IAuditRedactor +{ + /// Apply the configured truncation/redaction policy and return a filtered copy. + AuditEvent Apply(AuditEvent rawEvent); +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs new file mode 100644 index 0000000..a3e6619 --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Best-effort sink for 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. +/// +public interface IAuditWriter +{ + /// Persist an audit event. Best-effort; must not throw to the caller. + Task WriteAsync(AuditEvent evt, CancellationToken ct = default); +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs new file mode 100644 index 0000000..f5e17f8 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs @@ -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()); + } +}