diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs new file mode 100644 index 0000000..6cb271d --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.Audit; + +/// Fans an event out to several writers. Best-effort: a failing writer does not stop the others. +/// A failing writer's exception is swallowed so the fan-out drains and the caller is never +/// aborted — but is re-thrown so cancellation is honored. +public sealed class CompositeAuditWriter : IAuditWriter +{ + private readonly IReadOnlyList _inner; + + /// Creates a composite over the given writers. + public CompositeAuditWriter(IEnumerable inner) + { + ArgumentNullException.ThrowIfNull(inner); + _inner = inner.ToArray(); + } + + /// + public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + foreach (var writer in _inner) + { + try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow + catch { /* best-effort seam: a failing writer must not stop the others or the caller */ } + } + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs new file mode 100644 index 0000000..b737d9f --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Writer that discards events. Default when audit is disabled, and useful in tests. +public sealed class NoOpAuditWriter : IAuditWriter +{ + /// Shared singleton instance. + public static readonly NoOpAuditWriter Instance = new(); + private NoOpAuditWriter() { } + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs new file mode 100644 index 0000000..d688b3a --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Identity redactor — returns the event unchanged. The default when no policy is configured. +public sealed class NullAuditRedactor : IAuditRedactor +{ + /// Shared singleton instance. + public static readonly NullAuditRedactor Instance = new(); + private NullAuditRedactor() { } + + /// + public AuditEvent Apply(AuditEvent rawEvent) => rawEvent; +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs new file mode 100644 index 0000000..4ff4794 --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.Audit; + +/// Decorator: applies an , then delegates to an inner . +public sealed class RedactingAuditWriter : IAuditWriter +{ + private readonly IAuditRedactor _redactor; + private readonly IAuditWriter _inner; + + /// Creates the decorator around using . + public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner) + { + ArgumentNullException.ThrowIfNull(redactor); + ArgumentNullException.ThrowIfNull(inner); + _redactor = redactor; + _inner = inner; + } + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + return _inner.WriteAsync(_redactor.Apply(evt), ct); + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs new file mode 100644 index 0000000..4ca6cbd --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Redactor that caps oversized and . +/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy +/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed. +/// +public sealed class TruncatingAuditRedactor : IAuditRedactor +{ + private readonly TruncatingAuditRedactorOptions _options; + + /// Creates the redactor with the given options (defaults when null). + public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null) + => _options = options ?? new TruncatingAuditRedactorOptions(); + + /// + public AuditEvent Apply(AuditEvent rawEvent) + { + try + { + return rawEvent with + { + Target = Truncate(rawEvent.Target, _options.MaxTargetLength), + DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength), + }; + } + catch + { + // Hard contract: never throw. Over-redact on internal failure. + return rawEvent with { DetailsJson = null }; + } + } + + private string? Truncate(string? value, int max) + { + if (value is null || value.Length <= max) return value; + var marker = _options.TruncationMarker; + if (marker.Length >= max) return marker[..max]; + return string.Concat(value.AsSpan(0, max - marker.Length), marker); + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs new file mode 100644 index 0000000..0c44aba --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Caps for . +public sealed class TruncatingAuditRedactorOptions +{ + /// Max length of before truncation. Default 4096. + public int MaxDetailsJsonLength { get; set; } = 4096; + /// Max length of before truncation. Default 512. + public int MaxTargetLength { get; set; } = 512; + /// Marker appended to a truncated value. Default "…[truncated]". + public string TruncationMarker { get; set; } = "…[truncated]"; +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs new file mode 100644 index 0000000..1faef9c --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs @@ -0,0 +1,48 @@ +namespace ZB.MOM.WW.Audit.Tests; + +public class CompositeAuditWriterTests +{ + private sealed class RecordingWriter : IAuditWriter + { + public int Count; + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; } + } + private sealed class ThrowingWriter : IAuditWriter + { + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom"); + } + private sealed class CancellingWriter : IAuditWriter + { + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException(); + } + + private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "a", Action = "x", Outcome = AuditOutcome.Success }; + + [Fact] + public async Task Fans_out_to_all_writers() + { + var a = new RecordingWriter(); var b = new RecordingWriter(); + await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt()); + Assert.Equal(1, a.Count); + Assert.Equal(1, b.Count); + } + + [Fact] + public async Task One_failing_writer_does_not_stop_the_others() + { + var after = new RecordingWriter(); + var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after }); + await sut.WriteAsync(Evt()); // must not throw + Assert.Equal(1, after.Count); + } + + [Fact] + public async Task Cancellation_is_propagated_not_swallowed() + { + // OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed). + var after = new RecordingWriter(); + var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after }); + await Assert.ThrowsAsync(() => sut.WriteAsync(Evt())); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs new file mode 100644 index 0000000..f44fea1 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit.Tests; + +public class NoOpAuditWriterTests +{ + [Fact] + public async Task WriteAsync_completes_without_error() + { + var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "a", Action = "x", Outcome = AuditOutcome.Success }; + await NoOpAuditWriter.Instance.WriteAsync(evt); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs new file mode 100644 index 0000000..4bca581 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit.Tests; + +public class NullAuditRedactorTests +{ + [Fact] + public void Apply_returns_input_unchanged() + { + var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" }; + Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt)); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs new file mode 100644 index 0000000..34dc53d --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.Audit.Tests; + +public class RedactingAuditWriterTests +{ + private sealed class CapturingWriter : IAuditWriter + { + public AuditEvent? Last; + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; } + } + private sealed class StampRedactor : IAuditRedactor + { + public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" }; + } + + private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" }; + + [Fact] + public async Task Inner_writer_receives_the_redacted_event() + { + var inner = new CapturingWriter(); + var sut = new RedactingAuditWriter(new StampRedactor(), inner); + await sut.WriteAsync(Evt()); + Assert.Equal("redacted", inner.Last!.DetailsJson); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs new file mode 100644 index 0000000..02ba387 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs @@ -0,0 +1,56 @@ +namespace ZB.MOM.WW.Audit.Tests; + +public class TruncatingAuditRedactorTests +{ + private static AuditEvent Evt(string? details, string? target = null) => new() + { + EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "a", Action = "x", Outcome = AuditOutcome.Success, + DetailsJson = details, Target = target, + }; + + [Fact] + public void Short_values_pass_through_unchanged() + { + var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 }); + var evt = Evt("small"); + Assert.Equal("small", r.Apply(evt).DetailsJson); + } + + [Fact] + public void Oversized_details_are_truncated_with_marker() + { + var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" }; + var r = new TruncatingAuditRedactor(opts); + var result = r.Apply(Evt(new string('x', 50))); + Assert.Equal(10, result.DetailsJson!.Length); + Assert.EndsWith("~", result.DetailsJson); + } + + [Fact] + public void Oversized_target_is_truncated() + { + var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" }); + var result = r.Apply(Evt(null, target: "abcdefghij")); + Assert.Equal(5, result.Target!.Length); + } + + [Fact] + public void Null_fields_are_left_null() + { + var r = new TruncatingAuditRedactor(); + var result = r.Apply(Evt(null)); + Assert.Null(result.DetailsJson); + Assert.Null(result.Target); + } + + [Fact] + public void Marker_longer_than_max_clips_the_marker_itself() + { + // Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars. + var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" }; + var r = new TruncatingAuditRedactor(opts); + var result = r.Apply(Evt(new string('x', 20))); + Assert.Equal(3, result.DetailsJson!.Length); + } +}