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`,