feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows

This commit is contained in:
Joseph Doherty
2026-05-23 06:00:24 -04:00
parent 7b619d711d
commit a8d2e13d4e
2 changed files with 165 additions and 9 deletions

View File

@@ -42,11 +42,14 @@ namespace ScadaLink.InboundAPI.Middleware;
/// <para> /// <para>
/// <b>Body capture.</b> The request body is buffered via /// <b>Body capture.</b> The request body is buffered via
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then /// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
/// rewound so the downstream endpoint handler still sees the full payload. /// rewound so the downstream endpoint handler still sees the full payload. The
/// Response body capture is deferred to M5 — wrapping <c>Response.Body</c> /// response body is captured by swapping <see cref="HttpResponse.Body"/> for a
/// requires a memory-stream swap that interacts awkwardly with Minimal API's /// <see cref="MemoryStream"/> before the pipeline runs; after the pipeline
/// <c>Results.Json</c>/<c>Results.Text</c> writers; the M4 deliverable emits /// returns, the buffered bytes are copied to the original stream (transparent
/// the audit row with <see cref="AuditEvent.ResponseSummary"/> left null. /// to the real client) and read into <see cref="AuditEvent.ResponseSummary"/>.
/// Truncation to the configured inbound ceiling happens in
/// <see cref="ScadaLink.AuditLog.Payload.DefaultAuditPayloadFilter"/>; the
/// middleware itself stores the full buffered content.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class AuditWriteMiddleware public sealed class AuditWriteMiddleware
@@ -108,6 +111,16 @@ public sealed class AuditWriteMiddleware
ctx.Request.EnableBuffering(); ctx.Request.EnableBuffering();
var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false); var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);
// Response body — swap in a MemoryStream so the pipeline writes are
// captured. The original Response.Body is restored in the finally block,
// and the captured bytes are copied back to it so the real client still
// receives every byte (transparent wrap). The captured string is then
// available for the audit row.
var originalResponseBody = ctx.Response.Body;
using var responseBuffer = new MemoryStream();
ctx.Response.Body = responseBuffer;
string? responseBody = null;
Exception? thrown = null; Exception? thrown = null;
try try
{ {
@@ -123,7 +136,15 @@ public sealed class AuditWriteMiddleware
finally finally
{ {
sw.Stop(); sw.Stop();
EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody);
// Whatever the handler managed to write — full success, partial
// success before throwing, or nothing at all — copy back to the
// original stream and read for audit.
responseBody = await DrainResponseBufferAsync(responseBuffer, originalResponseBody)
.ConfigureAwait(false);
ctx.Response.Body = originalResponseBody;
EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody, responseBody);
} }
} }
@@ -136,7 +157,8 @@ public sealed class AuditWriteMiddleware
HttpContext ctx, HttpContext ctx,
long durationMs, long durationMs,
Exception? thrown, Exception? thrown,
string? requestBody) string? requestBody,
string? responseBody)
{ {
try try
{ {
@@ -187,8 +209,7 @@ public sealed class AuditWriteMiddleware
DurationMs = (int)Math.Min(durationMs, int.MaxValue), DurationMs = (int)Math.Min(durationMs, int.MaxValue),
ErrorMessage = thrown?.Message, ErrorMessage = thrown?.Message,
RequestSummary = requestBody, RequestSummary = requestBody,
// Response body capture is deferred to M5 (see XML doc above). ResponseSummary = responseBody,
ResponseSummary = null,
PayloadTruncated = false, PayloadTruncated = false,
Extra = extra, Extra = extra,
// Central direct-write — no site-local forwarding state. // Central direct-write — no site-local forwarding state.
@@ -245,6 +266,47 @@ public sealed class AuditWriteMiddleware
} }
} }
/// <summary>
/// Copies the bytes buffered in <paramref name="buffer"/> to
/// <paramref name="originalBody"/> (so the real client still receives them)
/// and returns a UTF-8 string copy for <see cref="AuditEvent.ResponseSummary"/>.
/// Returns null when no bytes were written, mirroring the
/// <see cref="ReadBufferedRequestBodyAsync"/> empty-body contract.
/// </summary>
private static async Task<string?> DrainResponseBufferAsync(
MemoryStream buffer,
Stream originalBody)
{
if (buffer.Length == 0)
{
return null;
}
buffer.Position = 0;
// Copy first so the client never misses bytes even if the read for audit
// throws somehow (defensive — MemoryStream.CopyToAsync to a sink shouldn't
// throw on its own, but the original body may).
try
{
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
}
catch
{
// Best-effort: a sink that refuses our copy is the sink's problem;
// the audit still records what the handler produced. Do NOT rethrow.
}
buffer.Position = 0;
using var reader = new StreamReader(
buffer,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
bufferSize: 1024,
leaveOpen: true);
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
return string.IsNullOrEmpty(content) ? null : content;
}
/// <summary> /// <summary>
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request /// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on /// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on

View File

@@ -487,4 +487,98 @@ public class AuditWriteMiddlewareTests
Assert.NotNull(evt.DurationMs); Assert.NotNull(evt.DurationMs);
Assert.True(evt.DurationMs >= 0); Assert.True(evt.DurationMs >= 0);
} }
// ---------------------------------------------------------------------
// Response body capture — Audit Log #23 (inbound full-response feature).
// Until the M5-deferred work landed, ResponseSummary was always null.
// These tests pin the new contract: the middleware wraps Response.Body,
// runs the pipeline, copies the buffered bytes back to the real stream,
// and stashes a UTF-8 string copy on ResponseSummary.
// ---------------------------------------------------------------------
[Fact]
public async Task ResponseBody_IsCaptured_OnResponseSummary()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var responseJson = "{\"result\":42}";
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 200;
hc.Response.ContentType = "application/json";
await hc.Response.WriteAsync(responseJson);
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(responseJson, evt.ResponseSummary);
}
[Fact]
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
{
// Wrapping the response body must be TRANSPARENT — the real client
// stream still receives every byte the pipeline wrote.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var captured = new MemoryStream();
ctx.Response.Body = captured; // simulate the client/test sink
var responseJson = "{\"ok\":true}";
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync(responseJson);
}, writer);
await mw.InvokeAsync(ctx);
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
}
[Fact]
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
{
// No bytes written => null, not empty-string. Mirrors the request-body
// contract in ReadBufferedRequestBodyAsync.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 204;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ResponseSummary);
Assert.Equal(204, evt.HttpStatus);
}
[Fact]
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
{
// If the handler writes some bytes then throws, the audit row still
// surfaces whatever the framework had flushed. The middleware re-throws
// (audit is best-effort, the request's error path stays authoritative).
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var boom = new InvalidOperationException("kaboom");
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 500;
await hc.Response.WriteAsync("partial");
throw boom;
}, writer);
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
() => mw.InvokeAsync(ctx));
Assert.Same(boom, thrown);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal("partial", evt.ResponseSummary);
}
} }