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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user