Files
scadalink-design/docs/plans/2026-05-23-inbound-api-full-response-audit.md
Joseph Doherty 441ec087a7 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.
2026-05-23 05:36:08 -04:00

30 KiB

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):

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:

/// <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:

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):

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:

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:

var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;

with:

// 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:

// ---------------------------------------------------------------------
// 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:

/// <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:

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):

/// <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:

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.