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>
/// <b>Body capture.</b> The request body is buffered via
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
/// rewound so the downstream endpoint handler still sees the full payload.
/// Response body capture is deferred to M5 — wrapping <c>Response.Body</c>
/// requires a memory-stream swap that interacts awkwardly with Minimal API's
/// <c>Results.Json</c>/<c>Results.Text</c> writers; the M4 deliverable emits
/// the audit row with <see cref="AuditEvent.ResponseSummary"/> left null.
/// rewound so the downstream endpoint handler still sees the full payload. The
/// response body is captured by swapping <see cref="HttpResponse.Body"/> for a
/// <see cref="MemoryStream"/> before the pipeline runs; after the pipeline
/// returns, the buffered bytes are copied to the original stream (transparent
/// 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>
/// </summary>
public sealed class AuditWriteMiddleware
@@ -108,6 +111,16 @@ public sealed class AuditWriteMiddleware
ctx.Request.EnableBuffering();
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;
try
{
@@ -123,7 +136,15 @@ public sealed class AuditWriteMiddleware
finally
{
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,
long durationMs,
Exception? thrown,
string? requestBody)
string? requestBody,
string? responseBody)
{
try
{
@@ -187,8 +209,7 @@ public sealed class AuditWriteMiddleware
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
ErrorMessage = thrown?.Message,
RequestSummary = requestBody,
// Response body capture is deferred to M5 (see XML doc above).
ResponseSummary = null,
ResponseSummary = responseBody,
PayloadTruncated = false,
Extra = extra,
// 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>
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on