feat(auditlog): DefaultAuditPayloadFilter truncation with UTF-8 boundary safety (#23 M5)

This commit is contained in:
Joseph Doherty
2026-05-20 17:01:13 -04:00
parent 25cdf857c9
commit bba2ef1b4d
3 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Payload;
/// <summary>
/// Default <see cref="IAuditPayloadFilter"/>. M5 Bundle A scope: payload
/// truncation only (RequestSummary / ResponseSummary / ErrorDetail / Extra),
/// capped at <see cref="AuditLogOptions.DefaultCapBytes"/> on success rows and
/// <see cref="AuditLogOptions.ErrorCapBytes"/> on error rows. Bundle B layers
/// header / body / SQL-parameter redaction on top.
/// </summary>
/// <remarks>
/// <para>
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
/// singleton.
/// </para>
/// <para>
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
/// error body survives.
/// </para>
/// <para>
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
/// (Bundle C) increments the <c>AuditRedactionFailure</c> health metric.
/// </para>
/// </remarks>
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
private readonly IOptionsMonitor<AuditLogOptions> _options;
private readonly ILogger<DefaultAuditPayloadFilter> _logger;
public DefaultAuditPayloadFilter(
IOptionsMonitor<AuditLogOptions> options,
ILogger<DefaultAuditPayloadFilter> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
var opts = _options.CurrentValue;
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
var truncated = false;
var request = TruncateField(rawEvent.RequestSummary, cap, ref truncated);
var response = TruncateField(rawEvent.ResponseSummary, cap, ref truncated);
var errorDetail = TruncateField(rawEvent.ErrorDetail, cap, ref truncated);
var extra = TruncateField(rawEvent.Extra, cap, ref truncated);
return rawEvent with
{
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = rawEvent.PayloadTruncated || truncated,
};
}
catch (Exception ex)
{
// Audit is best-effort: over-redact rather than fail the caller.
// Bundle C wires the AuditRedactionFailure health metric here.
_logger.LogWarning(
ex,
"Payload filter failed; returning raw event with PayloadTruncated=true");
return rawEvent with { PayloadTruncated = true };
}
}
private static string? TruncateField(string? value, int cap, ref bool truncated)
{
if (value is null)
{
return null;
}
var result = TruncateUtf8(value, cap);
if (result.Length != value.Length)
{
truncated = true;
}
return result;
}
/// <summary>
/// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
/// the cap position until the byte is NOT a continuation byte
/// (<c>byte &amp; 0xC0 == 0x80</c>), and decodes the resulting prefix —
/// guaranteeing the returned string never splits a multi-byte sequence.
/// </summary>
private static string TruncateUtf8(string value, int capBytes)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var bytes = Encoding.UTF8.GetBytes(value);
if (bytes.Length <= capBytes)
{
return value;
}
var boundary = capBytes;
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
{
boundary--;
}
return Encoding.UTF8.GetString(bytes, 0, boundary);
}
private static bool IsErrorStatus(AuditStatus status) => status switch
{
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
_ => true,
};
}

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
@@ -59,6 +60,15 @@ public static class ServiceCollectionExtensions
.ValidateOnStart();
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
// ResponseSummary / ErrorDetail / Extra fields between event
// construction and persistence. Bundle B layers header / body /
// SQL-parameter redaction onto the same singleton; Bundle C wires it
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
// paths. Singleton — the filter is stateless and the IOptionsMonitor
// dependency picks up M5-T8 hot reloads on its own.
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
// M2 Bundle E: site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the

View File

@@ -0,0 +1,226 @@
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Payload;
/// <summary>
/// Bundle A (M5-T2) tests for <see cref="DefaultAuditPayloadFilter"/> truncation.
/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
/// <see cref="AuditLogOptions.DefaultCapBytes"/> (8 KiB) on success rows and
/// <see cref="AuditLogOptions.ErrorCapBytes"/> (64 KiB) on error rows. "Error
/// row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>). Truncation must respect UTF-8 character
/// boundaries (never split a multi-byte sequence mid-character) and must set
/// <see cref="AuditEvent.PayloadTruncated"/> true when any field is shortened.
/// </summary>
public class TruncationTests
{
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null)
{
var snapshot = opts ?? new AuditLogOptions();
return new StaticMonitor(snapshot);
}
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
private static AuditEvent NewEvent(
AuditStatus status = AuditStatus.Delivered,
string? request = null,
string? response = null,
string? errorDetail = null,
string? extra = null,
bool payloadTruncated = false) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = status,
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = payloadTruncated,
};
[Fact]
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
{
var input = new string('a', 10 * 1024);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
{
var input = new string('b', 10 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
{
var input = new string('c', 70 * 1024);
var evt = NewEvent(AuditStatus.Failed, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.True(result.PayloadTruncated);
}
[Fact]
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
{
// U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
// safely under the 8192 default cap so the boundary scan kicks in mid-character
// when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
// and forces truncation.
var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
var sb = new StringBuilder();
for (int i = 0; i < 2100; i++)
{
sb.Append(emoji);
}
var input = sb.ToString();
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
var evt = NewEvent(AuditStatus.Delivered, request: input);
var result = Filter().Apply(evt);
Assert.NotNull(result.RequestSummary);
var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
// 4-byte emoji boundary: the kept byte length must be a multiple of 4.
Assert.Equal(0, resultBytes % 4);
// And round-tripping the result must not introduce a U+FFFD replacement char.
Assert.DoesNotContain('<27>', result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void NullSummary_PassesThrough_AsNull()
{
var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
var result = Filter().Apply(evt);
Assert.Null(result.RequestSummary);
Assert.Null(result.ResponseSummary);
Assert.Null(result.ErrorDetail);
Assert.Null(result.Extra);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
{
// Small payload that requires no truncation, but the caller already
// flagged PayloadTruncated upstream — the filter must not clear it.
var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
var result = Filter().Apply(evt);
Assert.Equal("small", result.RequestSummary);
Assert.True(result.PayloadTruncated);
}
[Fact]
public void StatusAttempted_TreatedAsError_UsesErrorCap()
{
// 10 KB is under the 64 KB error cap; if Attempted were a success status
// the value would be truncated to 8 KB. We assert it is NOT truncated.
var input = new string('d', 10 * 1024);
var evt = NewEvent(AuditStatus.Attempted, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusParked_TreatedAsError_UsesErrorCap()
{
var input = new string('e', 10 * 1024);
var evt = NewEvent(AuditStatus.Parked, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void StatusSkipped_TreatedAsError_UsesErrorCap()
{
var input = new string('f', 10 * 1024);
var evt = NewEvent(AuditStatus.Skipped, request: input);
var result = Filter().Apply(evt);
Assert.Equal(input, result.RequestSummary);
Assert.False(result.PayloadTruncated);
}
[Fact]
public void ErrorDetail_AndExtra_Truncated_Independently()
{
// Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
// ErrorDetail on the same Delivered row should both be cut to 8 KB and
// the row flagged truncated.
var input = new string('g', 10 * 1024);
var evt = NewEvent(
AuditStatus.Delivered,
request: input,
response: input,
errorDetail: input,
extra: input);
var result = Filter().Apply(evt);
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
Assert.True(result.PayloadTruncated);
}
/// <summary>
/// IOptionsMonitor test double — returns the same snapshot on every read,
/// no change-token plumbing required for these tests (Bundle D wires the
/// real hot-reload path).
/// </summary>
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}