docs(audit): implementation plan — full request/response capture for inbound API audit rows

Plan companion to the 2026-05-23 design doc. Seven tasks (#0 prep, #1-3
implementation TDD, #4-5 doc updates, #6 final sweep). Tracks via
.tasks.json for resumability.
This commit is contained in:
Joseph Doherty
2026-05-23 05:36:08 -04:00
parent 0670864160
commit 441ec087a7
2 changed files with 758 additions and 0 deletions

View File

@@ -0,0 +1,745 @@
# Inbound API: Full Request/Response Audit Capture — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
**Goal:** Inbound API audit rows (`AuditChannel.ApiInbound`) capture the FULL request and response body verbatim up to a configurable 1 MB per-body ceiling, instead of the global 8 KB / 64 KB caps. Other channels are untouched.
**Architecture:** Two changes. (1) `AuditLogOptions` gains an `InboundMaxBytes` knob; `DefaultAuditPayloadFilter` branches on `Channel == ApiInbound` to use it. (2) `AuditWriteMiddleware` finally implements the M5-deferred response-body capture — wraps `HttpContext.Response.Body` with a buffering `MemoryStream` swap, reads it after the pipeline runs, restores and flushes the original body. The redaction stages (headers, body regexes, SQL params) keep their existing semantics; only the truncation cap changes for ApiInbound rows. Validated design: `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md`.
**Tech Stack:** .NET 10, xUnit, ASP.NET Core Minimal API, `Microsoft.Extensions.Options.IOptionsMonitor`.
**Ground rules (every task):** create + work on branch `feature/inbound-api-full-response-audit` — never commit to `main`. TDD: failing test first, then minimal implementation, then verify. Edit in place; never edit `infra/*`, `alog.md`, or `docker/*` unless a task names them (none here). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. Solution stays green: `dotnet build ScadaLink.slnx` 0 warnings (`TreatWarningsAsErrors` on). Do not push.
---
## Task 0: Prep — branch, baseline build
**Files:** none.
**Steps:**
1. `git status --short` — confirm you are starting from the `main` revision that already contains commit `0670864` (`docs(audit): design — full request/response capture for inbound API rows`).
2. `git checkout -b feature/inbound-api-full-response-audit`.
3. `git branch --show-current` — expect `feature/inbound-api-full-response-audit`.
4. `dotnet build ScadaLink.slnx` from repo root — expect 0 warnings, 0 errors.
**Acceptance:** on the feature branch; solution builds clean.
**Commit:** none (no changes yet).
---
## Task 1: Add `InboundMaxBytes` to `AuditLogOptions` (TDD)
**What:** New `int InboundMaxBytes` property on `AuditLogOptions` with default 1 048 576 bytes, validated to `[8192, 16777216]`.
**Files:**
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — add property + XML doc.
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — add min/max constants + validation branch.
- Modify: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs` — extend the binding test to assert the new field round-trips from JSON.
- Test (new): `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs` — if this file does not exist, create it with the four cases below; if a validator-tests file already exists (search for it under `tests/ScadaLink.AuditLog.Tests/Configuration/`), extend it instead.
**Step 1: Write the failing tests**
Add to a validator-tests file (create if missing — namespace `ScadaLink.AuditLog.Tests.Configuration`):
```csharp
public class AuditLogOptionsValidatorTests
{
[Fact]
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
{
// The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
// is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure,
// not a silent operational surprise.
var opts = new AuditLogOptions();
Assert.Equal(1_048_576, opts.InboundMaxBytes);
}
[Theory]
[InlineData(8_192)] // documented min
[InlineData(1_048_576)] // default
[InlineData(16_777_216)] // documented max
public void Validate_InboundMaxBytes_InRange_Passes(int value)
{
var validator = new AuditLogOptionsValidator();
var opts = new AuditLogOptions { InboundMaxBytes = value };
Assert.True(validator.Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(8_191)]
[InlineData(16_777_217)]
[InlineData(int.MaxValue)]
public void Validate_InboundMaxBytes_OutOfRange_Fails(int value)
{
var validator = new AuditLogOptionsValidator();
var opts = new AuditLogOptions { InboundMaxBytes = value };
var result = validator.Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
}
}
```
Extend `AuditLogOptionsBindingTests.AuditLog_Section_Binds_AllFields` — add `"InboundMaxBytes": 524288` to the JSON literal and a matching `Assert.Equal(524_288, opts.InboundMaxBytes)`.
**Step 2: Run tests — confirm they fail**
```
dotnet test tests/ScadaLink.AuditLog.Tests \
--filter "FullyQualifiedName~AuditLogOptionsValidatorTests|FullyQualifiedName~AuditLogOptionsBindingTests"
```
Expected: the new validator tests fail (no `InboundMaxBytes` property), the binding test fails (property does not bind).
**Step 3: Add the property**
In `AuditLogOptions.cs`, insert after `RetentionDays`:
```csharp
/// <summary>
/// Per-body byte ceiling applied to <see cref="AuditEvent.RequestSummary"/> and
/// <see cref="AuditEvent.ResponseSummary"/> for <see cref="AuditChannel.ApiInbound"/> rows
/// (default 1 MiB). The 8 KiB / 64 KiB default/error caps that apply to other channels
/// do not apply here — inbound traffic captures verbatim up to this ceiling and only
/// then sets <see cref="AuditEvent.PayloadTruncated"/>. See
/// <c>docs/plans/2026-05-23-inbound-api-full-response-audit-design.md</c>.
/// </summary>
public int InboundMaxBytes { get; set; } = 1_048_576;
```
**Step 4: Add the validator branch**
In `AuditLogOptionsValidator.cs`, add the constants beside the existing retention bounds:
```csharp
public const int MinInboundMaxBytes = 8_192;
public const int MaxInboundMaxBytes = 16_777_216;
```
Add the validation block inside `Validate` (after the retention check, before the `return`):
```csharp
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
```
**Step 5: Run tests — confirm they pass**
```
dotnet test tests/ScadaLink.AuditLog.Tests
```
Expected: all green, including the extended binding test and the new validator tests.
**Step 6: Build the whole solution**
```
dotnet build ScadaLink.slnx
```
Expected: 0 warnings, 0 errors.
**Step 7: Commit**
```
git add src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs \
src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs \
tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs \
tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs
git commit -m "feat(auditlog): add AuditLog:InboundMaxBytes option (default 1 MiB, [8 KiB, 16 MiB])"
```
---
## Task 2: Wire `InboundMaxBytes` into `DefaultAuditPayloadFilter` (TDD)
**What:** When `AuditEvent.Channel == AuditChannel.ApiInbound`, the filter selects `InboundMaxBytes` as the truncation cap instead of `DefaultCapBytes` / `ErrorCapBytes`. Redaction stages run unchanged.
**Files:**
- Modify: `src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs` — change the one cap-selection line.
- Test (new): `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs` — pin the new behaviour.
**Step 1: Write the failing tests**
Create `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs`:
```csharp
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Tests.Configuration; // for TestOptionsMonitor — confirm namespace via existing file
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Payload;
/// <summary>
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
/// ErrorCapBytes. Other channels keep the existing caps.
/// </summary>
public class InboundChannelCapTests
{
private static AuditEvent MakeInbound(
AuditStatus status,
string? request = null,
string? response = null) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = status == AuditStatus.Delivered
? AuditKind.InboundRequest
: AuditKind.InboundRequest,
Status = status,
RequestSummary = request,
ResponseSummary = response,
};
[Fact]
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
{
// Body well above the legacy 8 KiB default cap but under the 1 MiB
// inbound ceiling — must NOT truncate.
var body = new string('a', 100_000);
var opts = new AuditLogOptions(); // defaults
var filter = new DefaultAuditPayloadFilter(
new TestOptionsMonitor<AuditLogOptions>(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(body.Length, result.RequestSummary!.Length);
}
[Fact]
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
{
var body = new string('a', 100_000);
var opts = new AuditLogOptions();
var filter = new DefaultAuditPayloadFilter(
new TestOptionsMonitor<AuditLogOptions>(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
Assert.False(result.PayloadTruncated);
Assert.Equal(body.Length, result.ResponseSummary!.Length);
}
[Fact]
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
{
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
var oversized = new string('z', 50_000);
var filter = new DefaultAuditPayloadFilter(
new TestOptionsMonitor<AuditLogOptions>(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
}
[Fact]
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
{
// Regression guard: lifting the inbound cap MUST NOT change other
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
var opts = new AuditLogOptions();
var body = new string('a', 100_000);
var filter = new DefaultAuditPayloadFilter(
new TestOptionsMonitor<AuditLogOptions>(opts),
NullLogger<DefaultAuditPayloadFilter>.Instance);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
RequestSummary = body,
};
var result = filter.Apply(evt);
Assert.True(result.PayloadTruncated);
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
}
}
```
Verify the `TestOptionsMonitor<T>` helper lives in `tests/ScadaLink.AuditLog.Tests/`. Grep:
```
grep -rn "class TestOptionsMonitor" tests/ScadaLink.AuditLog.Tests
```
If its namespace differs from `ScadaLink.AuditLog.Tests.Configuration`, update the `using` accordingly.
**Step 2: Run tests — confirm they fail**
```
dotnet test tests/ScadaLink.AuditLog.Tests \
--filter "FullyQualifiedName~InboundChannelCapTests"
```
Expected: the inbound tests fail (filter still applies the 8 KiB cap to ApiInbound). The `ApiOutbound_StillUsesDefaultCap` test SHOULD pass even before the change — that's the regression baseline and stays green after.
**Step 3: Add the channel branch to the filter**
In `DefaultAuditPayloadFilter.cs`, replace the single line:
```csharp
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
```
with:
```csharp
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
// replay exactly what the caller sent and what we returned. Other channels
// keep the global 8 KiB / 64 KiB policy.
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
var cap = rawEvent.Channel == AuditChannel.ApiInbound
? opts.InboundMaxBytes
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
```
**Step 4: Run tests — confirm they pass**
```
dotnet test tests/ScadaLink.AuditLog.Tests
```
Expected: all green. The existing `FilterIntegrationTests`, `BodyRegexRedactionTests`, `RedactionSafetyNetTests`, `SqlParamRedactionTests`, etc., MUST stay passing — the change is channel-scoped and the non-inbound cases never see the new branch.
**Step 5: Build the whole solution**
```
dotnet build ScadaLink.slnx
```
Expected: 0 warnings.
**Step 6: Commit**
```
git add src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs \
tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs
git commit -m "feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows"
```
---
## Task 3: Capture the response body in `AuditWriteMiddleware` (TDD)
**What:** Implement the M5-deferred response-body capture. Wrap `HttpContext.Response.Body` with a buffering `MemoryStream` BEFORE `_next(ctx)`, restore + copy the buffered bytes back to the original stream AFTER the pipeline runs, then read the buffer as UTF-8 into `ResponseSummary` on the audit event. The `AuditEvent.ResponseSummary = null` line goes away.
**Files:**
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs`.
- Modify: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — extend.
**Step 1: Write the failing tests**
Append to `AuditWriteMiddlewareTests.cs` (before the closing class brace), keeping the existing `BuildContext` / `CreateMiddleware` helpers:
```csharp
// ---------------------------------------------------------------------
// 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);
}
```
**Step 2: Run tests — confirm they fail**
```
dotnet test tests/ScadaLink.InboundAPI.Tests \
--filter "FullyQualifiedName~AuditWriteMiddlewareTests"
```
Expected: the four new tests fail (ResponseSummary stays null today). Pre-existing tests still pass (they don't assert ResponseSummary).
**Step 3: Implement response capture in the middleware**
Edit `AuditWriteMiddleware.cs`:
(a) Update the XML doc at the top — remove the "Response body capture is deferred to M5…" paragraph (lines 42-50 in the current file). Replace with:
```csharp
/// <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. 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>
```
(b) Rewrite `InvokeAsync` so the response stream is swapped, the buffer is read post-pipeline (in `finally`, even on a thrown handler), and the original stream receives the bytes back:
```csharp
public async Task InvokeAsync(HttpContext ctx)
{
var sw = Stopwatch.StartNew();
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
// Request body — buffer for both audit + downstream handler.
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
{
await _next(ctx).ConfigureAwait(false);
}
catch (Exception ex)
{
thrown = ex;
throw;
}
finally
{
sw.Stop();
// 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);
}
}
```
(c) Add the new helper (place beside `ReadBufferedRequestBodyAsync`):
```csharp
/// <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;
}
```
(d) Change `EmitInboundAudit`'s signature to take the response body, and drop the `ResponseSummary = null` line:
```csharp
private void EmitInboundAudit(
HttpContext ctx,
long durationMs,
Exception? thrown,
string? requestBody,
string? responseBody)
{
// ... unchanged up to the AuditEvent constructor ...
var evt = new AuditEvent
{
// ... existing fields ...
RequestSummary = requestBody,
ResponseSummary = responseBody, // was null in the M4 deliverable
PayloadTruncated = false,
Extra = extra,
ForwardState = null,
};
// ... unchanged fire-and-forget write ...
}
```
**Step 4: Run tests — confirm they pass**
```
dotnet test tests/ScadaLink.InboundAPI.Tests
```
Expected: all green, including the four new response-body tests AND every pre-existing middleware test.
**Step 5: Build the whole solution**
```
dotnet build ScadaLink.slnx
```
Expected: 0 warnings.
**Step 6: Commit**
```
git add src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs \
tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
git commit -m "feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows"
```
---
## Task 4: Update `Component-AuditLog.md`
**What:** Reflect the inbound carve-out in the requirements doc — schema row descriptions + Payload Capture Policy.
**Files:**
- Modify: `docs/requirements/Component-AuditLog.md`.
**Edits (all in-place — no copies):**
1. Schema table — find the `RequestSummary` row. Change its `Description` cell from:
> Truncated request payload (configurable cap). Headers redacted.
to:
> Truncated request payload (configurable cap). Headers redacted. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) — see Payload Capture Policy.
2. Schema table — `ResponseSummary` row. Change its `Description` cell from:
> Truncated response payload. Full on errors.
to:
> Truncated response payload. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB). For other channels, capped at `DefaultCapBytes` by default and `ErrorCapBytes` on error rows.
3. **Payload Capture Policy** section — after the existing **Default cap** bullet, insert:
> - **Inbound API exception.** For `Channel = ApiInbound`, `RequestSummary` and `ResponseSummary` are captured in full up to a per-body hard ceiling of 1 MiB (configurable via `AuditLog:InboundMaxBytes`; default 1 048 576 bytes; min 8 192; max 16 777 216). The 8 KiB / 64 KiB default/error caps that apply to other channels do not apply here. `PayloadTruncated = 1` is set only when the inbound ceiling is hit — verbatim capture is the normal case. The ceiling applies independently to each body. Header redaction and per-target body redactors still run before persistence.
**Verify:** `git diff docs/requirements/Component-AuditLog.md` — three edits, no other lines touched.
**Commit:**
```
git add docs/requirements/Component-AuditLog.md
git commit -m "docs(audit): schema + Payload Capture Policy note inbound full-body carve-out"
```
---
## Task 5: Update `Component-InboundAPI.md`
**What:** Update the two existing references that say "truncated request/response bodies per the Audit Log capture policy" to reflect the new wording.
**Files:**
- Modify: `docs/requirements/Component-InboundAPI.md`.
**Edits:**
1. Around line 119 (the inbound-audit description in §Operational Audit / Logging) — change:
> Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and truncated request/response bodies per the Audit Log capture policy (see Component-AuditLog.md, Payload Capture Policy).
to:
> Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and the request/response bodies. Bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB); `PayloadTruncated = 1` only when that ceiling is hit. Header redaction and per-target body redactors still apply (see Component-AuditLog.md, Payload Capture Policy).
2. Around line 202 (Dependencies → Audit Log) — change:
> **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Payload truncation/redaction follows the Audit Log Payload Capture Policy.
to:
> **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Request and response bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) per the Audit Log Payload Capture Policy; redaction (headers + per-target body redactors) still applies before persistence.
**Verify:**
```
grep -nE "truncated request/response|InboundMaxBytes" docs/requirements/Component-InboundAPI.md
```
Expected: no "truncated request/response" hits remain; two "InboundMaxBytes" hits land on the updated lines.
**Commit:**
```
git add docs/requirements/Component-InboundAPI.md
git commit -m "docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes"
```
---
## Task 6: Final solution build + full test run + branch summary
**What:** Confirm the cumulative change is green end-to-end and summarise the branch.
**Steps:**
1. `dotnet build ScadaLink.slnx` from the repo root — expect 0 warnings, 0 errors.
2. Run the affected test projects:
```
dotnet test tests/ScadaLink.AuditLog.Tests
dotnet test tests/ScadaLink.InboundAPI.Tests
```
Expect both green. (The wider solution test run is optional but cheap — `dotnet test ScadaLink.slnx` if you want full coverage.)
3. `git log --oneline main..HEAD` — expect exactly five commits:
- feat(auditlog): add AuditLog:InboundMaxBytes option …
- feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows
- feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
- docs(audit): schema + Payload Capture Policy note inbound full-body carve-out
- docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes
4. `git status --short` — expect a clean tree (no uncommitted files; pre-existing uncommitted files from `git status` at session start may still be present and are unrelated to this work — leave them).
**Acceptance:**
- All acceptance criteria in `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md` met.
- Solution builds clean.
- All targeted tests pass.
- Five commits on the feature branch, no commits on `main`, branch not pushed.
**Commit:** none. Do not merge or push — that is the user's call.
---
## Notes for the executor
- The `ResponseSummary = null` comment in `AuditWriteMiddleware.cs` (line ~191 today) is the smoking-gun: response capture was always intended, just deferred. The design doc explicitly authorises closing that gap.
- The `TestOptionsMonitor<T>` helper is already used by `AuditLogOptionsBindingTests.cs` — reuse it; do not introduce a second one. If its public location moves between when this plan was written and execution, `grep -rn "class TestOptionsMonitor" tests/` and adjust the `using`.
- `appsettings.json` files in `docker/central-node-a` and `docker/central-node-b` do not currently override `AuditLog:*` — leave them alone. The 1 MiB default takes effect automatically from `AuditLogOptions`.
- No EF migration is needed — schema is unchanged (`nvarchar(max)` already).
- No new health metric — `PayloadTruncated = 1` carries the ceiling-hit signal. The design doc explicitly defers a dedicated `AuditInboundCeilingHits` counter.

View File

@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-05-23-inbound-api-full-response-audit.md",
"tasks": [
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "pending"},
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "pending", "blockedBy": [6]}
],
"lastUpdated": "2026-05-23"
}