Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f3b13345 | ||
|
|
a09c4ed87b |
@@ -40,15 +40,18 @@ public static class Mappers
|
||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
||||
// by passing different `now` values without waiting for the wall clock.
|
||||
//
|
||||
// Ingediend/MeerInfoGevraagd (AanvraagStatusTag, WP-63) aren't produced here yet — no
|
||||
// behandelaar action exists to reach them (WP-65 adds the transition endpoint).
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||
{
|
||||
if (!a.Submitted)
|
||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||
if (a.Reden is not null)
|
||||
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
|
||||
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
|
||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return new("Goedgekeurd", Referentie: a.Referentie);
|
||||
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
|
||||
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||
}
|
||||
|
||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
|
||||
@@ -3,6 +3,16 @@ using BigRegister.Domain.Submissions;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
|
||||
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
|
||||
/// the wizard draft) isn't part of this enum — see <see cref="Aanvraag.Submitted"/>.
|
||||
/// <see cref="Ingediend"/> and <see cref="MeerInfoGevraagd"/> are not reachable yet: no
|
||||
/// endpoint sets them (that's WP-65's behandelaar-facing mutation) — modelled here so the
|
||||
/// contract is ready when it does.
|
||||
/// </summary>
|
||||
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
||||
|
||||
/// <summary>
|
||||
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
||||
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IDocumentSource
|
||||
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
|
||||
UploadResponse Upload(
|
||||
string localId, string categoryId, string wizardId, string fileName, string contentType,
|
||||
byte[] content, CallerIdentity caller);
|
||||
byte[] content, ZorgverlenerCaller caller);
|
||||
|
||||
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
|
||||
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
|
||||
|
||||
@@ -28,7 +28,7 @@ public interface IZaakSource
|
||||
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
|
||||
/// can never see another citizen's zaken.
|
||||
/// </summary>
|
||||
IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now);
|
||||
IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
|
||||
|
||||
/// <summary>
|
||||
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed class LocalDocumentSource : IDocumentSource
|
||||
{
|
||||
public UploadResponse Upload(
|
||||
string localId, string categoryId, string wizardId, string fileName, string contentType,
|
||||
byte[] content, CallerIdentity caller)
|
||||
byte[] content, ZorgverlenerCaller caller)
|
||||
{
|
||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
|
||||
return new UploadResponse(doc.DocumentId, doc.LocalId);
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class LocalZaakSource : IZaakSource
|
||||
|
||||
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute
|
||||
/// inline before it was routed through this seam.</summary>
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
|
||||
ApplicationStore.List(caller.Bsn)
|
||||
.OrderByDescending(a => a.UpdatedAt)
|
||||
.Select(a => a.ToSummaryDto(now)).ToList();
|
||||
|
||||
@@ -75,6 +75,16 @@ public static class Authz
|
||||
/// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision.
|
||||
public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
// --- Medewerker (backoffice) capabilities (WP-62, ADR-0002 §3) ------------------------------
|
||||
|
||||
/// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling,
|
||||
/// WP-64/65)? Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false
|
||||
/// regardless of X-Role, because the capability belongs to the medewerker actor kind, not to
|
||||
/// the dev role stand-in. Shipped to a frontend only as a decision flag, never as a rollen
|
||||
/// matrix (ADR-0001).
|
||||
public static bool CanBeoordelen(CallerIdentity caller) =>
|
||||
caller is MedewerkerCaller m && m.Rollen.Contains(MedewerkerRol.Behandelaar);
|
||||
|
||||
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
|
||||
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
|
||||
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved
|
||||
/// once per request by <see cref="IIdentityProvider"/> and stashed on <see cref="HttpContext.Items"/>
|
||||
/// by the identity-resolution middleware (<c>Program.cs</c>, right after the correlation-id
|
||||
/// middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> or the static
|
||||
/// <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a production
|
||||
/// <see cref="IIdentityProvider"/> (real OIDC/DigiD claims) swaps in without touching any consumer.
|
||||
/// The two actor kinds a request can come from (WP-62, ADR-0002 §3): a <see cref="ZorgverlenerCaller"/>
|
||||
/// (citizen, WP-53 — subject BSN) or a <see cref="MedewerkerCaller"/> (backoffice employee — no BSN,
|
||||
/// has rollen). Resolved once per request by <see cref="IIdentityProvider"/> and stashed on
|
||||
/// <see cref="HttpContext.Items"/> by the identity-resolution middleware (<c>Program.cs</c>, right
|
||||
/// after the correlation-id middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c>
|
||||
/// or the static <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a
|
||||
/// production <see cref="IIdentityProvider"/> (real DigiD claims / employee SSO claims) swaps in
|
||||
/// without touching any consumer.
|
||||
/// </summary>
|
||||
public sealed record CallerIdentity(string Bsn, string DisplayName, PrincipalRole Role);
|
||||
public abstract record CallerIdentity(string DisplayName, PrincipalRole Role)
|
||||
{
|
||||
/// <summary>Stable subject id for audit/claims only (the ZGW JWT's <c>user_id</c>) — the BSN or
|
||||
/// the medewerkerId depending on the kind. Never use this as an ownership key: ownership scoping
|
||||
/// requires a BSN, i.e. a <see cref="ZorgverlenerCaller"/> (see <c>HttpContext.Zorgverlener()</c>).</summary>
|
||||
public abstract string SubjectId { get; }
|
||||
}
|
||||
|
||||
public sealed record ZorgverlenerCaller(string Bsn, string DisplayName, PrincipalRole Role)
|
||||
: CallerIdentity(DisplayName, Role)
|
||||
{
|
||||
public override string SubjectId => Bsn;
|
||||
}
|
||||
|
||||
public sealed record MedewerkerCaller(
|
||||
string MedewerkerId, IReadOnlyList<MedewerkerRol> Rollen, string DisplayName, PrincipalRole Role)
|
||||
: CallerIdentity(DisplayName, Role)
|
||||
{
|
||||
public override string SubjectId => MedewerkerId;
|
||||
}
|
||||
|
||||
/// <summary>Backoffice functions a medewerker holds (ADR-0002 §4: admin/auditor/institution-rep
|
||||
/// slot in here as extra rollen, never as new CallerIdentity variants). Deliberately one member —
|
||||
/// WP-65 adds the next one when a capability actually needs it.</summary>
|
||||
public enum MedewerkerRol { Behandelaar }
|
||||
|
||||
public static class CallerIdentityHttpContextExtensions
|
||||
{
|
||||
@@ -24,4 +50,13 @@ public static class CallerIdentityHttpContextExtensions
|
||||
? identity
|
||||
: throw new InvalidOperationException(
|
||||
"No CallerIdentity resolved for this request — the identity middleware didn't run.");
|
||||
|
||||
/// <summary>The citizen-scoped narrowing (WP-62): every SSP endpoint that scopes data by owner
|
||||
/// needs a BSN, which only a zorgverlener has. Throws rather than silently degrading — no
|
||||
/// medewerker reaches these endpoints today (the behandelportal calls its own endpoints,
|
||||
/// WP-64+), so this is a loud "wrong actor kind" bug detector, not a user-facing path.</summary>
|
||||
public static ZorgverlenerCaller Zorgverlener(this HttpContext ctx) =>
|
||||
ctx.Caller() as ZorgverlenerCaller
|
||||
?? throw new InvalidOperationException(
|
||||
"This endpoint is citizen-scoped but the caller is not a zorgverlener.");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — the seam a real
|
||||
/// OIDC/DigiD-backed provider replaces in production. <see cref="StubIdentityProvider"/> is the
|
||||
/// only implementation today.
|
||||
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — one of the two actor
|
||||
/// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker
|
||||
/// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is
|
||||
/// the only implementation today.
|
||||
/// </summary>
|
||||
public interface IIdentityProvider
|
||||
{
|
||||
|
||||
@@ -3,13 +3,16 @@ using BigRegister.Api.Data;
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Dev stub (WP-53) — NOT a security boundary, same caveat as <see cref="Authz.ResolvePrincipal"/>
|
||||
/// (which this provider now backs). Role comes from the existing client-asserted X-Role header
|
||||
/// (mirrors the FE's <c>?role=</c> toggle); the subject BSN comes from a new X-Subject header,
|
||||
/// defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>) so every
|
||||
/// existing request — none of which send X-Subject — keeps behaving exactly as before this WP.
|
||||
/// A real system builds this from verified AD/OIDC/DigiD claims; every consumer of
|
||||
/// <see cref="CallerIdentity"/> carries over unchanged once that swap happens.
|
||||
/// Dev stub (WP-53, extended WP-62) — NOT a security boundary, same caveat as
|
||||
/// <see cref="Authz.ResolvePrincipal"/> (which this provider now backs). Role comes from the
|
||||
/// existing client-asserted X-Role header (mirrors the FE's <c>?role=</c> toggle) and applies to
|
||||
/// either actor kind. Presence of X-Medewerker selects a <see cref="MedewerkerCaller"/> (id +
|
||||
/// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today —
|
||||
/// falls through to the WP-53 <see cref="ZorgverlenerCaller"/> path unchanged: subject BSN from
|
||||
/// X-Subject, defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>).
|
||||
/// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims
|
||||
/// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that
|
||||
/// swap happens.
|
||||
/// </summary>
|
||||
public sealed class StubIdentityProvider : IIdentityProvider
|
||||
{
|
||||
@@ -21,12 +24,34 @@ public sealed class StubIdentityProvider : IIdentityProvider
|
||||
"admin" => PrincipalRole.Admin,
|
||||
_ => PrincipalRole.Drafter,
|
||||
};
|
||||
|
||||
var medewerkerId = ctx.Request.Headers["X-Medewerker"].ToString();
|
||||
if (!string.IsNullOrEmpty(medewerkerId))
|
||||
return new MedewerkerCaller(medewerkerId, ParseRollen(ctx), medewerkerId, role);
|
||||
|
||||
var bsn = ctx.Request.Headers.TryGetValue("X-Subject", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: DocumentStore.DemoOwner;
|
||||
// Only one seeded citizen exists in this POC — a real provider carries the display name in
|
||||
// the verified claims themselves, so there's no "look up a name by BSN" step to stand in for.
|
||||
var displayName = bsn == DocumentStore.DemoOwner ? SeedData.Registration.Naam : bsn;
|
||||
return new CallerIdentity(bsn, displayName, role);
|
||||
return new ZorgverlenerCaller(bsn, displayName, role);
|
||||
}
|
||||
|
||||
/// X-Rollen absent → the useful default (behandelaar), mirroring how X-Subject defaults to the
|
||||
/// seeded citizen: one header is enough to be a working backoffice caller. Present → parsed,
|
||||
/// unrecognised tokens dropped (so `X-Rollen: geen` is how you exercise a deny path).
|
||||
private static IReadOnlyList<MedewerkerRol> ParseRollen(HttpContext ctx)
|
||||
{
|
||||
var raw = ctx.Request.Headers["X-Rollen"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return new[] { MedewerkerRol.Behandelaar };
|
||||
return raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(ToRol).Where(r => r is not null).Select(r => r!.Value).Distinct().ToList();
|
||||
}
|
||||
|
||||
private static MedewerkerRol? ToRol(string token) => token.ToLowerInvariant() switch
|
||||
{
|
||||
"behandelaar" => MedewerkerRol.Behandelaar,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,10 +43,11 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
// override it (ConnectionStrings:AppDb) without touching this file.
|
||||
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
|
||||
|
||||
// WP-53: the per-request acting citizen — resolved once (middleware, below) into
|
||||
// HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
|
||||
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today
|
||||
// (X-Role/X-Subject headers); a real OIDC/DigiD provider swaps in without touching a consumer.
|
||||
// WP-53 (extended WP-62): the per-request acting caller — resolved once (middleware, below)
|
||||
// into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
|
||||
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
|
||||
// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
|
||||
// DigiD/employee-SSO provider swaps in without touching a consumer.
|
||||
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
|
||||
|
||||
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
|
||||
@@ -228,7 +229,7 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
|
||||
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
|
||||
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
|
||||
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
|
||||
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Caller());
|
||||
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
|
||||
return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
@@ -258,7 +259,7 @@ api.MapGet("/uploads/status", (string? localIds) =>
|
||||
|
||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
DocumentStore.DeleteOwned(documentId, ctx.Caller().Bsn) switch
|
||||
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
|
||||
{
|
||||
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
||||
DocumentStore.DeleteResult.Linked => Results.Problem(
|
||||
@@ -286,10 +287,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
|
||||
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
|
||||
// openzaak-integration.md's ACL caveat used to flag for this endpoint.
|
||||
api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) =>
|
||||
zaken.ListMyCases(ctx.Caller(), DateTimeOffset.UtcNow));
|
||||
zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow));
|
||||
|
||||
api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
ApplicationStore.Get(id, ctx.Caller().Bsn) is { } a
|
||||
ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a
|
||||
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
|
||||
: Results.NotFound())
|
||||
.Produces<ApplicationDetailDto>()
|
||||
@@ -300,7 +301,7 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
||||
// Feature flag (WP-47): self-service registration can be closed by an admin.
|
||||
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
|
||||
return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden);
|
||||
var a = ApplicationStore.CreateConcept(req.Type, ctx.Caller().Bsn);
|
||||
var a = ApplicationStore.CreateConcept(req.Type, ctx.Zorgverlener().Bsn);
|
||||
if (a is null)
|
||||
return Results.Problem(
|
||||
detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.",
|
||||
@@ -312,7 +313,7 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
||||
|
||||
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
|
||||
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
|
||||
ApplicationStore.SyncDraft(id, ctx.Caller().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
||||
ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
|
||||
? Results.NoContent() : Results.NotFound())
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
@@ -321,11 +322,11 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c
|
||||
// be withdrawn (out of scope — no "intrekken").
|
||||
api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
{
|
||||
var a = ApplicationStore.Get(id, ctx.Caller().Bsn);
|
||||
var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
|
||||
if (a is null) return Results.NotFound();
|
||||
if (a.Submitted)
|
||||
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
||||
ApplicationStore.Delete(id, ctx.Caller().Bsn);
|
||||
ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
@@ -336,7 +337,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
|
||||
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
|
||||
{
|
||||
var existing = ApplicationStore.Get(id, ctx.Caller().Bsn);
|
||||
var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
|
||||
if (existing is null) return Results.NotFound();
|
||||
if (existing.Submitted)
|
||||
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
||||
@@ -351,7 +352,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
var docs = req.Documents;
|
||||
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
|
||||
var submitted = ApplicationStore.Submit(id, ctx.Caller().Bsn, reject, autoApprovable, documentIds);
|
||||
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
|
||||
if (submitted is null) return Results.Conflict();
|
||||
|
||||
app.Logger.LogInformation(
|
||||
@@ -483,7 +484,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
|
||||
|
||||
api.MapGet("/brief", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(ctx.Caller().Bsn);
|
||||
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.Produces<BriefViewDto>();
|
||||
@@ -491,7 +492,7 @@ api.MapGet("/brief", (HttpContext ctx) =>
|
||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
return BriefResult(ctx, BriefStore.Save(ctx.Caller().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
return BriefResult(ctx, BriefStore.Save(ctx.Zorgverlener().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
})
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -500,7 +501,7 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
{
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
var r = BriefStore.Submit(ctx.Caller().Bsn, isDrafter, Now());
|
||||
var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
||||
})
|
||||
@@ -511,7 +512,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
|
||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
{
|
||||
var r = BriefStore.Approve(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), Now());
|
||||
var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now());
|
||||
LogBrief("approve", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
@@ -521,7 +522,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
|
||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var r = BriefStore.Reject(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||
var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||
LogBrief("reject", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
@@ -534,7 +535,7 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||
// port); the backend only guards the approved→sent transition (not role-gated
|
||||
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
|
||||
var r = BriefStore.Send(ctx.Caller().Bsn, Now());
|
||||
var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now());
|
||||
LogBrief("send", r);
|
||||
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
||||
})
|
||||
@@ -552,7 +553,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
var canReveal = Authz.CanRevealBigNummer(principal);
|
||||
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
|
||||
var allowed = canReveal && steppedUp;
|
||||
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Caller().Bsn, allowed, principal);
|
||||
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);
|
||||
if (!allowed)
|
||||
return Results.Problem(
|
||||
detail: canReveal
|
||||
@@ -571,7 +572,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(ctx.Caller().Bsn);
|
||||
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
@@ -593,7 +594,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
var e = BriefStore.ResetAndCreate(ctx.Caller().Bsn);
|
||||
var e = BriefStore.ResetAndCreate(ctx.Zorgverlener().Bsn);
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.WithName("briefReset")
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class OpenZaakDocumentSource(
|
||||
// existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource.
|
||||
public UploadResponse Upload(
|
||||
string localId, string categoryId, string wizardId, string fileName, string contentType,
|
||||
byte[] content, CallerIdentity caller) =>
|
||||
byte[] content, ZorgverlenerCaller caller) =>
|
||||
UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class OpenZaakDocumentSource(
|
||||
// "Write resilience" section for why the two write paths differ).
|
||||
private async Task<UploadResponse> UploadAsync(
|
||||
string localId, string categoryId, string wizardId, string fileName, string contentType,
|
||||
byte[] content, CallerIdentity caller)
|
||||
byte[] content, ZorgverlenerCaller caller)
|
||||
{
|
||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
/// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
|
||||
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
|
||||
/// system-level one <see cref="ListCases"/> uses.</summary>
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
|
||||
ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
|
||||
|
||||
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller)
|
||||
|
||||
@@ -23,11 +23,13 @@ public sealed class ZgwTokenProvider(ZgwOptions options)
|
||||
/// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
|
||||
public string Mint() => MintCore(options.UserId, options.UserRepresentation);
|
||||
|
||||
/// <summary>Per-request variant (WP-53): the ZGW audit trail (<c>user_id</c>/
|
||||
/// <c>user_representation</c>) reflects the acting citizen instead of this BFF's static
|
||||
/// config identity, for any call made on a specific citizen's behalf (create zaak, upload,
|
||||
/// link, citizen-scoped list).</summary>
|
||||
public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName);
|
||||
/// <summary>Per-request variant (WP-53, extended WP-62): the ZGW audit trail (<c>user_id</c>/
|
||||
/// <c>user_representation</c>) reflects the acting caller instead of this BFF's static
|
||||
/// config identity, for any call made on a specific caller's behalf (create zaak, upload,
|
||||
/// link, citizen-scoped list). <see cref="CallerIdentity.SubjectId"/> is the BSN for a
|
||||
/// zorgverlener or the medewerkerId for a medewerker (WP-66 mints this for a besluit write
|
||||
/// the same way, with no further change needed here).</summary>
|
||||
public string Mint(CallerIdentity caller) => MintCore(caller.SubjectId, caller.DisplayName);
|
||||
|
||||
private string MintCore(string userId, string userRepresentation)
|
||||
{
|
||||
|
||||
@@ -205,4 +205,15 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
Assert.Equal("InBehandeling", status.Tag);
|
||||
Assert.True(status.Manual);
|
||||
}
|
||||
|
||||
// WP-63: the published lifecycle (ADR-0002) must name exactly these five tags, in this
|
||||
// order — ToStatusDto's string literals must keep matching Enum.ToString(), and Ingediend/
|
||||
// MeerInfoGevraagd (unreachable until WP-65 adds the behandelaar transition) stay defined.
|
||||
[Fact]
|
||||
public void AanvraagStatusTag_covers_the_published_lifecycle()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "Ingediend", "InBehandeling", "MeerInfoGevraagd", "Goedgekeurd", "Afgewezen" },
|
||||
Enum.GetNames<AanvraagStatusTag>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +81,30 @@ public class AuthzTests
|
||||
Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer);
|
||||
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer);
|
||||
}
|
||||
|
||||
// --- CanBeoordelen (WP-62) --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CanBeoordelen_true_for_a_medewerker_with_the_behandelaar_rol()
|
||||
{
|
||||
var medewerker = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter);
|
||||
Assert.True(Authz.CanBeoordelen(medewerker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanBeoordelen_false_for_a_medewerker_without_it()
|
||||
{
|
||||
var medewerker = new MedewerkerCaller("m.jansen", [], "M. Jansen", PrincipalRole.Drafter);
|
||||
Assert.False(Authz.CanBeoordelen(medewerker));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PrincipalRole.Drafter)]
|
||||
[InlineData(PrincipalRole.Approver)]
|
||||
[InlineData(PrincipalRole.Admin)]
|
||||
public void CanBeoordelen_false_for_a_zorgverlener_regardless_of_role(PrincipalRole role)
|
||||
{
|
||||
var zorgverlener = new ZorgverlenerCaller("111222333", "Dr. Test", role);
|
||||
Assert.False(Authz.CanBeoordelen(zorgverlener));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class OpenZaakDocumentSourceTests
|
||||
InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl },
|
||||
};
|
||||
|
||||
private static readonly CallerIdentity Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter);
|
||||
private static readonly ZorgverlenerCaller Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter);
|
||||
|
||||
[Fact]
|
||||
public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally()
|
||||
|
||||
@@ -71,7 +71,7 @@ public class OpenZaakZaakSourceTests
|
||||
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var caller = new CallerIdentity("111222333", "Dr. Test", PrincipalRole.Drafter);
|
||||
var caller = new ZorgverlenerCaller("111222333", "Dr. Test", PrincipalRole.Drafter);
|
||||
|
||||
source.ListMyCases(caller, DateTimeOffset.UtcNow);
|
||||
|
||||
@@ -123,7 +123,7 @@ public class OpenZaakZaakSourceTests
|
||||
Referentie = "BIG-2026-000123",
|
||||
};
|
||||
|
||||
var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
|
||||
|
||||
Assert.Equal("BIG-2026-000123", referentie);
|
||||
@@ -156,7 +156,7 @@ public class OpenZaakZaakSourceTests
|
||||
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
|
||||
var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
|
||||
}
|
||||
@@ -177,7 +177,7 @@ public class OpenZaakZaakSourceTests
|
||||
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||
};
|
||||
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" };
|
||||
var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
|
||||
return (options, aanvraag, caller);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,30 @@ using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// WP-53: the dev stub identity provider — role from X-Role (unchanged behaviour), subject BSN
|
||||
/// from the new X-Subject header, defaulting to the single seeded citizen so every existing
|
||||
/// request (none of which send X-Subject) resolves exactly as before this WP.
|
||||
/// WP-53 (extended WP-62): the dev stub identity provider — role from X-Role (unchanged
|
||||
/// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the
|
||||
/// single seeded citizen so every existing request (none of which send X-Subject) resolves
|
||||
/// exactly as before this WP. X-Medewerker (+ X-Rollen) selects the medewerker actor kind.
|
||||
public class StubIdentityProviderTests
|
||||
{
|
||||
private static CallerIdentity Resolve(string? role, string? subject)
|
||||
private static CallerIdentity Resolve(
|
||||
string? role = null, string? subject = null, string? medewerker = null, string? rollen = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
if (role is not null) ctx.Request.Headers["X-Role"] = role;
|
||||
if (subject is not null) ctx.Request.Headers["X-Subject"] = subject;
|
||||
if (medewerker is not null) ctx.Request.Headers["X-Medewerker"] = medewerker;
|
||||
if (rollen is not null) ctx.Request.Headers["X-Rollen"] = rollen;
|
||||
return new StubIdentityProvider().Resolve(ctx);
|
||||
}
|
||||
|
||||
private static ZorgverlenerCaller ResolveZorgverlener(string? role = null, string? subject = null) =>
|
||||
Assert.IsType<ZorgverlenerCaller>(Resolve(role, subject));
|
||||
|
||||
[Fact]
|
||||
public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter()
|
||||
{
|
||||
var caller = Resolve(role: null, subject: null);
|
||||
var caller = ResolveZorgverlener(role: null, subject: null);
|
||||
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
|
||||
Assert.Equal(PrincipalRole.Drafter, caller.Role);
|
||||
}
|
||||
@@ -37,7 +44,53 @@ public class StubIdentityProviderTests
|
||||
[Fact]
|
||||
public void X_subject_overrides_the_default_bsn()
|
||||
{
|
||||
var caller = Resolve(role: null, subject: "999888777");
|
||||
var caller = ResolveZorgverlener(role: null, subject: "999888777");
|
||||
Assert.Equal("999888777", caller.Bsn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_headers_resolves_a_zorgverlener_kind()
|
||||
{
|
||||
Assert.IsType<ZorgverlenerCaller>(Resolve());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_medewerker_resolves_a_medewerker_with_the_default_behandelaar_rol()
|
||||
{
|
||||
var caller = Assert.IsType<MedewerkerCaller>(Resolve(medewerker: "m.jansen"));
|
||||
Assert.Equal("m.jansen", caller.MedewerkerId);
|
||||
Assert.Equal("m.jansen", caller.SubjectId);
|
||||
Assert.Contains(MedewerkerRol.Behandelaar, caller.Rollen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_medewerker_takes_precedence_over_x_subject()
|
||||
{
|
||||
var caller = Resolve(subject: "999888777", medewerker: "m.jansen");
|
||||
Assert.IsType<MedewerkerCaller>(caller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_x_medewerker_falls_through_to_the_zorgverlener_default()
|
||||
{
|
||||
var caller = Assert.IsType<ZorgverlenerCaller>(Resolve(medewerker: ""));
|
||||
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("behandelaar", new[] { MedewerkerRol.Behandelaar })]
|
||||
[InlineData("Behandelaar, behandelaar", new[] { MedewerkerRol.Behandelaar })]
|
||||
[InlineData("geen", new MedewerkerRol[0])]
|
||||
public void X_rollen_parses_known_tokens_and_drops_unknown_ones(string rollen, MedewerkerRol[] expected)
|
||||
{
|
||||
var caller = Assert.IsType<MedewerkerCaller>(Resolve(medewerker: "m.jansen", rollen: rollen));
|
||||
Assert.Equal(expected, caller.Rollen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_role_still_applies_to_a_medewerker()
|
||||
{
|
||||
var caller = Resolve(role: "admin", medewerker: "m.jansen");
|
||||
Assert.Equal(PrincipalRole.Admin, caller.Role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ZgwTokenProviderTests
|
||||
[Fact]
|
||||
public void Mint_with_a_caller_carries_that_citizen_not_the_static_config_identity()
|
||||
{
|
||||
var caller = new CallerIdentity("111222333", "Dr. Citizen", PrincipalRole.Drafter);
|
||||
var caller = new ZorgverlenerCaller("111222333", "Dr. Citizen", PrincipalRole.Drafter);
|
||||
var token = new ZgwTokenProvider(Options).Mint(caller);
|
||||
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>(Decode(token.Split('.')[1]));
|
||||
@@ -56,6 +56,19 @@ public class ZgwTokenProviderTests
|
||||
Assert.Equal("big-register", payload.GetProperty("client_id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mint_with_a_medewerker_caller_uses_the_medewerkerId_as_user_id()
|
||||
{
|
||||
// WP-62: SubjectId is what ZgwTokenProvider.Mint reads — a medewerker's is its
|
||||
// medewerkerId, not a BSN, and this is the only place that's directly observable.
|
||||
var caller = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter);
|
||||
var token = new ZgwTokenProvider(Options).Mint(caller);
|
||||
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>(Decode(token.Split('.')[1]));
|
||||
Assert.Equal("m.jansen", payload.GetProperty("user_id").GetString());
|
||||
Assert.Equal("M. Jansen", payload.GetProperty("user_representation").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_verifies_with_the_shared_secret()
|
||||
{
|
||||
|
||||
@@ -111,9 +111,9 @@ 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-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 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 | done |
|
||||
| [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-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-62 — Backend: medewerker caller identity + authz seam
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
@@ -50,17 +50,17 @@ SSO — out of scope per CLAUDE.md, same as DigiD).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `CallerIdentity` represents both actor kinds without breaking any existing
|
||||
- [x] `CallerIdentity` represents both actor kinds without breaking any existing
|
||||
zorgverlener call site (WP-53's tests still green).
|
||||
- [ ] A stub medewerker identity resolves from a request header, mirroring the existing
|
||||
- [x] A stub medewerker identity resolves from a request header, mirroring the existing
|
||||
citizen stub.
|
||||
- [ ] At least one capability flag (`canBeoordelen`) computable for a medewerker
|
||||
- [x] At least one capability flag (`canBeoordelen`) computable for a medewerker
|
||||
identity, unit-tested.
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` (existing WP-53 tests unaffected + new medewerker tests
|
||||
green).
|
||||
green) — 182/182 (168 baseline + 14 new). `dotnet format --verify-no-changes` clean.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -71,3 +71,26 @@ Any actual backoffice endpoint using this (WP-64+); real employee SSO/eHerkennin
|
||||
If the union is modeled as a bolt-on rather than replacing the flat type, existing
|
||||
zorgverlener call sites could break — mitigated by keeping WP-53's existing tests as a
|
||||
regression gate.
|
||||
|
||||
## Outcome notes
|
||||
|
||||
- **The `Files` list undersold the blast radius.** `CallerIdentity` became `abstract`
|
||||
with two derived records (`ZorgverlenerCaller`, `MedewerkerCaller`), which is a hard
|
||||
compile error at every `new CallerIdentity(...)` and every `.Bsn` read outside
|
||||
`Domain/Authorization/` — 17 `ctx.Caller().Bsn` reads in `Program.cs` alone, plus 6
|
||||
seam signatures (`IDocumentSource.Upload`, `IZaakSource.ListMyCases` and their
|
||||
Local/OpenZaak implementations) narrowed to `ZorgverlenerCaller` where `.Bsn` is used
|
||||
as an ownership key, plus test fixtures in 4 test files.
|
||||
`CallerIdentity.SubjectId` (BSN or medewerkerId) is the trick that kept the
|
||||
token-mint-only call sites (`ZgwTokenProvider.Mint`, `ZgwHttpClient`, `IZaakSource
|
||||
.CreateZaak`, `IDocumentSource.LinkToZaak`) compiling with zero signature changes —
|
||||
they never needed the BSN specifically, just _an_ id for the ZGW audit trail.
|
||||
- **`Role` (`PrincipalRole`, the existing dev-role stand-in) stays on the base record**,
|
||||
not per-variant — it's an orthogonal axis (both actor kinds can be any dev role),
|
||||
which is why `Authz.ResolvePrincipal(ctx) => new(ctx.Caller().Role)` and its ~15
|
||||
call sites needed no changes at all.
|
||||
- **A new extension, `ctx.Zorgverlener()`**, narrows `CallerIdentity` to
|
||||
`ZorgverlenerCaller` or throws — deliberately a 500, not a 403, since no medewerker
|
||||
reaches any SSP endpoint today (nothing sends `X-Medewerker` yet). WP-64 should
|
||||
map this to a 403 once real backoffice traffic exists; flagging it now so it isn't
|
||||
mistaken for an oversight.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-63 — Backend: aanvraag status lifecycle as a published DTO
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
@@ -48,10 +48,27 @@ read (this WP), the behandelportal needs it as the thing it advances (WP-65).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Backend publishes the full status lifecycle value on the existing aanvraag DTO.
|
||||
- [ ] `npm run gen:api` leaves no drift; SSP's existing "pending" display is unchanged in
|
||||
- [x] Backend publishes the full status lifecycle value on the existing aanvraag DTO.
|
||||
- [x] `npm run gen:api` leaves no drift; SSP's existing "pending" display is unchanged in
|
||||
behavior, now backed by the real status.
|
||||
- [ ] `dotnet test` + `npm run ci` green.
|
||||
- [x] `dotnet test` + `npm run ci` green.
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented as a pure type-system widening, not a behavior change: `AanvraagStatusTag`
|
||||
(`Ingediend | InBehandeling | MeerInfoGevraagd | Goedgekeurd | Afgewezen`) is a new C# enum
|
||||
backing `Mappers.ToStatusDto`'s existing string literals — `AanvraagStatusDto.Tag` stays a
|
||||
plain string, so the OpenAPI schema (and `npm run gen:api`) don't change at all, satisfying
|
||||
"zero required FE behavior change" trivially. `Ingediend`/`MeerInfoGevraagd` aren't reachable
|
||||
from any code path yet (no behandelaar action exists to produce them) — that's WP-65's
|
||||
transition endpoint, exactly per this WP's own Risks note. The FE `AanvraagStatus` union,
|
||||
`parseAanvraagStatus`, `statusLabel`/`submittedRow`/`detailRows`, `blockActions`, and the
|
||||
dashboard's sort order were all widened to the two new tags so TypeScript's exhaustiveness
|
||||
checking forces every switch to handle them once WP-65 starts emitting them.
|
||||
`big-profile.store.ts`'s `pendingHerregistratie` was deliberately left untouched — it's a
|
||||
pure client-side optimistic UI flag unrelated to any DTO field (not what the WP's "Why"
|
||||
section implied), and the decision text's "or kept as a computed convenience" explicitly
|
||||
allows this.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -45,6 +45,14 @@ describe('submittedRow', () => {
|
||||
} as Aanvraag);
|
||||
expect(rejected.status).toContain('Onvoldoende uren');
|
||||
});
|
||||
|
||||
it('meer-info-gevraagd adds its reason, like a rejection', () => {
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'MeerInfoGevraagd', referentie: 'R3', reden: 'Diploma ontbreekt' },
|
||||
} as Aanvraag);
|
||||
expect(row.status).toContain('Diploma ontbreekt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailRows', () => {
|
||||
|
||||
@@ -28,8 +28,12 @@ export function statusLabel(status: AanvraagStatus): string {
|
||||
switch (status.tag) {
|
||||
case 'Concept':
|
||||
return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
|
||||
case 'Ingediend':
|
||||
return $localize`:@@aanvraag.status.ingediend:Ingediend`;
|
||||
case 'InBehandeling':
|
||||
return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
|
||||
case 'MeerInfoGevraagd':
|
||||
return $localize`:@@aanvraag.status.meerInfoGevraagd:Meer informatie gevraagd`;
|
||||
case 'Goedgekeurd':
|
||||
return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
|
||||
case 'Afgewezen':
|
||||
@@ -65,7 +69,7 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||
parts.push(
|
||||
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
||||
);
|
||||
if (s.tag === 'Afgewezen') parts.push(s.reden);
|
||||
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') parts.push(s.reden);
|
||||
return {
|
||||
heading: TYPE_LABELS[a.type],
|
||||
subtitle: purposeLabel(a.type),
|
||||
@@ -94,5 +98,11 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
|
||||
value: a.status.reden,
|
||||
});
|
||||
}
|
||||
if (a.status.tag === 'MeerInfoGevraagd') {
|
||||
rows.push({
|
||||
key: $localize`:@@aanvraag.detail.meerInfoReden:Gevraagde informatie`,
|
||||
value: a.status.reden,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
*/
|
||||
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
|
||||
|
||||
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse
|
||||
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's
|
||||
// behandelaar-facing transition endpoint.
|
||||
export type AanvraagStatus =
|
||||
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
||||
| { tag: 'Ingediend'; referentie: string }
|
||||
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt handmatig beoordeeld"
|
||||
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
|
||||
| { tag: 'Goedgekeurd'; referentie: string }
|
||||
| { tag: 'Afgewezen'; referentie: string; reden: string };
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ describe('blockActions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ingediend and meer-info-gevraagd behave like in-behandeling', () => {
|
||||
expect(blockActions({ tag: 'Ingediend', referentie: 'BIG-1' })).toEqual(['viewDocuments']);
|
||||
expect(blockActions({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' })).toEqual([
|
||||
'viewDocuments',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolved aanvragen have no actions', () => {
|
||||
expect(blockActions({ tag: 'Goedgekeurd', referentie: 'BIG-1' })).toEqual([]);
|
||||
expect(blockActions({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' })).toEqual([]);
|
||||
|
||||
@@ -9,7 +9,9 @@ export function blockActions(status: AanvraagStatus): BlockAction[] {
|
||||
switch (status.tag) {
|
||||
case 'Concept':
|
||||
return ['resume', 'cancel'];
|
||||
case 'Ingediend':
|
||||
case 'InBehandeling':
|
||||
case 'MeerInfoGevraagd':
|
||||
return ['viewDocuments'];
|
||||
case 'Goedgekeurd':
|
||||
case 'Afgewezen':
|
||||
|
||||
@@ -21,9 +21,13 @@ describe('parseAanvraagStatus', () => {
|
||||
ok: true,
|
||||
value: { tag: 'Concept', stepIndex: 2, stepCount: 4 },
|
||||
});
|
||||
expect(parseAanvraagStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'diploma?' }).ok,
|
||||
).toBe(true);
|
||||
expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok,
|
||||
|
||||
@@ -78,10 +78,17 @@ export function parseAanvraagStatus(
|
||||
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number')
|
||||
return err('aanvraag: bad Concept status');
|
||||
return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount });
|
||||
case 'Ingediend':
|
||||
if (typeof s.referentie !== 'string') return err('aanvraag: bad Ingediend status');
|
||||
return ok({ tag: 'Ingediend', referentie: s.referentie });
|
||||
case 'InBehandeling':
|
||||
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
|
||||
return err('aanvraag: bad InBehandeling status');
|
||||
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
|
||||
case 'MeerInfoGevraagd':
|
||||
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||
return err('aanvraag: bad MeerInfoGevraagd status');
|
||||
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
|
||||
case 'Goedgekeurd':
|
||||
if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status');
|
||||
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
|
||||
|
||||
@@ -236,7 +236,9 @@ export class DashboardPage {
|
||||
if (rd.tag !== 'Success') return [];
|
||||
const order: Record<Aanvraag['status']['tag'], number> = {
|
||||
Concept: 0,
|
||||
Ingediend: 1,
|
||||
InBehandeling: 1,
|
||||
MeerInfoGevraagd: 1,
|
||||
Goedgekeurd: 2,
|
||||
Afgewezen: 2,
|
||||
};
|
||||
|
||||
@@ -1006,12 +1006,28 @@
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.status.ingediend" datatype="html">
|
||||
<source>Ingediend</source>
|
||||
<target datatype="html">Submitted</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/domain/aanvraag-view.ts</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.status.inBehandeling" datatype="html">
|
||||
<source>In behandeling</source>
|
||||
<target datatype="html">In progress</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/domain/aanvraag-view.ts</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.status.meerInfoGevraagd" datatype="html">
|
||||
<source>Meer informatie gevraagd</source>
|
||||
<target datatype="html">More information requested</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/domain/aanvraag-view.ts</context>
|
||||
<context context-type="linenumber">36</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.status.goedgekeurd" datatype="html">
|
||||
@@ -1094,6 +1110,14 @@
|
||||
<context context-type="linenumber">87</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.detail.meerInfoReden" datatype="html">
|
||||
<source>Gevraagde informatie</source>
|
||||
<target datatype="html">Information requested</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/domain/aanvraag-view.ts</context>
|
||||
<context context-type="linenumber">99</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="aanvraag.detail.reden" datatype="html">
|
||||
<source>Reden van afwijzing</source>
|
||||
<target datatype="html">Reason for rejection</target>
|
||||
|
||||
Reference in New Issue
Block a user