Merge RB-18 — key IdempotencyStore on caller plus idem key
BIO-018: the store was a process-global dictionary keyed on the client-supplied Idempotency-Key alone, so one caller could replay another caller's key and receive their cached response. The key is now scoped with the caller SubjectId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md # libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
@@ -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
|
// generated reference and the caller's correlation id (the observability seam — a
|
||||||
// real system ships this to structured logging / an audit store). A repeated
|
// real system ships this to structured logging / an audit store). A repeated
|
||||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
// 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)
|
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 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)
|
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||||
? k.ToString()
|
? $"{ctx.Caller().SubjectId}:{k}"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
|
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);
|
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]
|
[Fact]
|
||||||
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
|
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -100,41 +100,41 @@ deployed first_, not _must ship together_.
|
|||||||
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
|
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
|
||||||
16-row "Compliance review required" list, carries it — regardless of priority.
|
16-row "Compliance review required" list, carries it — regardless of priority.
|
||||||
|
|
||||||
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
|
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
|
||||||
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- |
|
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
|
||||||
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
|
| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
|
||||||
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
|
| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
|
| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
|
||||||
| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
|
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
|
||||||
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
|
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
|
||||||
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
||||||
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | implemented |
|
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
|
||||||
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
||||||
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
|
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
|
||||||
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
||||||
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
|
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
|
||||||
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
|
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
|
||||||
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
|
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
|
||||||
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
|
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open |
|
||||||
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
|
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
|
||||||
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
||||||
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
|
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
|
||||||
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
|
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
|
||||||
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
|
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}`
|
||||||
|
|
||||||
|
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 ·
|
||||||
|
`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"),
|
||||||
|
agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
`Data/IdempotencyStore.cs` is a process-global `Dictionary<string, IResult>` keyed only on
|
||||||
|
the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it
|
||||||
|
with that raw value, never composed with the caller's identity:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||||
|
? k.ToString()
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
The client picks the header value. Two different callers who happen to send the same
|
||||||
|
value shared one cache slot: the second caller's request short-circuited to the first
|
||||||
|
caller's cached `IResult` instead of running its own submission. BIO-018 rates this
|
||||||
|
**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or
|
||||||
|
a `ProblemDetails`, never personal data — but flags it as a defect in an access-control
|
||||||
|
path with a trivial fix.
|
||||||
|
|
||||||
|
**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/
|
||||||
|
write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before,
|
||||||
|
same file, unrelated change) shifted line numbers; the real call sites are
|
||||||
|
`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at
|
||||||
|
`:991`. The store file itself is untouched by RB-17 and matches the finding's shape
|
||||||
|
exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the
|
||||||
|
`ChangeRequestRequest` → `telefoonwijziging` endpoint — so the scoping change lands on a
|
||||||
|
single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to
|
||||||
|
produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used
|
||||||
|
this helper in the first place, so its ordering benefit does not change what this ticket
|
||||||
|
touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped
|
||||||
|
its residual to "this ticket is unaffected by this split beyond it now landing on a
|
||||||
|
correctly write-only call set" — true, and the call set was already this one endpoint
|
||||||
|
before and after RB-17.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 |
|
||||||
|
| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` |
|
||||||
|
|
||||||
|
`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in
|
||||||
|
`Program.cs` — `ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if
|
||||||
|
the identity middleware did not run, so this composition cannot silently fall back to an
|
||||||
|
unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a
|
||||||
|
`MedewerkerCaller`; either way it is stable per caller and never empty.
|
||||||
|
|
||||||
|
This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound,
|
||||||
|
no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment
|
||||||
|
("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory
|
||||||
|
leak at scale") is untouched — the store is still unbounded and still keyed on a
|
||||||
|
client-supplied string, only now composed with a server-resolved one first. The comment
|
||||||
|
stays accurate; this ticket did not touch the part it would need to correct.
|
||||||
|
|
||||||
|
## The test
|
||||||
|
|
||||||
|
`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with
|
||||||
|
three cases exercising same-caller replay/independence. Added a fourth:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a
|
||||||
|
test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and
|
||||||
|
caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`.
|
||||||
|
|
||||||
|
**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to
|
||||||
|
`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed
|
||||||
|
intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms]
|
||||||
|
Error Message:
|
||||||
|
Assert.NotEqual() Failure: Strings are equal
|
||||||
|
Expected: Not "BIG-2026-476969"
|
||||||
|
Actual: "BIG-2026-476969"
|
||||||
|
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Caller B received caller A's cached reference. Then reapplied the fix with a second
|
||||||
|
`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`dotnet test` (full suite): **261 passed, 1 failed** — the failure is
|
||||||
|
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
|
||||||
|
which needs a live OpenZaak container and fails identically on a stashed tree; it predates
|
||||||
|
this change and is not run by `npm run ci`.
|
||||||
|
|
||||||
|
`npm run ci` (foreground): green — see the commit's own record for the full step list.
|
||||||
@@ -21,7 +21,7 @@ tested where._
|
|||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 460 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 460 frontend behaviours across
|
||||||
9 contexts; 236 backend behaviours across 41 test
|
9 contexts; 237 backend behaviours across 41 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
## Frontend (by context)
|
## Frontend (by context)
|
||||||
@@ -1064,6 +1064,7 @@ classes.
|
|||||||
|
|
||||||
- Replaying the same idempotency key returns the same reference not a new one
|
- Replaying the same idempotency key returns the same reference not a new one
|
||||||
- Different idempotency keys are independent submissions
|
- Different idempotency keys are independent submissions
|
||||||
|
- A caller replaying another callers idempotency key does not get their cached result
|
||||||
- A rejected submission replays the same rejection not a retry
|
- A rejected submission replays the same rejection not a retry
|
||||||
|
|
||||||
### IntakeRuleTests
|
### IntakeRuleTests
|
||||||
|
|||||||
Reference in New Issue
Block a user