diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs new file mode 100644 index 0000000..8682a9f --- /dev/null +++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs @@ -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; + +/// +/// Default . M5 Bundle A scope: payload +/// truncation only (RequestSummary / ResponseSummary / ErrorDetail / Extra), +/// capped at on success rows and +/// on error rows. Bundle B layers +/// header / body / SQL-parameter redaction on top. +/// +/// +/// +/// Uses (not ) +/// so the M5-T8 hot-reload path sees fresh values without re-resolving the +/// singleton. +/// +/// +/// "Error row" = NOT IN (Delivered, +/// Submitted, Forwarded) — every other status, including the +/// non-terminal Attempted, the parked/discarded terminals, and the +/// short-circuit Skipped, receives the larger error cap so a verbose +/// error body survives. +/// +/// +/// Apply MUST NOT throw — on internal failure the filter over-redacts by +/// returning the input with set and +/// (Bundle C) increments the AuditRedactionFailure health metric. +/// +/// +public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter +{ + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + public DefaultAuditPayloadFilter( + IOptionsMonitor options, + ILogger 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; + } + + /// + /// 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 + /// (byte & 0xC0 == 0x80), and decodes the resulting prefix — + /// guaranteeing the returned string never splits a multi-byte sequence. + /// + 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, + }; +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 346ea0f..d42f299 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -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, 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(); + // 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 diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs new file mode 100644 index 0000000..d747336 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs @@ -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; + +/// +/// Bundle A (M5-T2) tests for truncation. +/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at +/// (8 KiB) on success rows and +/// (64 KiB) on error rows. "Error +/// row" = NOT IN (Delivered, +/// Submitted, Forwarded). Truncation must respect UTF-8 character +/// boundaries (never split a multi-byte sequence mid-character) and must set +/// true when any field is shortened. +/// +public class TruncationTests +{ + private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) + { + var snapshot = opts ?? new AuditLogOptions(); + return new StaticMonitor(snapshot); + } + + private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) => + new(Monitor(opts), NullLogger.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('�', 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); + } + + /// + /// 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). + /// + private sealed class StaticMonitor : IOptionsMonitor + { + private readonly AuditLogOptions _value; + + public StaticMonitor(AuditLogOptions value) => _value = value; + + public AuditLogOptions CurrentValue => _value; + + public AuditLogOptions Get(string? name) => _value; + + public IDisposable? OnChange(Action listener) => null; + } +}