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> /// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload( UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, 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 /// <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 /// (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 /// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
/// can never see another citizen's zaken. /// can never see another citizen's zaken.
/// </summary> /// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now); IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary> /// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is /// 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( public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, 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); var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
return new UploadResponse(doc.DocumentId, doc.LocalId); 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 /// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute
/// inline before it was routed through this seam.</summary> /// 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) ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt) .OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList(); .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. /// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision.
public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin; 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 /// 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 /// 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 /// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
@@ -1,14 +1,40 @@
namespace BigRegister.Domain.Authorization; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved /// The two actor kinds a request can come from (WP-62, ADR-0002 §3): a <see cref="ZorgverlenerCaller"/>
/// once per request by <see cref="IIdentityProvider"/> and stashed on <see cref="HttpContext.Items"/> /// (citizen, WP-53 — subject BSN) or a <see cref="MedewerkerCaller"/> (backoffice employee — no BSN,
/// by the identity-resolution middleware (<c>Program.cs</c>, right after the correlation-id /// has rollen). Resolved once per request by <see cref="IIdentityProvider"/> and stashed on
/// middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> or the static /// <see cref="HttpContext.Items"/> by the identity-resolution middleware (<c>Program.cs</c>, right
/// <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a production /// after the correlation-id middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c>
/// <see cref="IIdentityProvider"/> (real OIDC/DigiD claims) swaps in without touching any consumer. /// 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> /// </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 public static class CallerIdentityHttpContextExtensions
{ {
@@ -24,4 +50,13 @@ public static class CallerIdentityHttpContextExtensions
? identity ? identity
: throw new InvalidOperationException( : throw new InvalidOperationException(
"No CallerIdentity resolved for this request — the identity middleware didn't run."); "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; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — the seam a real /// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — one of the two actor
/// OIDC/DigiD-backed provider replaces in production. <see cref="StubIdentityProvider"/> is the /// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker
/// only implementation today. /// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is
/// the only implementation today.
/// </summary> /// </summary>
public interface IIdentityProvider public interface IIdentityProvider
{ {
@@ -3,13 +3,16 @@ using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// Dev stub (WP-53) — NOT a security boundary, same caveat as <see cref="Authz.ResolvePrincipal"/> /// 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 /// <see cref="Authz.ResolvePrincipal"/> (which this provider now backs). Role comes from the
/// (mirrors the FE's <c>?role=</c> toggle); the subject BSN comes from a new X-Subject header, /// existing client-asserted X-Role header (mirrors the FE's <c>?role=</c> toggle) and applies to
/// defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>) so every /// either actor kind. Presence of X-Medewerker selects a <see cref="MedewerkerCaller"/> (id +
/// existing request — none of which send X-Subject — keeps behaving exactly as before this WP. /// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today —
/// A real system builds this from verified AD/OIDC/DigiD claims; every consumer of /// falls through to the WP-53 <see cref="ZorgverlenerCaller"/> path unchanged: subject BSN from
/// <see cref="CallerIdentity"/> carries over unchanged once that swap happens. /// 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> /// </summary>
public sealed class StubIdentityProvider : IIdentityProvider public sealed class StubIdentityProvider : IIdentityProvider
{ {
@@ -21,12 +24,34 @@ public sealed class StubIdentityProvider : IIdentityProvider
"admin" => PrincipalRole.Admin, "admin" => PrincipalRole.Admin,
_ => PrincipalRole.Drafter, _ => 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) var bsn = ctx.Request.Headers.TryGetValue("X-Subject", out var v) && !string.IsNullOrEmpty(v)
? v.ToString() ? v.ToString()
: DocumentStore.DemoOwner; : DocumentStore.DemoOwner;
// Only one seeded citizen exists in this POC — a real provider carries the display name in // 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. // 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; 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. // override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
// WP-53: the per-request acting citizen — resolved once (middleware, below) into // WP-53 (extended WP-62): the per-request acting caller — resolved once (middleware, below)
// HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and // into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today // every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
// (X-Role/X-Subject headers); a real OIDC/DigiD provider swaps in without touching a consumer. // 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>(); builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // 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 // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. // 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); return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
@@ -258,7 +259,7 @@ api.MapGet("/uploads/status", (string? localIds) =>
// User delete: owner-scoped; 409 once linked to a finalised submission. // User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => 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.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem( 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 (BSN-filtered) too, closing the last "reads a static store directly" gap
// openzaak-integration.md's ACL caveat used to flag for this endpoint. // openzaak-integration.md's ACL caveat used to flag for this endpoint.
api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) => 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) => 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.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
: Results.NotFound()) : Results.NotFound())
.Produces<ApplicationDetailDto>() .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. // Feature flag (WP-47): self-service registration can be closed by an admin.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden); 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) if (a is null)
return Results.Problem( return Results.Problem(
detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", 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). // Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => 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()) ? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -321,11 +322,11 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c
// be withdrawn (out of scope — no "intrekken"). // be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => 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 is null) return Results.NotFound();
if (a.Submitted) if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); 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(); return Results.NoContent();
}) })
.Produces(StatusCodes.Status204NoContent) .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. // 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) => 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 is null) return Results.NotFound();
if (existing.Submitted) if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); 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 docs = req.Documents;
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); 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(); if (submitted is null) return Results.Conflict();
app.Logger.LogInformation( app.Logger.LogInformation(
@@ -483,7 +484,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) => api.MapGet("/brief", (HttpContext ctx) =>
{ {
var e = BriefStore.GetOrCreate(ctx.Caller().Bsn); var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
return ToView(ctx, e); return ToView(ctx, e);
}) })
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
@@ -491,7 +492,7 @@ api.MapGet("/brief", (HttpContext ctx) =>
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{ {
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; 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>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -500,7 +501,7 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/submit", (HttpContext ctx) =>
{ {
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; 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); LogBrief("submit", r);
return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); 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) => 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); LogBrief("approve", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); 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) => 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); LogBrief("reject", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); 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 // 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 // port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step). // 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); LogBrief("send", r);
return BriefResult(ctx, r, "Versturen kan niet in deze status."); 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 canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp; 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) if (!allowed)
return Results.Problem( return Results.Problem(
detail: canReveal 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. // letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) => 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) if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html"); return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); 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) => api.MapPost("/brief/reset", (HttpContext ctx) =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // 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); return ToView(ctx, e);
}) })
.WithName("briefReset") .WithName("briefReset")
@@ -40,7 +40,7 @@ public sealed class OpenZaakDocumentSource(
// existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource. // existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource.
public UploadResponse Upload( public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, 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) UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
@@ -52,7 +52,7 @@ public sealed class OpenZaakDocumentSource(
// "Write resilience" section for why the two write paths differ). // "Write resilience" section for why the two write paths differ).
private async Task<UploadResponse> UploadAsync( private async Task<UploadResponse> UploadAsync(
string localId, string categoryId, string wizardId, string fileName, string contentType, 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); 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 /// <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 /// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary> /// 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(); ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller) 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> /// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
public string Mint() => MintCore(options.UserId, options.UserRepresentation); public string Mint() => MintCore(options.UserId, options.UserRepresentation);
/// <summary>Per-request variant (WP-53): the ZGW audit trail (<c>user_id</c>/ /// <summary>Per-request variant (WP-53, extended WP-62): the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) reflects the acting citizen instead of this BFF's static /// <c>user_representation</c>) reflects the acting caller instead of this BFF's static
/// config identity, for any call made on a specific citizen's behalf (create zaak, upload, /// config identity, for any call made on a specific caller's behalf (create zaak, upload,
/// link, citizen-scoped list).</summary> /// link, citizen-scoped list). <see cref="CallerIdentity.SubjectId"/> is the BSN for a
public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName); /// 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) private string MintCore(string userId, string userRepresentation)
{ {
@@ -81,4 +81,30 @@ public class AuthzTests
Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer); Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer);
Assert.False(Authz.Decisions(Approver, "draft", 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 }, 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] [Fact]
public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally() 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 options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); 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); source.ListMyCases(caller, DateTimeOffset.UtcNow);
@@ -123,7 +123,7 @@ public class OpenZaakZaakSourceTests
Referentie = "BIG-2026-000123", 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); var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
Assert.Equal("BIG-2026-000123", referentie); 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 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 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 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)); Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
} }
@@ -177,7 +177,7 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl }, ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
}; };
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" }; 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); return (options, aanvraag, caller);
} }
@@ -4,23 +4,30 @@ using Microsoft.AspNetCore.Http;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-53: the dev stub identity provider — role from X-Role (unchanged behaviour), subject BSN /// WP-53 (extended WP-62): the dev stub identity provider — role from X-Role (unchanged
/// from the new X-Subject header, defaulting to the single seeded citizen so every existing /// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the
/// request (none of which send X-Subject) resolves exactly as before this WP. /// 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 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(); var ctx = new DefaultHttpContext();
if (role is not null) ctx.Request.Headers["X-Role"] = role; if (role is not null) ctx.Request.Headers["X-Role"] = role;
if (subject is not null) ctx.Request.Headers["X-Subject"] = subject; 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); return new StubIdentityProvider().Resolve(ctx);
} }
private static ZorgverlenerCaller ResolveZorgverlener(string? role = null, string? subject = null) =>
Assert.IsType<ZorgverlenerCaller>(Resolve(role, subject));
[Fact] [Fact]
public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter() 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(DocumentStore.DemoOwner, caller.Bsn);
Assert.Equal(PrincipalRole.Drafter, caller.Role); Assert.Equal(PrincipalRole.Drafter, caller.Role);
} }
@@ -37,7 +44,53 @@ public class StubIdentityProviderTests
[Fact] [Fact]
public void X_subject_overrides_the_default_bsn() 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); 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] [Fact]
public void Mint_with_a_caller_carries_that_citizen_not_the_static_config_identity() 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 token = new ZgwTokenProvider(Options).Mint(caller);
var payload = JsonSerializer.Deserialize<JsonElement>(Decode(token.Split('.')[1])); 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()); 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] [Fact]
public void Signature_verifies_with_the_shared_secret() 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-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-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-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-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 | todo | | [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-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-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 | | [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 # WP-62 — Backend: medewerker caller identity + authz seam
Status: todo Status: done
Phase: 11 — Behandelportal Phase: 11 — Behandelportal
## Why ## Why
@@ -50,17 +50,17 @@ SSO — out of scope per CLAUDE.md, same as DigiD).
## Acceptance criteria ## 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). 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. 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. identity, unit-tested.
## Verification ## Verification
`cd backend && dotnet test` (existing WP-53 tests unaffected + new medewerker tests `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 ## 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 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 zorgverlener call sites could break — mitigated by keeping WP-53's existing tests as a
regression gate. 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 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. 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) ## The five ZGW APIs (context for later slices)
| API | Component | Used by | | 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 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`. 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 ## What each role unlocks
Capabilities are resolved server-side (`backend/src/BigRegister.Api/Domain/Authorization/Authz.cs`, Capabilities are resolved server-side (`backend/src/BigRegister.Api/Domain/Authorization/Authz.cs`,