feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user