feat(audit): redactor + writer helpers (Null/Truncating/NoOp/Composite/Redacting)

Code-review fixes: CompositeAuditWriter re-throws OperationCanceledException
(honors cancellation) + evt null-guard; RedactingAuditWriter evt null-guard;
added marker-longer-than-max and cancellation-propagation regression tests.
This commit is contained in:
Joseph Doherty
2026-06-01 07:28:13 -04:00
parent 3934e528f2
commit 453ec7358d
11 changed files with 283 additions and 0 deletions
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
public sealed class CompositeAuditWriter : IAuditWriter
{
private readonly IReadOnlyList<IAuditWriter> _inner;
/// <summary>Creates a composite over the given writers.</summary>
public CompositeAuditWriter(IEnumerable<IAuditWriter> inner)
{
ArgumentNullException.ThrowIfNull(inner);
_inner = inner.ToArray();
}
/// <inheritdoc />
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 */ }
}
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Writer that discards events. Default when audit is disabled, and useful in tests.</summary>
public sealed class NoOpAuditWriter : IAuditWriter
{
/// <summary>Shared singleton instance.</summary>
public static readonly NoOpAuditWriter Instance = new();
private NoOpAuditWriter() { }
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Identity redactor — returns the event unchanged. The default when no policy is configured.</summary>
public sealed class NullAuditRedactor : IAuditRedactor
{
/// <summary>Shared singleton instance.</summary>
public static readonly NullAuditRedactor Instance = new();
private NullAuditRedactor() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Decorator: applies an <see cref="IAuditRedactor"/>, then delegates to an inner <see cref="IAuditWriter"/>.</summary>
public sealed class RedactingAuditWriter : IAuditWriter
{
private readonly IAuditRedactor _redactor;
private readonly IAuditWriter _inner;
/// <summary>Creates the decorator around <paramref name="inner"/> using <paramref name="redactor"/>.</summary>
public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
{
ArgumentNullException.ThrowIfNull(redactor);
ArgumentNullException.ThrowIfNull(inner);
_redactor = redactor;
_inner = inner;
}
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
return _inner.WriteAsync(_redactor.Apply(evt), ct);
}
}
@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
/// 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.
/// </summary>
public sealed class TruncatingAuditRedactor : IAuditRedactor
{
private readonly TruncatingAuditRedactorOptions _options;
/// <summary>Creates the redactor with the given options (defaults when null).</summary>
public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
=> _options = options ?? new TruncatingAuditRedactorOptions();
/// <inheritdoc />
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);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
public sealed class TruncatingAuditRedactorOptions
{
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
public int MaxDetailsJsonLength { get; set; } = 4096;
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
public int MaxTargetLength { get; set; } = 512;
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
public string TruncationMarker { get; set; } = "…[truncated]";
}
@@ -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<OperationCanceledException>(() => sut.WriteAsync(Evt()));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}