fix(backend): key IdempotencyStore on caller + idem key (RB-18)

IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.

Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 18:27:52 +02:00
co-authored by Claude Opus 5
parent 7fbac8fca5
commit 4631556e68
5 changed files with 181 additions and 38 deletions
+4 -2
View File
@@ -987,12 +987,14 @@ void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, Brief
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference.
// — so a retried submit dedupes instead of minting a second reference. The key is
// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same
// client-chosen header value do not share a cached result.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString()
? $"{ctx.Caller().SubjectId}:{k}"
: null;
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
@@ -45,6 +45,31 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
}
// RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so
// caller B replaying caller A's Idempotency-Key got caller A's cached reference back —
// a cross-caller leak of a value caller B never submitted. The store now keys on
// "{SubjectId}:{idemKey}", so the same header value from two different callers is two
// independent submissions.
[Fact]
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
{
var sharedKey = Guid.NewGuid().ToString();
var callerARequest = ChangeRequestWithKey(sharedKey);
callerARequest.Headers.Add("X-Subject", "111222333");
var callerA = await _client.SendAsync(callerARequest);
callerA.EnsureSuccessStatusCode();
var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();
var callerBRequest = ChangeRequestWithKey(sharedKey);
callerBRequest.Headers.Add("X-Subject", "999888777");
var callerB = await _client.SendAsync(callerBRequest);
callerB.EnsureSuccessStatusCode();
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
}
[Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{