feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
Replaces the hardcoded DocumentStore.DemoOwner and the static ZgwOptions
UserId/UserRepresentation with one per-request CallerIdentity, resolved by a
pluggable IIdentityProvider (StubIdentityProvider reads X-Role/X-Subject
today; a real OIDC/DigiD provider swaps in without touching any consumer).
- Domain/Authorization/{CallerIdentity,IIdentityProvider,StubIdentityProvider}.cs
+ a resolution middleware in Program.cs, right after correlation-id.
- Authz.ResolvePrincipal(ctx) keeps its signature (now reads ctx.Caller().Role),
so its ~15 call sites needed no changes.
- Every endpoint that passed DocumentStore.DemoOwner to a store now passes
ctx.Caller().Bsn.
- ZgwTokenProvider gains Mint(CallerIdentity) alongside the original Mint()
(kept for calls not tied to one citizen); ZgwHttpClient threads an optional
caller through to pick the right overload.
- IZaakSource gains ListMyCases(caller, now) — the citizen-scoped read
OpenZaakZaakSource backs with ZGW's rol__...__inpBsn filter. GET /applications
now routes through it instead of ApplicationStore directly, closing the last
"reads a static store" gap for a citizen-facing endpoint.
Backend 159/159 tests (+8, incl. an HTTP-level two-identity scoping proof),
npm run ci green, no api-client drift.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,41 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
}
|
||||
|
||||
// --- WP-53: citizen-scoping — GET /applications must never leak across identities. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Applications_are_scoped_to_the_caller_bsn()
|
||||
{
|
||||
var mine = await Create("intake");
|
||||
|
||||
var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/applications")
|
||||
{
|
||||
Content = JsonContent.Create(new { type = "intake" }),
|
||||
Headers = { { "X-Subject", "999888777" } },
|
||||
};
|
||||
var otherRes = await _client.SendAsync(createOther);
|
||||
Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode);
|
||||
var other = (await otherRes.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||
|
||||
try
|
||||
{
|
||||
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
|
||||
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
||||
Assert.Contains(theirCases, c => c.Id == other.Id);
|
||||
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
|
||||
|
||||
var myCases = (await List())!;
|
||||
Assert.Contains(myCases, c => c.Id == mine.Id);
|
||||
Assert.DoesNotContain(myCases, c => c.Id == other.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
|
||||
await _client.SendAsync(deleteOther);
|
||||
await _client.DeleteAsync($"/api/v1/applications/{mine.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||
|
||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||
|
||||
Reference in New Issue
Block a user