diff --git a/backend/src/BigRegister.Api/Data/IDocumentSource.cs b/backend/src/BigRegister.Api/Data/IDocumentSource.cs index 2338836..4c3e827 100644 --- a/backend/src/BigRegister.Api/Data/IDocumentSource.cs +++ b/backend/src/BigRegister.Api/Data/IDocumentSource.cs @@ -20,7 +20,7 @@ public interface IDocumentSource /// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT. UploadResponse Upload( string localId, string categoryId, string wizardId, string fileName, string contentType, - byte[] content, CallerIdentity caller); + byte[] content, ZorgverlenerCaller caller); /// Finalise a set of already-uploaded documents against a just-submitted aanvraag /// (WP-50/51): local behaviour is exactly today's DocumentStore.Link; the OpenZaak diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index ca64e37..fc0a844 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -28,7 +28,7 @@ public interface IZaakSource /// rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn query filter so a citizen /// can never see another citizen's zaken. /// - IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now); + IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is diff --git a/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs index c82c6a5..6b47384 100644 --- a/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs @@ -13,7 +13,7 @@ public sealed class LocalDocumentSource : IDocumentSource { public UploadResponse Upload( string localId, string categoryId, string wizardId, string fileName, string contentType, - byte[] content, CallerIdentity caller) + byte[] content, ZorgverlenerCaller caller) { var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn); return new UploadResponse(doc.DocumentId, doc.LocalId); diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index be4eadd..95624cd 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -16,7 +16,7 @@ public sealed class LocalZaakSource : IZaakSource /// Citizen-scoped (WP-53) — exactly what GET /applications used to compute /// inline before it was routed through this seam. - public IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ApplicationStore.List(caller.Bsn) .OrderByDescending(a => a.UpdatedAt) .Select(a => a.ToSummaryDto(now)).ToList(); diff --git a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs index 5dfbe8b..9410a6d 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs @@ -75,6 +75,16 @@ public static class Authz /// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision. public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin; + // --- Medewerker (backoffice) capabilities (WP-62, ADR-0002 §3) ------------------------------ + + /// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling, + /// WP-64/65)? Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false + /// regardless of X-Role, because the capability belongs to the medewerker actor kind, not to + /// the dev role stand-in. Shipped to a frontend only as a decision flag, never as a rollen + /// matrix (ADR-0001). + public static bool CanBeoordelen(CallerIdentity caller) => + caller is MedewerkerCaller m && m.Rollen.Contains(MedewerkerRol.Behandelaar); + /// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships /// masked by default; only the behandelaar (Drafter) composing the case — the actor /// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a diff --git a/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs b/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs index 9a459eb..4fdd775 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs @@ -1,14 +1,40 @@ namespace BigRegister.Domain.Authorization; /// -/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved -/// once per request by and stashed on -/// by the identity-resolution middleware (Program.cs, right after the correlation-id -/// middleware). Everything that used to hardcode DocumentStore.DemoOwner or the static -/// ZgwOptions.UserId/UserRepresentation claims now reads this instead — a production -/// (real OIDC/DigiD claims) swaps in without touching any consumer. +/// The two actor kinds a request can come from (WP-62, ADR-0002 §3): a +/// (citizen, WP-53 — subject BSN) or a (backoffice employee — no BSN, +/// has rollen). Resolved once per request by and stashed on +/// by the identity-resolution middleware (Program.cs, right +/// after the correlation-id middleware). Everything that used to hardcode DocumentStore.DemoOwner +/// or the static ZgwOptions.UserId/UserRepresentation claims now reads this instead — a +/// production (real DigiD claims / employee SSO claims) swaps in +/// without touching any consumer. /// -public sealed record CallerIdentity(string Bsn, string DisplayName, PrincipalRole Role); +public abstract record CallerIdentity(string DisplayName, PrincipalRole Role) +{ + /// Stable subject id for audit/claims only (the ZGW JWT's user_id) — the BSN or + /// the medewerkerId depending on the kind. Never use this as an ownership key: ownership scoping + /// requires a BSN, i.e. a (see HttpContext.Zorgverlener()). + public abstract string SubjectId { get; } +} + +public sealed record ZorgverlenerCaller(string Bsn, string DisplayName, PrincipalRole Role) + : CallerIdentity(DisplayName, Role) +{ + public override string SubjectId => Bsn; +} + +public sealed record MedewerkerCaller( + string MedewerkerId, IReadOnlyList Rollen, string DisplayName, PrincipalRole Role) + : CallerIdentity(DisplayName, Role) +{ + public override string SubjectId => MedewerkerId; +} + +/// Backoffice functions a medewerker holds (ADR-0002 §4: admin/auditor/institution-rep +/// slot in here as extra rollen, never as new CallerIdentity variants). Deliberately one member — +/// WP-65 adds the next one when a capability actually needs it. +public enum MedewerkerRol { Behandelaar } public static class CallerIdentityHttpContextExtensions { @@ -24,4 +50,13 @@ public static class CallerIdentityHttpContextExtensions ? identity : throw new InvalidOperationException( "No CallerIdentity resolved for this request — the identity middleware didn't run."); + + /// The citizen-scoped narrowing (WP-62): every SSP endpoint that scopes data by owner + /// needs a BSN, which only a zorgverlener has. Throws rather than silently degrading — no + /// medewerker reaches these endpoints today (the behandelportal calls its own endpoints, + /// WP-64+), so this is a loud "wrong actor kind" bug detector, not a user-facing path. + public static ZorgverlenerCaller Zorgverlener(this HttpContext ctx) => + ctx.Caller() as ZorgverlenerCaller + ?? throw new InvalidOperationException( + "This endpoint is citizen-scoped but the caller is not a zorgverlener."); } diff --git a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs index 497176f..5d4f047 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs @@ -1,9 +1,10 @@ namespace BigRegister.Domain.Authorization; /// -/// Resolves the acting for a request (WP-53) — the seam a real -/// OIDC/DigiD-backed provider replaces in production. is the -/// only implementation today. +/// Resolves the acting for a request (WP-53) — one of the two actor +/// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker +/// (real employee SSO/eHerkenning claims in production). is +/// the only implementation today. /// public interface IIdentityProvider { diff --git a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs index 69f519c..d3ba6ff 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs @@ -3,13 +3,16 @@ using BigRegister.Api.Data; namespace BigRegister.Domain.Authorization; /// -/// Dev stub (WP-53) — NOT a security boundary, same caveat as -/// (which this provider now backs). Role comes from the existing client-asserted X-Role header -/// (mirrors the FE's ?role= toggle); the subject BSN comes from a new X-Subject header, -/// defaulting to the single seeded citizen () so every -/// existing request — none of which send X-Subject — keeps behaving exactly as before this WP. -/// A real system builds this from verified AD/OIDC/DigiD claims; every consumer of -/// carries over unchanged once that swap happens. +/// Dev stub (WP-53, extended WP-62) — NOT a security boundary, same caveat as +/// (which this provider now backs). Role comes from the +/// existing client-asserted X-Role header (mirrors the FE's ?role= toggle) and applies to +/// either actor kind. Presence of X-Medewerker selects a (id + +/// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today — +/// falls through to the WP-53 path unchanged: subject BSN from +/// X-Subject, defaulting to the single seeded citizen (). +/// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims +/// (medewerker); every consumer of carries over unchanged once that +/// swap happens. /// public sealed class StubIdentityProvider : IIdentityProvider { @@ -21,12 +24,34 @@ public sealed class StubIdentityProvider : IIdentityProvider "admin" => PrincipalRole.Admin, _ => PrincipalRole.Drafter, }; + + var medewerkerId = ctx.Request.Headers["X-Medewerker"].ToString(); + if (!string.IsNullOrEmpty(medewerkerId)) + return new MedewerkerCaller(medewerkerId, ParseRollen(ctx), medewerkerId, role); + var bsn = ctx.Request.Headers.TryGetValue("X-Subject", out var v) && !string.IsNullOrEmpty(v) ? v.ToString() : DocumentStore.DemoOwner; // Only one seeded citizen exists in this POC — a real provider carries the display name in // the verified claims themselves, so there's no "look up a name by BSN" step to stand in for. var displayName = bsn == DocumentStore.DemoOwner ? SeedData.Registration.Naam : bsn; - return new CallerIdentity(bsn, displayName, role); + return new ZorgverlenerCaller(bsn, displayName, role); } + + /// X-Rollen absent → the useful default (behandelaar), mirroring how X-Subject defaults to the + /// seeded citizen: one header is enough to be a working backoffice caller. Present → parsed, + /// unrecognised tokens dropped (so `X-Rollen: geen` is how you exercise a deny path). + private static IReadOnlyList ParseRollen(HttpContext ctx) + { + var raw = ctx.Request.Headers["X-Rollen"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) return new[] { MedewerkerRol.Behandelaar }; + return raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(ToRol).Where(r => r is not null).Select(r => r!.Value).Distinct().ToList(); + } + + private static MedewerkerRol? ToRol(string token) => token.ToLowerInvariant() switch + { + "behandelaar" => MedewerkerRol.Behandelaar, + _ => null, + }; } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 5623f6e..129dd75 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -43,10 +43,11 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => // override it (ConnectionStrings:AppDb) without touching this file. Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; -// WP-53: the per-request acting citizen — resolved once (middleware, below) into -// HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and -// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today -// (X-Role/X-Subject headers); a real OIDC/DigiD provider swaps in without touching a consumer. +// WP-53 (extended WP-62): the per-request acting caller — resolved once (middleware, below) +// into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and +// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/ +// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real +// DigiD/employee-SSO provider swaps in without touching a consumer. builder.Services.AddSingleton(); // WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend @@ -228,7 +229,7 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. - var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Caller()); + var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener()); return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); }) .ExcludeFromDescription(); @@ -258,7 +259,7 @@ api.MapGet("/uploads/status", (string? localIds) => // User delete: owner-scoped; 409 once linked to a finalised submission. api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => - DocumentStore.DeleteOwned(documentId, ctx.Caller().Bsn) switch + DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch { DocumentStore.DeleteResult.Ok => Results.NoContent(), DocumentStore.DeleteResult.Linked => Results.Problem( @@ -286,10 +287,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap // openzaak-integration.md's ACL caveat used to flag for this endpoint. api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) => - zaken.ListMyCases(ctx.Caller(), DateTimeOffset.UtcNow)); + zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow)); api.MapGet("/applications/{id}", (string id, HttpContext ctx) => - ApplicationStore.Get(id, ctx.Caller().Bsn) is { } a + ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) : Results.NotFound()) .Produces() @@ -300,7 +301,7 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => // Feature flag (WP-47): self-service registration can be closed by an admin. if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden); - var a = ApplicationStore.CreateConcept(req.Type, ctx.Caller().Bsn); + var a = ApplicationStore.CreateConcept(req.Type, ctx.Zorgverlener().Bsn); if (a is null) return Results.Problem( detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", @@ -312,7 +313,7 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => - ApplicationStore.SyncDraft(id, ctx.Caller().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) + ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) ? Results.NoContent() : Results.NotFound()) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); @@ -321,11 +322,11 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c // be withdrawn (out of scope — no "intrekken"). api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => { - var a = ApplicationStore.Get(id, ctx.Caller().Bsn); + var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (a is null) return Results.NotFound(); if (a.Submitted) return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); - ApplicationStore.Delete(id, ctx.Caller().Bsn); + ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn); return Results.NoContent(); }) .Produces(StatusCodes.Status204NoContent) @@ -336,7 +337,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => { - var existing = ApplicationStore.Get(id, ctx.Caller().Bsn); + var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (existing is null) return Results.NotFound(); if (existing.Submitted) return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); @@ -351,7 +352,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re var docs = req.Documents; var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); - var submitted = ApplicationStore.Submit(id, ctx.Caller().Bsn, reject, autoApprovable, documentIds); + var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds); if (submitted is null) return Results.Conflict(); app.Logger.LogInformation( @@ -483,7 +484,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon api.MapGet("/brief", (HttpContext ctx) => { - var e = BriefStore.GetOrCreate(ctx.Caller().Bsn); + var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); return ToView(ctx, e); }) .Produces(); @@ -491,7 +492,7 @@ api.MapGet("/brief", (HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; - return BriefResult(ctx, BriefStore.Save(ctx.Caller().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); + return BriefResult(ctx, BriefStore.Save(ctx.Zorgverlener().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); }) .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -500,7 +501,7 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPost("/brief/submit", (HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; - var r = BriefStore.Submit(ctx.Caller().Bsn, isDrafter, Now()); + var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now()); LogBrief("submit", r); return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); }) @@ -511,7 +512,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) => { - var r = BriefStore.Approve(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), Now()); + var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now()); LogBrief("approve", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) @@ -521,7 +522,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => { - var r = BriefStore.Reject(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); + var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); LogBrief("reject", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) @@ -534,7 +535,7 @@ api.MapPost("/brief/send", (HttpContext ctx) => // Send-time placeholder linting is FE-authoritative in this slice (no C# parity // port); the backend only guards the approved→sent transition (not role-gated // today — see Authz.CanActOn(Send, …), a mechanical dispatch step). - var r = BriefStore.Send(ctx.Caller().Bsn, Now()); + var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now()); LogBrief("send", r); return BriefResult(ctx, r, "Versturen kan niet in deze status."); }) @@ -552,7 +553,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => var canReveal = Authz.CanRevealBigNummer(principal); var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var allowed = canReveal && steppedUp; - AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Caller().Bsn, allowed, principal); + AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal); if (!allowed) return Results.Problem( detail: canReveal @@ -571,7 +572,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => // letters serve their frozen archive; anything else renders live with a watermark. api.MapGet("/brief/preview", (HttpContext ctx) => { - var e = BriefStore.GetOrCreate(ctx.Caller().Bsn); + var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) return Results.Content(archived, "text/html"); var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); @@ -593,7 +594,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte api.MapPost("/brief/reset", (HttpContext ctx) => { // Demo "start over": recreate a fresh draft. No guards — showcase affordance only. - var e = BriefStore.ResetAndCreate(ctx.Caller().Bsn); + var e = BriefStore.ResetAndCreate(ctx.Zorgverlener().Bsn); return ToView(ctx, e); }) .WithName("briefReset") diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs index 47a22a4..65bb715 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs @@ -40,7 +40,7 @@ public sealed class OpenZaakDocumentSource( // existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource. public UploadResponse Upload( string localId, string categoryId, string wizardId, string fileName, string contentType, - byte[] content, CallerIdentity caller) => + byte[] content, ZorgverlenerCaller caller) => UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller) .GetAwaiter().GetResult(); @@ -52,7 +52,7 @@ public sealed class OpenZaakDocumentSource( // "Write resilience" section for why the two write paths differ). private async Task UploadAsync( string localId, string categoryId, string wizardId, string fileName, string contentType, - byte[] content, CallerIdentity caller) + byte[] content, ZorgverlenerCaller caller) { var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn); diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index 5903a4b..c638fb4 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -38,7 +38,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, /// WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param /// (see ) — and minted with that citizen's identity, not the /// system-level one uses. - public IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult(); private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller) diff --git a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs index b321f1e..ddaf95c 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs @@ -23,11 +23,13 @@ public sealed class ZgwTokenProvider(ZgwOptions options) /// specific citizen (e.g. the admin cross-owner ListCases). public string Mint() => MintCore(options.UserId, options.UserRepresentation); - /// Per-request variant (WP-53): the ZGW audit trail (user_id/ - /// user_representation) reflects the acting citizen instead of this BFF's static - /// config identity, for any call made on a specific citizen's behalf (create zaak, upload, - /// link, citizen-scoped list). - public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName); + /// Per-request variant (WP-53, extended WP-62): the ZGW audit trail (user_id/ + /// user_representation) reflects the acting caller instead of this BFF's static + /// config identity, for any call made on a specific caller's behalf (create zaak, upload, + /// link, citizen-scoped list). is the BSN for a + /// zorgverlener or the medewerkerId for a medewerker (WP-66 mints this for a besluit write + /// the same way, with no further change needed here). + public string Mint(CallerIdentity caller) => MintCore(caller.SubjectId, caller.DisplayName); private string MintCore(string userId, string userRepresentation) { diff --git a/backend/tests/BigRegister.Tests/AuthzTests.cs b/backend/tests/BigRegister.Tests/AuthzTests.cs index 3fd50eb..2b2241c 100644 --- a/backend/tests/BigRegister.Tests/AuthzTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzTests.cs @@ -81,4 +81,30 @@ public class AuthzTests Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer); Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer); } + + // --- CanBeoordelen (WP-62) -------------------------------------------------------------- + + [Fact] + public void CanBeoordelen_true_for_a_medewerker_with_the_behandelaar_rol() + { + var medewerker = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter); + Assert.True(Authz.CanBeoordelen(medewerker)); + } + + [Fact] + public void CanBeoordelen_false_for_a_medewerker_without_it() + { + var medewerker = new MedewerkerCaller("m.jansen", [], "M. Jansen", PrincipalRole.Drafter); + Assert.False(Authz.CanBeoordelen(medewerker)); + } + + [Theory] + [InlineData(PrincipalRole.Drafter)] + [InlineData(PrincipalRole.Approver)] + [InlineData(PrincipalRole.Admin)] + public void CanBeoordelen_false_for_a_zorgverlener_regardless_of_role(PrincipalRole role) + { + var zorgverlener = new ZorgverlenerCaller("111222333", "Dr. Test", role); + Assert.False(Authz.CanBeoordelen(zorgverlener)); + } } diff --git a/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs index 0f24b91..a90fd73 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs @@ -28,7 +28,7 @@ public class OpenZaakDocumentSourceTests InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl }, }; - private static readonly CallerIdentity Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter); + private static readonly ZorgverlenerCaller Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter); [Fact] public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally() diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs index de997f4..15588d2 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -71,7 +71,7 @@ public class OpenZaakZaakSourceTests var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); - var caller = new CallerIdentity("111222333", "Dr. Test", PrincipalRole.Drafter); + var caller = new ZorgverlenerCaller("111222333", "Dr. Test", PrincipalRole.Drafter); source.ListMyCases(caller, DateTimeOffset.UtcNow); @@ -123,7 +123,7 @@ public class OpenZaakZaakSourceTests Referentie = "BIG-2026-000123", }; - var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); + var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller); Assert.Equal("BIG-2026-000123", referentie); @@ -156,7 +156,7 @@ public class OpenZaakZaakSourceTests var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" }; - var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); + var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller)); } @@ -177,7 +177,7 @@ public class OpenZaakZaakSourceTests ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl }, }; var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" }; - var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); + var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter); return (options, aanvraag, caller); } diff --git a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs index b12f08c..529a154 100644 --- a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs +++ b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs @@ -4,23 +4,30 @@ using Microsoft.AspNetCore.Http; namespace BigRegister.Tests; -/// WP-53: the dev stub identity provider — role from X-Role (unchanged behaviour), subject BSN -/// from the new X-Subject header, defaulting to the single seeded citizen so every existing -/// request (none of which send X-Subject) resolves exactly as before this WP. +/// WP-53 (extended WP-62): the dev stub identity provider — role from X-Role (unchanged +/// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the +/// single seeded citizen so every existing request (none of which send X-Subject) resolves +/// exactly as before this WP. X-Medewerker (+ X-Rollen) selects the medewerker actor kind. public class StubIdentityProviderTests { - private static CallerIdentity Resolve(string? role, string? subject) + private static CallerIdentity Resolve( + string? role = null, string? subject = null, string? medewerker = null, string? rollen = null) { var ctx = new DefaultHttpContext(); if (role is not null) ctx.Request.Headers["X-Role"] = role; if (subject is not null) ctx.Request.Headers["X-Subject"] = subject; + if (medewerker is not null) ctx.Request.Headers["X-Medewerker"] = medewerker; + if (rollen is not null) ctx.Request.Headers["X-Rollen"] = rollen; return new StubIdentityProvider().Resolve(ctx); } + private static ZorgverlenerCaller ResolveZorgverlener(string? role = null, string? subject = null) => + Assert.IsType(Resolve(role, subject)); + [Fact] public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter() { - var caller = Resolve(role: null, subject: null); + var caller = ResolveZorgverlener(role: null, subject: null); Assert.Equal(DocumentStore.DemoOwner, caller.Bsn); Assert.Equal(PrincipalRole.Drafter, caller.Role); } @@ -37,7 +44,53 @@ public class StubIdentityProviderTests [Fact] public void X_subject_overrides_the_default_bsn() { - var caller = Resolve(role: null, subject: "999888777"); + var caller = ResolveZorgverlener(role: null, subject: "999888777"); Assert.Equal("999888777", caller.Bsn); } + + [Fact] + public void No_headers_resolves_a_zorgverlener_kind() + { + Assert.IsType(Resolve()); + } + + [Fact] + public void X_medewerker_resolves_a_medewerker_with_the_default_behandelaar_rol() + { + var caller = Assert.IsType(Resolve(medewerker: "m.jansen")); + Assert.Equal("m.jansen", caller.MedewerkerId); + Assert.Equal("m.jansen", caller.SubjectId); + Assert.Contains(MedewerkerRol.Behandelaar, caller.Rollen); + } + + [Fact] + public void X_medewerker_takes_precedence_over_x_subject() + { + var caller = Resolve(subject: "999888777", medewerker: "m.jansen"); + Assert.IsType(caller); + } + + [Fact] + public void Empty_x_medewerker_falls_through_to_the_zorgverlener_default() + { + var caller = Assert.IsType(Resolve(medewerker: "")); + Assert.Equal(DocumentStore.DemoOwner, caller.Bsn); + } + + [Theory] + [InlineData("behandelaar", new[] { MedewerkerRol.Behandelaar })] + [InlineData("Behandelaar, behandelaar", new[] { MedewerkerRol.Behandelaar })] + [InlineData("geen", new MedewerkerRol[0])] + public void X_rollen_parses_known_tokens_and_drops_unknown_ones(string rollen, MedewerkerRol[] expected) + { + var caller = Assert.IsType(Resolve(medewerker: "m.jansen", rollen: rollen)); + Assert.Equal(expected, caller.Rollen); + } + + [Fact] + public void X_role_still_applies_to_a_medewerker() + { + var caller = Resolve(role: "admin", medewerker: "m.jansen"); + Assert.Equal(PrincipalRole.Admin, caller.Role); + } } diff --git a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs index 30b4e0a..fe584b5 100644 --- a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs +++ b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs @@ -46,7 +46,7 @@ public class ZgwTokenProviderTests [Fact] public void Mint_with_a_caller_carries_that_citizen_not_the_static_config_identity() { - var caller = new CallerIdentity("111222333", "Dr. Citizen", PrincipalRole.Drafter); + var caller = new ZorgverlenerCaller("111222333", "Dr. Citizen", PrincipalRole.Drafter); var token = new ZgwTokenProvider(Options).Mint(caller); var payload = JsonSerializer.Deserialize(Decode(token.Split('.')[1])); @@ -56,6 +56,19 @@ public class ZgwTokenProviderTests Assert.Equal("big-register", payload.GetProperty("client_id").GetString()); } + [Fact] + public void Mint_with_a_medewerker_caller_uses_the_medewerkerId_as_user_id() + { + // WP-62: SubjectId is what ZgwTokenProvider.Mint reads — a medewerker's is its + // medewerkerId, not a BSN, and this is the only place that's directly observable. + var caller = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter); + var token = new ZgwTokenProvider(Options).Mint(caller); + + var payload = JsonSerializer.Deserialize(Decode(token.Split('.')[1])); + Assert.Equal("m.jansen", payload.GetProperty("user_id").GetString()); + Assert.Equal("M. Jansen", payload.GetProperty("user_representation").GetString()); + } + [Fact] public void Signature_verifies_with_the_shared_secret() { diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index e55c45c..b9bfc9c 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -111,8 +111,8 @@ for its existing violations, so every WP ends green. | [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done | | [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done | | [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done | -| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | todo | -| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | todo | +| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done | +| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done | | [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | todo | | [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | todo | | [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | todo | diff --git a/docs/project/backlog/WP-62-medewerker-identity-authz.md b/docs/project/backlog/WP-62-medewerker-identity-authz.md index ac52ef4..2f47408 100644 --- a/docs/project/backlog/WP-62-medewerker-identity-authz.md +++ b/docs/project/backlog/WP-62-medewerker-identity-authz.md @@ -1,6 +1,6 @@ # WP-62 — Backend: medewerker caller identity + authz seam -Status: todo +Status: done Phase: 11 — Behandelportal ## Why @@ -50,17 +50,17 @@ SSO — out of scope per CLAUDE.md, same as DigiD). ## Acceptance criteria -- [ ] `CallerIdentity` represents both actor kinds without breaking any existing +- [x] `CallerIdentity` represents both actor kinds without breaking any existing zorgverlener call site (WP-53's tests still green). -- [ ] A stub medewerker identity resolves from a request header, mirroring the existing +- [x] A stub medewerker identity resolves from a request header, mirroring the existing citizen stub. -- [ ] At least one capability flag (`canBeoordelen`) computable for a medewerker +- [x] At least one capability flag (`canBeoordelen`) computable for a medewerker identity, unit-tested. ## Verification `cd backend && dotnet test` (existing WP-53 tests unaffected + new medewerker tests -green). +green) — 182/182 (168 baseline + 14 new). `dotnet format --verify-no-changes` clean. ## Out of scope @@ -71,3 +71,26 @@ Any actual backoffice endpoint using this (WP-64+); real employee SSO/eHerkennin If the union is modeled as a bolt-on rather than replacing the flat type, existing zorgverlener call sites could break — mitigated by keeping WP-53's existing tests as a regression gate. + +## Outcome notes + +- **The `Files` list undersold the blast radius.** `CallerIdentity` became `abstract` + with two derived records (`ZorgverlenerCaller`, `MedewerkerCaller`), which is a hard + compile error at every `new CallerIdentity(...)` and every `.Bsn` read outside + `Domain/Authorization/` — 17 `ctx.Caller().Bsn` reads in `Program.cs` alone, plus 6 + seam signatures (`IDocumentSource.Upload`, `IZaakSource.ListMyCases` and their + Local/OpenZaak implementations) narrowed to `ZorgverlenerCaller` where `.Bsn` is used + as an ownership key, plus test fixtures in 4 test files. + `CallerIdentity.SubjectId` (BSN or medewerkerId) is the trick that kept the + token-mint-only call sites (`ZgwTokenProvider.Mint`, `ZgwHttpClient`, `IZaakSource +.CreateZaak`, `IDocumentSource.LinkToZaak`) compiling with zero signature changes — + they never needed the BSN specifically, just _an_ id for the ZGW audit trail. +- **`Role` (`PrincipalRole`, the existing dev-role stand-in) stays on the base record**, + not per-variant — it's an orthogonal axis (both actor kinds can be any dev role), + which is why `Authz.ResolvePrincipal(ctx) => new(ctx.Caller().Role)` and its ~15 + call sites needed no changes at all. +- **A new extension, `ctx.Zorgverlener()`**, narrows `CallerIdentity` to + `ZorgverlenerCaller` or throws — deliberately a 500, not a 403, since no medewerker + reaches any SSP endpoint today (nothing sends `X-Medewerker` yet). WP-64 should + map this to a 403 once real backoffice traffic exists; flagging it now so it isn't + mistaken for an oversight. diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index b8acea9..9629723 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -229,6 +229,22 @@ CallerIdentity.cs`): instead of calling `ApplicationStore` directly — the last "reads a static store directly" gap the ACL caveat below used to flag for a citizen-facing endpoint. +**WP-62 split `CallerIdentity` into the two actor kinds ADR-0002 §3 requires** — a +`ZorgverlenerCaller` (citizen, the WP-53 shape above) or a `MedewerkerCaller` (backoffice +employee: `MedewerkerId` + `Rollen`, no BSN), backend-only, unused by any frontend until WP-64. +`StubIdentityProvider` selects the medewerker kind when `X-Medewerker` is present (its value is +the medewerkerId; `X-Rollen` is a comma-separated rollen list, defaulting to `Behandelaar`) — +takes precedence over `X-Subject`; absent, every request today, falls through to the +zorgverlener path unchanged. `CallerIdentity.SubjectId` (BSN or medewerkerId) is what +`ZgwTokenProvider.Mint` now reads instead of `.Bsn` directly, so the ZGW JWT's `user_id` is +correct for either kind with no further change (WP-66's besluit write mints this for free). The +ownership-scoping seams (`ctx.Zorgverlener()`, `IDocumentSource.Upload`, `IZaakSource +.ListMyCases`) are narrowed to `ZorgverlenerCaller` — a medewerker hitting a citizen-scoped SSP +endpoint is a 500 today (unreachable, since no consumer sends `X-Medewerker` yet; WP-64 upgrades +it to a 403 once real backoffice traffic exists). `Authz.CanBeoordelen(CallerIdentity)` is the +first medewerker capability (rol-based, `MedewerkerRol.Behandelaar`), shipped only as a decision +flag, never a rollen matrix. + ## The five ZGW APIs (context for later slices) | API | Component | Used by | diff --git a/docs/reference/roles-and-access.md b/docs/reference/roles-and-access.md index e8c3fc2..06e58c2 100644 --- a/docs/reference/roles-and-access.md +++ b/docs/reference/roles-and-access.md @@ -32,6 +32,18 @@ Both are wired only under `isDevMode()` — they do not exist in a production bu Mechanism: `src/app/shared/infrastructure/role.ts` reads the role and the HTTP interceptor stamps it as an `X-Role` header on role-aware requests; the backend resolves it into a `Principal`. +## Actor kinds (backend, WP-62) + +`X-Role`/`Principal` above is a coarse role that applies to **either** of two actor kinds the +backend now models (ADR-0002 §3): a **zorgverlener** (this SSP's citizen — has a BSN) or a +**medewerker** (backoffice employee — no BSN, has `Rollen`). `StubIdentityProvider` picks the +medewerker kind from a dev header, `X-Medewerker` (+ `X-Rollen`), mirroring `X-Role`/`X-Subject` +above — **the SSP's FE never sends either header**; they exist only for the backend's own tests +and for the behandelportal (WP-64+) to use later. `Authz.CanBeoordelen(caller)` is the first +medewerker capability — a rol-based decision flag (`MedewerkerRol.Behandelaar`), not a role +entry on `/me`, since `/me`'s `RoleCapabilities` is keyed on `Principal` and can't see the actor +kind. + ## What each role unlocks Capabilities are resolved server-side (`backend/src/BigRegister.Api/Domain/Authorization/Authz.cs`,