feat(auditlog): DefaultAuditPayloadFilter truncation with UTF-8 boundary safety (#23 M5)
This commit is contained in:
124
src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
Normal file
124
src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
Normal 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 & 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user