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