feat(behandelportal): WP-62 medewerker caller identity + authz seam

Splits backend CallerIdentity into the two ADR-0002 §3 actor kinds
(ZorgverlenerCaller/MedewerkerCaller), a stub X-Medewerker/X-Rollen header
path mirroring WP-53's citizen stub, and Authz.CanBeoordelen as the first
medewerker capability — backend-only, no consumer until WP-64. Also fixes
the backlog README's stale WP-61 status (done, but table said todo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-31 23:47:43 +02:00
co-authored by Claude Sonnet 5
parent e4ea75414b
commit a09c4ed87b
21 changed files with 289 additions and 72 deletions
@@ -20,7 +20,7 @@ public interface IDocumentSource
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, CallerIdentity caller);
byte[] content, ZorgverlenerCaller caller);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
@@ -28,7 +28,7 @@ public interface IZaakSource
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
/// can never see another citizen's zaken.
/// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now);
IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
@@ -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);
@@ -16,7 +16,7 @@ public sealed class LocalZaakSource : IZaakSource
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute
/// inline before it was routed through this seam.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
@@ -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
@@ -1,14 +1,40 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved
/// once per request by <see cref="IIdentityProvider"/> and stashed on <see cref="HttpContext.Items"/>
/// by the identity-resolution middleware (<c>Program.cs</c>, right after the correlation-id
/// middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> or the static
/// <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a production
/// <see cref="IIdentityProvider"/> (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 <see cref="ZorgverlenerCaller"/>
/// (citizen, WP-53 — subject BSN) or a <see cref="MedewerkerCaller"/> (backoffice employee — no BSN,
/// has rollen). Resolved once per request by <see cref="IIdentityProvider"/> and stashed on
/// <see cref="HttpContext.Items"/> by the identity-resolution middleware (<c>Program.cs</c>, right
/// after the correlation-id middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c>
/// or the static <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a
/// production <see cref="IIdentityProvider"/> (real DigiD claims / employee SSO claims) swaps in
/// without touching any consumer.
/// </summary>
public sealed record CallerIdentity(string Bsn, string DisplayName, PrincipalRole Role);
public abstract record CallerIdentity(string DisplayName, PrincipalRole Role)
{
/// <summary>Stable subject id for audit/claims only (the ZGW JWT's <c>user_id</c>) — 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 cref="ZorgverlenerCaller"/> (see <c>HttpContext.Zorgverlener()</c>).</summary>
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<MedewerkerRol> Rollen, string DisplayName, PrincipalRole Role)
: CallerIdentity(DisplayName, Role)
{
public override string SubjectId => MedewerkerId;
}
/// <summary>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.</summary>
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.");
/// <summary>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.</summary>
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.");
}
@@ -1,9 +1,10 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — the seam a real
/// OIDC/DigiD-backed provider replaces in production. <see cref="StubIdentityProvider"/> is the
/// only implementation today.
/// Resolves the acting <see cref="CallerIdentity"/> 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). <see cref="StubIdentityProvider"/> is
/// the only implementation today.
/// </summary>
public interface IIdentityProvider
{
@@ -3,13 +3,16 @@ using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Dev stub (WP-53) — NOT a security boundary, same caveat as <see cref="Authz.ResolvePrincipal"/>
/// (which this provider now backs). Role comes from the existing client-asserted X-Role header
/// (mirrors the FE's <c>?role=</c> toggle); the subject BSN comes from a new X-Subject header,
/// defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>) 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
/// <see cref="CallerIdentity"/> carries over unchanged once that swap happens.
/// Dev stub (WP-53, extended WP-62) — NOT a security boundary, same caveat as
/// <see cref="Authz.ResolvePrincipal"/> (which this provider now backs). Role comes from the
/// existing client-asserted X-Role header (mirrors the FE's <c>?role=</c> toggle) and applies to
/// either actor kind. Presence of X-Medewerker selects a <see cref="MedewerkerCaller"/> (id +
/// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today —
/// falls through to the WP-53 <see cref="ZorgverlenerCaller"/> path unchanged: subject BSN from
/// X-Subject, defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>).
/// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims
/// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that
/// swap happens.
/// </summary>
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<MedewerkerRol> 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,
};
}
+24 -23
View File
@@ -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<IIdentityProvider, StubIdentityProvider>();
// 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<ApplicationDetailDto>()
@@ -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<BriefViewDto>();
@@ -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<BriefViewDto>()
.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")
@@ -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<UploadResponse> 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);
@@ -38,7 +38,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller)
@@ -23,11 +23,13 @@ public sealed class ZgwTokenProvider(ZgwOptions options)
/// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
public string Mint() => MintCore(options.UserId, options.UserRepresentation);
/// <summary>Per-request variant (WP-53): the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) 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).</summary>
public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName);
/// <summary>Per-request variant (WP-53, extended WP-62): the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) 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). <see cref="CallerIdentity.SubjectId"/> 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).</summary>
public string Mint(CallerIdentity caller) => MintCore(caller.SubjectId, caller.DisplayName);
private string MintCore(string userId, string userRepresentation)
{
@@ -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));
}
}
@@ -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()
@@ -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<InvalidOperationException>(() => 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);
}
@@ -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<ZorgverlenerCaller>(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<ZorgverlenerCaller>(Resolve());
}
[Fact]
public void X_medewerker_resolves_a_medewerker_with_the_default_behandelaar_rol()
{
var caller = Assert.IsType<MedewerkerCaller>(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<MedewerkerCaller>(caller);
}
[Fact]
public void Empty_x_medewerker_falls_through_to_the_zorgverlener_default()
{
var caller = Assert.IsType<ZorgverlenerCaller>(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<MedewerkerCaller>(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);
}
}
@@ -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<JsonElement>(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<JsonElement>(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()
{
+2 -2
View File
@@ -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 |
@@ -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.
+16
View File
@@ -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 |
+12
View File
@@ -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`,