feat(audit): M5.3 response-capture increments — request headers, ceiling-hits counter, per-method body opt-out (T7)
1. Request headers in Extra JSON (AuditWriteMiddleware): adds a `requestHeaders` object to the existing Extra JSON alongside remoteIp/userAgent; headers whose names appear in AuditLogOptions.HeaderRedactList (Authorization, X-Api-Key, Cookie, Set-Cookie by default) are replaced with "<redacted>" using OrdinalIgnoreCase matching — same policy as ScadaBridgeAuditRedactor. 2. AuditInboundCeilingHits counter: new IAuditInboundCeilingHitsCounter interface + NoOpAuditInboundCeilingHitsCounter default; AuditCentralHealthSnapshot implements the interface (Interlocked field, thread-safe) and exposes AuditInboundCeilingHits on IAuditCentralHealthSnapshot; AddAuditLog registers the NoOp default, AddAuditLogCentralMaintenance forwards to the snapshot; AuditWriteMiddleware accepts the counter as an optional ctor arg and increments it once per request where either the request or response body hit the cap. 3. Per-method SkipBodyCapture opt-out: adds SkipBodyCapture bool to PerTargetRedactionOverride; AuditWriteMiddleware consults the per-target override map at the start of InvokeAsync (before EnableBuffering) and, when set, skips body read + capture entirely — the audit row still emits with headers/metadata but null RequestSummary/ResponseSummary; truncation flags are also cleared so the ceiling-hits counter is not bumped for opted-out methods.
This commit is contained in:
@@ -39,10 +39,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
public sealed class AuditCentralHealthSnapshot
|
||||
: IAuditCentralHealthSnapshot,
|
||||
ICentralAuditWriteFailureCounter,
|
||||
IAuditRedactionFailureCounter
|
||||
IAuditRedactionFailureCounter,
|
||||
IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
private int _centralAuditWriteFailures;
|
||||
private int _auditRedactionFailure;
|
||||
private int _auditInboundCeilingHits;
|
||||
private readonly ConcurrentDictionary<string, bool> _stalled = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -53,6 +55,10 @@ public sealed class AuditCentralHealthSnapshot
|
||||
public int AuditRedactionFailure =>
|
||||
Interlocked.CompareExchange(ref _auditRedactionFailure, 0, 0);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int AuditInboundCeilingHits =>
|
||||
Interlocked.CompareExchange(ref _auditInboundCeilingHits, 0, 0);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, bool> SiteAuditTelemetryStalled =>
|
||||
new Dictionary<string, bool>(_stalled);
|
||||
@@ -78,4 +84,8 @@ public sealed class AuditCentralHealthSnapshot
|
||||
/// <inheritdoc/>
|
||||
void IAuditRedactionFailureCounter.Increment() =>
|
||||
Interlocked.Increment(ref _auditRedactionFailure);
|
||||
|
||||
/// <inheritdoc/>
|
||||
void IAuditInboundCeilingHitsCounter.Increment() =>
|
||||
Interlocked.Increment(ref _auditInboundCeilingHits);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,17 @@ public interface IAuditCentralHealthSnapshot
|
||||
/// </summary>
|
||||
int AuditRedactionFailure { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of inbound request/response body truncations at the
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.InboundMaxBytes"/>
|
||||
/// ceiling since process start. Incremented by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware.AuditWriteMiddleware"/>
|
||||
/// whenever either the request or response body exceeds the cap and is
|
||||
/// truncated in the audit copy. A sustained non-zero count can indicate
|
||||
/// callers sending unexpectedly large bodies.
|
||||
/// </summary>
|
||||
int AuditInboundCeilingHits { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-site latched stalled state: <c>true</c> when the
|
||||
/// <see cref="SiteAuditReconciliationActor"/> has observed two
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M5.3 (T7) counter sink incremented by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware.AuditWriteMiddleware"/>
|
||||
/// whenever an inbound request or response body is truncated at the
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.InboundMaxBytes"/>
|
||||
/// ceiling. Mirrors the <see cref="ICentralAuditWriteFailureCounter"/> shape:
|
||||
/// one-method, NoOp default, must-never-abort-the-user-facing-action invariant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A ceiling hit is a normal operational event (the caller sent a large
|
||||
/// body) rather than a failure, but surfacing a cumulative count lets
|
||||
/// operators detect over-size callers early. The
|
||||
/// <see cref="AuditCentralHealthSnapshot"/> production implementation
|
||||
/// accumulates the count via an <c>Interlocked</c> field alongside
|
||||
/// <see cref="ICentralAuditWriteFailureCounter"/> and
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter"/>.
|
||||
/// </remarks>
|
||||
public interface IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
/// <summary>Increment the inbound body-ceiling hit counter by one.</summary>
|
||||
void Increment();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditInboundCeilingHitsCounter"/> binding used when
|
||||
/// the central health snapshot is not wired (e.g. site composition roots,
|
||||
/// test harnesses that have no health dashboard). All increments are silently
|
||||
/// dropped — correct for environments that have no audit KPI surface.
|
||||
/// </summary>
|
||||
public sealed class NoOpAuditInboundCeilingHitsCounter : IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Increment() { }
|
||||
}
|
||||
@@ -25,4 +25,15 @@ public sealed class PerTargetRedactionOverride
|
||||
/// rows.
|
||||
/// </summary>
|
||||
public string? RedactSqlParamsMatching { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the inbound API audit row for this target records
|
||||
/// request/response headers and metadata (status, duration, actor, etc.)
|
||||
/// but the request and response body strings are omitted
|
||||
/// (<c>RequestSummary</c> / <c>ResponseSummary</c> are left null). The
|
||||
/// audit row itself is always emitted — only the body content is suppressed.
|
||||
/// Null (the default, equivalent to <c>false</c>) means body capture
|
||||
/// proceeds normally up to <see cref="AuditLogOptions.InboundMaxBytes"/>.
|
||||
/// </summary>
|
||||
public bool SkipBodyCapture { get; set; }
|
||||
}
|
||||
|
||||
@@ -200,6 +200,13 @@ public static class ServiceCollectionExtensions
|
||||
// surface on the central dashboard.
|
||||
services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>();
|
||||
|
||||
// M5.3 (T7): inbound body-ceiling hit counter — NoOp default for
|
||||
// site/test roots. AddAuditLogCentralMaintenance replaces this binding
|
||||
// with the AuditCentralHealthSnapshot implementation so ceiling-hit
|
||||
// counts surface on the central dashboard alongside write-failure and
|
||||
// redaction-failure counters.
|
||||
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
|
||||
|
||||
// M4 Bundle B: central direct-write audit writer used by
|
||||
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
|
||||
// emit AuditLog rows that originate ON central, not via site telemetry.
|
||||
@@ -383,6 +390,12 @@ public static class ServiceCollectionExtensions
|
||||
// HealthMetricsAuditRedactionFailureCounter shape one-for-one.
|
||||
services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter,
|
||||
CentralAuditRedactionFailureCounter>());
|
||||
// M5.3 (T7): replace the NoOp IAuditInboundCeilingHitsCounter with the
|
||||
// AuditCentralHealthSnapshot so ceiling-hit counts surface on the
|
||||
// central dashboard. Same singleton-forward pattern as
|
||||
// ICentralAuditWriteFailureCounter above.
|
||||
services.Replace(ServiceDescriptor.Singleton<IAuditInboundCeilingHitsCounter>(
|
||||
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -95,6 +96,7 @@ public sealed class AuditWriteMiddleware
|
||||
private readonly ILogger<AuditWriteMiddleware> _logger;
|
||||
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
||||
private readonly IAuditActorAccessor? _actorAccessor;
|
||||
private readonly IAuditInboundCeilingHitsCounter _ceilingHitsCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the middleware with its required dependencies.
|
||||
@@ -110,18 +112,26 @@ public sealed class AuditWriteMiddleware
|
||||
/// construct the middleware; when absent, actor resolution falls back to the
|
||||
/// stashed API-key name only.
|
||||
/// </param>
|
||||
/// <param name="ceilingHitsCounter">
|
||||
/// M5.3 (T7, optional): incremented whenever an inbound request or response
|
||||
/// body is truncated at <see cref="AuditLogOptions.InboundMaxBytes"/>. Optional
|
||||
/// so existing tests and composition roots without the central health snapshot
|
||||
/// wired still construct without the counter; a NoOp is used when absent.
|
||||
/// </param>
|
||||
public AuditWriteMiddleware(
|
||||
RequestDelegate next,
|
||||
ICentralAuditWriter auditWriter,
|
||||
ILogger<AuditWriteMiddleware> logger,
|
||||
IOptionsMonitor<AuditLogOptions> options,
|
||||
IAuditActorAccessor? actorAccessor = null)
|
||||
IAuditActorAccessor? actorAccessor = null,
|
||||
IAuditInboundCeilingHitsCounter? ceilingHitsCounter = null)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_actorAccessor = actorAccessor;
|
||||
_ceilingHitsCounter = ceilingHitsCounter ?? new NoOpAuditInboundCeilingHitsCounter();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -133,9 +143,11 @@ public sealed class AuditWriteMiddleware
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Per-request hot read of the inbound cap so a live config change
|
||||
// Per-request hot read of the options snapshot so a live config change
|
||||
// picks up on the next request without re-resolving the singleton.
|
||||
var cap = _options.CurrentValue.InboundMaxBytes;
|
||||
// InboundMaxBytes is read once here and passed to the capture helpers.
|
||||
var opts = _options.CurrentValue;
|
||||
var cap = opts.InboundMaxBytes;
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
|
||||
// ExecutionId ONCE, here at the start of the request, and stash it on
|
||||
@@ -163,9 +175,20 @@ public sealed class AuditWriteMiddleware
|
||||
// ReadBufferedRequestBodyAsync's own ContentLength is 0 short-circuit
|
||||
// returns (null, false) for the bodyless case anyway, so the audit row
|
||||
// is unchanged.
|
||||
//
|
||||
// M5.3 (T7): check if the matched method/target has SkipBodyCapture set.
|
||||
// The route value is resolved BEFORE the pipeline runs (route matching
|
||||
// has already bound {methodName} at this point), so we can skip the
|
||||
// EnableBuffering allocation and body read up front.
|
||||
var methodNameForOverride = ctx.Request.RouteValues.TryGetValue("methodName", out var rv)
|
||||
&& rv is string mn && !string.IsNullOrWhiteSpace(mn) ? mn : null;
|
||||
var skipBody = methodNameForOverride != null
|
||||
&& opts.PerTargetOverrides.TryGetValue(methodNameForOverride, out var perTarget)
|
||||
&& perTarget.SkipBodyCapture;
|
||||
|
||||
var requestBody = (string?)null;
|
||||
var requestTruncated = false;
|
||||
if (RequestHasBody(ctx.Request))
|
||||
if (!skipBody && RequestHasBody(ctx.Request))
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
(requestBody, requestTruncated) =
|
||||
@@ -200,7 +223,14 @@ public sealed class AuditWriteMiddleware
|
||||
// The forwarding wrapper has already written every byte to the
|
||||
// original sink; this just pulls back the bounded UTF-8 string.
|
||||
ctx.Response.Body = originalResponseBody;
|
||||
var (responseBody, responseTruncated) = captureStream.GetCapturedBody();
|
||||
var (capturedResponseBody, capturedResponseTruncated) = captureStream.GetCapturedBody();
|
||||
// M5.3 (T7): if SkipBodyCapture is set, discard the captured response
|
||||
// body (the request body was never captured above). The row + headers
|
||||
// still emit with null RequestSummary / ResponseSummary.
|
||||
// Truncation flags are also cleared so ceiling-hit counter is not
|
||||
// bumped for methods that deliberately opt out of body capture.
|
||||
var responseBody = skipBody ? null : capturedResponseBody;
|
||||
var responseTruncated = skipBody ? false : capturedResponseTruncated;
|
||||
|
||||
EmitInboundAudit(
|
||||
ctx,
|
||||
@@ -208,7 +238,9 @@ public sealed class AuditWriteMiddleware
|
||||
thrown,
|
||||
requestBody,
|
||||
responseBody,
|
||||
requestTruncated || responseTruncated);
|
||||
requestTruncated || responseTruncated,
|
||||
requestTruncated,
|
||||
responseTruncated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +255,9 @@ public sealed class AuditWriteMiddleware
|
||||
Exception? thrown,
|
||||
string? requestBody,
|
||||
string? responseBody,
|
||||
bool payloadTruncated)
|
||||
bool payloadTruncated,
|
||||
bool requestTruncated = false,
|
||||
bool responseTruncated = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -243,10 +277,40 @@ public sealed class AuditWriteMiddleware
|
||||
var actor = isAuthFailure ? null : ResolveActor(ctx);
|
||||
var methodName = ResolveMethodName(ctx);
|
||||
|
||||
// M5.3 (T7): increment the ceiling-hits counter once per request
|
||||
// that hit the cap on EITHER the request or response body.
|
||||
if (requestTruncated || responseTruncated)
|
||||
{
|
||||
try { _ceilingHitsCounter.Increment(); } catch { /* swallow per §7 */ }
|
||||
}
|
||||
|
||||
// M5.3 (T7): capture request headers into Extra JSON alongside the
|
||||
// existing remoteIp / userAgent provenance fields. The header
|
||||
// collection is run through the SAME header-redaction list
|
||||
// (AuditLogOptions.HeaderRedactList) that the ScadaBridgeAuditRedactor
|
||||
// applies to RequestSummary / ResponseSummary — auth/sensitive
|
||||
// headers are redacted before they land in the row.
|
||||
var currentOpts = _options.CurrentValue;
|
||||
var redactSet = new HashSet<string>(
|
||||
currentOpts.HeaderRedactList,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var headerDict = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var header in ctx.Request.Headers)
|
||||
{
|
||||
// Redact headers whose name appears in the HeaderRedactList —
|
||||
// the same "<redacted>" marker used by ScadaBridgeAuditRedactor.
|
||||
var value = redactSet.Contains(header.Key)
|
||||
? "<redacted>"
|
||||
: header.Value.ToString();
|
||||
headerDict[header.Key] = value;
|
||||
}
|
||||
|
||||
var extra = JsonSerializer.Serialize(new
|
||||
{
|
||||
remoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
|
||||
userAgent = ctx.Request.Headers.UserAgent.ToString(),
|
||||
requestHeaders = headerDict,
|
||||
});
|
||||
|
||||
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
@@ -163,6 +164,69 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(0, snapshot.AuditRedactionFailure);
|
||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) AuditInboundCeilingHits counter
|
||||
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
|
||||
// Incrementing through the interface surface is reflected on the snapshot.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_StartsAtZero()
|
||||
{
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
|
||||
{
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var counter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
|
||||
counter.Increment();
|
||||
counter.Increment();
|
||||
counter.Increment();
|
||||
|
||||
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsThreadSafe()
|
||||
{
|
||||
// Interlocked increment must produce the correct count under concurrent
|
||||
// increments — same shape as the existing counter tests.
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var counter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
const int incrementCount = 1000;
|
||||
|
||||
Parallel.For(0, incrementCount, _ => counter.Increment());
|
||||
|
||||
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
|
||||
{
|
||||
// Ceiling-hits increments must not cross-contaminate the other counters
|
||||
// and vice versa — each Interlocked field is independent.
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var ceilingCounter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
var writeCounter = (ICentralAuditWriteFailureCounter)snapshot;
|
||||
var redactCounter = (ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot;
|
||||
|
||||
ceilingCounter.Increment();
|
||||
ceilingCounter.Increment();
|
||||
writeCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
|
||||
Assert.Equal(2, snapshot.AuditInboundCeilingHits);
|
||||
Assert.Equal(1, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(3, snapshot.AuditRedactionFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,4 +1022,429 @@ public class AuditWriteMiddlewareTests
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 1: Request headers in Extra JSON
|
||||
// Request headers are captured into the Extra JSON object alongside the
|
||||
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
|
||||
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
|
||||
// HeaderRedactList as ScadaBridgeAuditRedactor.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Custom-Header"] = "custom-value";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
// Extra must carry a requestHeaders object.
|
||||
Assert.True(root.TryGetProperty("requestHeaders", out var headers),
|
||||
"Extra JSON must contain a 'requestHeaders' property");
|
||||
Assert.Equal(JsonValueKind.Object, headers.ValueKind);
|
||||
// The non-sensitive custom header must appear unredacted.
|
||||
Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal),
|
||||
"requestHeaders must contain 'X-Custom-Header'");
|
||||
Assert.Equal("custom-value", customVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
|
||||
{
|
||||
// Authorization is in the default HeaderRedactList and must appear as
|
||||
// "<redacted>" rather than the real token value.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
var headers = root.GetProperty("requestHeaders");
|
||||
Assert.True(headers.TryGetProperty("Authorization", out var authVal),
|
||||
"requestHeaders must contain 'Authorization'");
|
||||
Assert.Equal("<redacted>", authVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
|
||||
{
|
||||
// X-Api-Key is in the default HeaderRedactList and must be redacted.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
var headers = root.GetProperty("requestHeaders");
|
||||
Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal));
|
||||
Assert.Equal("<redacted>", keyVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
|
||||
{
|
||||
// A non-default entry added to HeaderRedactList must also be redacted.
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
HeaderRedactList = new List<string>
|
||||
{
|
||||
"Authorization", "X-Api-Key", "Cookie", "Set-Cookie",
|
||||
"X-Internal-Secret", // custom addition
|
||||
},
|
||||
};
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value";
|
||||
ctx.Request.Headers["X-Safe-Header"] = "safe-value";
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var headers = doc.RootElement.GetProperty("requestHeaders");
|
||||
Assert.Equal("<redacted>", headers.GetProperty("X-Internal-Secret").GetString());
|
||||
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
|
||||
{
|
||||
// HeaderRedactList match must be case-insensitive (mirrors the
|
||||
// ScadaBridgeAuditRedactor behaviour — the redact set uses
|
||||
// OrdinalIgnoreCase).
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
// Vary the casing from the list entry ("Authorization").
|
||||
ctx.Request.Headers["authorization"] = "Bearer lower-case-token";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var headers = doc.RootElement.GetProperty("requestHeaders");
|
||||
// ASP.NET Core normalises the header name to "authorization" in the dict;
|
||||
// the redact set (OrdinalIgnoreCase) must still match it.
|
||||
Assert.Equal("<redacted>", headers.GetProperty("authorization").GetString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
|
||||
// When request OR response exceeds InboundMaxBytes, the middleware
|
||||
// increments IAuditInboundCeilingHitsCounter once per request.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditInboundCeilingHitsCounter"/> that records
|
||||
/// every <see cref="Increment"/> call.
|
||||
/// </summary>
|
||||
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
private int _count;
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
private static AuditWriteMiddleware CreateMiddlewareWithCounter(
|
||||
RequestDelegate next,
|
||||
ICentralAuditWriter writer,
|
||||
AuditLogOptions? options,
|
||||
ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) =>
|
||||
new(
|
||||
next,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
|
||||
actorAccessor: null,
|
||||
ceilingHitsCounter: counter);
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
const int cap = 1024;
|
||||
var bigBody = new string('x', cap + 100);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var ctx = BuildContext(body: bigBody);
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
// Verify the truncation did happen to confirm ceiling was hit.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
const int cap = 1024;
|
||||
var bigResponse = new string('y', cap + 100);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync(bigResponse);
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var smallBody = "{\"ok\":true}";
|
||||
var ctx = BuildContext(body: smallBody);
|
||||
// Cap is well above the body size.
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = 8192 },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
|
||||
// A target with SkipBodyCapture=true produces an audit row with
|
||||
// headers/metadata but empty/omitted body. A normal target still captures.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static DefaultHttpContext BuildContextWithRoute(
|
||||
string methodName,
|
||||
string? body = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.Path = $"/api/{methodName}";
|
||||
ctx.Request.RouteValues["methodName"] = methodName;
|
||||
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Request.Body = new MemoryStream(bytes);
|
||||
ctx.Request.ContentLength = bytes.Length;
|
||||
ctx.Request.ContentType = "application/json";
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
|
||||
{
|
||||
// A target with SkipBodyCapture=true must produce an audit row (the
|
||||
// row must not be suppressed entirely) but RequestSummary and
|
||||
// ResponseSummary must both be null — only the body is omitted.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}");
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"result\":\"secret\"}");
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// Row IS emitted — only the body content is suppressed.
|
||||
Assert.Equal("secret-method", evt.Target);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
// Bodies are null — SkipBodyCapture stripped them.
|
||||
Assert.Null(evt.RequestSummary);
|
||||
Assert.Null(evt.ResponseSummary);
|
||||
// Headers / metadata are still present.
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _),
|
||||
"Headers must be present even when body capture is skipped");
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
|
||||
{
|
||||
// When SkipBodyCapture=true the body is never measured against the cap;
|
||||
// the counter must NOT be bumped even if the body would have exceeded it.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
const int cap = 64;
|
||||
var bigBody = new string('z', cap + 1000);
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
InboundMaxBytes = cap,
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var ctx = BuildContextWithRoute("large-method", body: bigBody);
|
||||
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: opts,
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
|
||||
{
|
||||
// Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false)
|
||||
// must still capture the body normally.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
var requestJson = "{\"a\":1}";
|
||||
var ctx = BuildContextWithRoute("normal-method", body: requestJson);
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"result\":1}");
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
|
||||
{
|
||||
// A target with no per-target override at all must still capture the body —
|
||||
// SkipBodyCapture defaults to false and must not suppress capture.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var requestJson = "{\"x\":99}";
|
||||
var ctx = BuildContext(body: requestJson);
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"y\":99}");
|
||||
},
|
||||
writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
Assert.Equal("{\"y\":99}", evt.ResponseSummary);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user