From 5cae44f163321f87dfb7eb0e9588e49016189f17 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Mon, 20 Jul 2026 19:59:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(security):=20ABAC=20P2/P3-lite=20=E2=80=94?= =?UTF-8?q?=20BIG-nummer=20redaction,=20authz=20audit,=20guard;=20clear=20?= =?UTF-8?q?dev=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(deps): pin @babel/core ^7.29.7 via overrides → npm audit 0 (dev+prod), no --force / no Angular downgrade; README corrected - feat(brief): field-level PII reveal (PRD-0002 §5c) — CaseContext BIG-nummer ships masked; step-up-stubbed (X-Step-Up), audited POST /brief/reveal-bignummer unmasks it; drafter-only capability, deny-by-default. Realized on the BIG-nummer (no BSN on the wire) - feat(authz): no-PII AuditAuthz log for reveal attempts + org-admin denials (§8) - feat(routes): wire capabilityGuard('orgtemplate:edit') onto brief/huisstijl (§6) - test: backend +5 (Authz + reveal endpoint), FE +3 (adapter boundary, store swap) - docs: PRD-0002 §5c/§9, WP-18 follow-up, README Co-Authored-By: Claude Opus 4.8 --- README.md | 12 +- backend/src/BigRegister.Api/Contracts/Dtos.cs | 7 +- .../Domain/Authorization/Authz.cs | 12 +- backend/src/BigRegister.Api/Program.cs | 62 ++++- backend/swagger.json | 3 + backend/tests/BigRegister.Tests/AuthzTests.cs | 16 ++ .../BigRegister.Tests/BriefEndpointTests.cs | 38 ++- docs/backlog/WP-18-abac-capability-spine.md | 12 + .../0002-attribute-based-access-control.md | 13 ++ package-lock.json | 219 +++--------------- package.json | 1 + src/app/app.routes.ts | 7 +- src/app/brief/application/brief.store.spec.ts | 36 +++ src/app/brief/application/brief.store.ts | 17 ++ src/app/brief/domain/brief.machine.spec.ts | 2 + src/app/brief/domain/brief.ts | 3 + .../infrastructure/brief.adapter.spec.ts | 10 +- src/app/brief/infrastructure/brief.adapter.ts | 4 +- .../reveal-bignummer.adapter.ts | 47 ++++ .../behandel-scherm.component.ts | 24 +- .../behandel-scherm.stories.ts | 13 ++ src/app/brief/ui/brief.page.ts | 2 + .../letter-composer.stories.ts | 3 + src/app/shared/infrastructure/api-client.ts | 1 + 24 files changed, 353 insertions(+), 211 deletions(-) create mode 100644 src/app/brief/infrastructure/reveal-bignummer.adapter.ts diff --git a/README.md b/README.md index c6b33c8..8bb09b8 100644 --- a/README.md +++ b/README.md @@ -185,12 +185,12 @@ degrade to an instant navigation. ### Dependency security -The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`). All advisories -live in dev/build tooling (Storybook + the Angular build chain) and never reach the -bundle. `package.json` `overrides` pin patched transitive versions, taking the full -audit from 16 (incl. 3 high) down to **5 low** — the remainder all cascade from -`@babel/core`'s low-severity sourceMappingURL issue, which only "fixes" by jumping to -Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately left. +The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the +`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in +dev/build tooling (Storybook + the Angular build chain) and never reach the bundle. +`package.json` `overrides` pin patched transitive versions; the last remaining cluster +cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning +`@babel/core` to a patched **7.x** (`^7.29.7`) — no jump to Babel 8, no breaking change. We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21. ### Deliberately out of scope (POC) diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index fbae447..8011d82 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -153,7 +153,9 @@ public sealed record BriefDto( // Decision flags for the CURRENT acting principal + this brief's live status // (PRD-0002 phase P1) — the FE renders these, it never recomputes them. -public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend); +// CanRevealBigNummer (PRD-0002 §5c): whether the acting principal may unmask the +// BIG-nummer the case screen ships masked. Status-independent, unlike the action gates. +public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend, bool CanRevealBigNummer); // The brief's screen DTO also carries the org template it renders with (WP-23): // the sub-org's current PUBLISHED version — or, once sent, the version pinned at @@ -169,6 +171,9 @@ public sealed record BriefViewDto( public sealed record SaveBriefRequest(IReadOnlyList Sections); public sealed record RejectBriefRequest(string Comments); +// The unmasked BIG-nummer, returned only from the audited + step-up-gated reveal +// endpoint (PRD-0002 §5c). Never logged. +public sealed record RevealBigNummerResponse(string BigNummer); // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks. public sealed record MeDto(IReadOnlyList Capabilities); diff --git a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs index fb694df..110d45b 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs @@ -64,6 +64,14 @@ public static class Authz /// have no per-resource state to weigh, so role IS the whole decision here. public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin; + /// 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 + /// real system resolves it from the app overlay independent of role. The reveal itself + /// is additionally step-up-gated + audited at the endpoint. (Illustrated on the + /// BIG-nummer because no BSN travels the wire — see PRD note.) + public static bool CanRevealBigNummer(Principal principal) => principal.Role == PrincipalRole.Drafter; + /// Resource-aware decision for the screen DTO: "would this action succeed right /// now" — role/SoD AND the brief's current status. This is what the UI renders; /// it never re-derives these booleans itself. @@ -71,5 +79,7 @@ public static class Authz CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected", CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted", CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted", - CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved"); + CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved", + // PII reveal is status-independent (§5c) — unlike the action gates above. + CanRevealBigNummer: CanRevealBigNummer(principal)); } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 873005b..2dd1979 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -346,6 +346,30 @@ api.MapPost("/brief/send", (HttpContext ctx) => .Produces() .ProducesProblem(StatusCodes.Status409Conflict); +// Field-level PII reveal (PRD-0002 §5c/§5d, phase P2): the case screen ships the +// BIG-nummer masked (see ToView). Unmasking requires the reveal capability AND a +// step-up (stubbed here as the X-Step-Up header); every attempt — allow or deny — is +// audited with NO PII (AuditAuthz). The unmasked value is returned only on allow, +// and never written to a log line. +api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => +{ + var principal = Authz.ResolvePrincipal(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/" + DocumentStore.DemoOwner, allowed, principal); + if (!allowed) + return Results.Problem( + detail: canReveal + ? "Aanvullende verificatie vereist om het BIG-nummer te tonen." + : "U mag het BIG-nummer niet inzien.", + statusCode: StatusCodes.Status403Forbidden); + return Results.Ok(new RevealBigNummerResponse(SeedData.Registration.BigNummer)); +}) +// Hand-written fetch on the FE (needs a per-call X-Step-Up header) — excluded from the +// OpenAPI doc, same seam as /brief/preview and uploads. +.ExcludeFromDescription(); + // Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the // same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → // blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent @@ -435,12 +459,34 @@ app.Run(); static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; // One gate for every org-template endpoint — the enforce twin of the -// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). -static IResult OrgAdmin(HttpContext ctx, Func action) => - Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx)) - ? action() - : Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", - statusCode: StatusCodes.Status403Forbidden); +// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial +// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their +// own effect, e.g. publish). +IResult OrgAdmin(HttpContext ctx, Func action) +{ + var principal = Authz.ResolvePrincipal(ctx); + if (Authz.CanManageOrgTemplates(principal)) return action(); + AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal); + return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", + statusCode: StatusCodes.Status403Forbidden); +} + +// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII — +// action, resource ref, allow/deny, acting role, correlation id. Never the value that +// was (or wasn't) revealed. Mirrors the no-PII Submit audit below. +void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, Principal principal) +{ + var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; + app.Logger.LogInformation( + "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}", + action, resource, allowed ? "allow" : "deny", principal.Role, cid); +} + +// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail +// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree. +static string MaskTail(string value, int keep) => + value.Length <= keep ? new string('*', value.Length) + : new string('*', value.Length - keep) + value[^keep..]; static string Now() => DateTimeOffset.UtcNow.ToString("o"); @@ -453,7 +499,9 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null), // The case this letter is about — joined from the seeded zorgverlener so the // behandel scherm can show whom/what it concerns without brief/ importing registratie. - new CaseContextDto(SeedData.Registration.Naam, SeedData.Registration.BigNummer, e.Beroep, BriefSeed.AanvraagReferentie)); + // The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal + // endpoint returns the full value, gated + audited. + new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. diff --git a/backend/swagger.json b/backend/swagger.json index 5a14d2c..8c81580 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -1295,6 +1295,9 @@ }, "canSend": { "type": "boolean" + }, + "canRevealBigNummer": { + "type": "boolean" } }, "additionalProperties": false diff --git a/backend/tests/BigRegister.Tests/AuthzTests.cs b/backend/tests/BigRegister.Tests/AuthzTests.cs index 90109a1..3fd50eb 100644 --- a/backend/tests/BigRegister.Tests/AuthzTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzTests.cs @@ -65,4 +65,20 @@ public class AuthzTests Assert.Empty(Authz.RoleCapabilities(Drafter)); Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver)); } + + [Fact] + public void CanRevealBigNummer_only_for_the_case_drafter_behandelaar() + { + Assert.True(Authz.CanRevealBigNummer(Drafter)); + Assert.False(Authz.CanRevealBigNummer(Approver)); + } + + [Fact] + public void Decisions_CanRevealBigNummer_is_status_independent() + { + // Unlike the action gates, PII reveal does not depend on the brief's status. + Assert.True(Authz.Decisions(Drafter, "draft", DrafterId).CanRevealBigNummer); + Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer); + Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer); + } } diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index aed64c7..6125c88 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -68,12 +68,48 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu Assert.Contains(view.AvailablePassages, p => p.Besluit == "negatief" && p.Reason == "onvoldoende_scholing"); // Case context is joined onto the screen DTO for the behandel scherm header. - Assert.Equal("19012345601", view.CaseContext.BigNummer); + // The BIG-nummer ships MASKED by default (PRD-0002 §5c) — reveal is a separate call. + Assert.Equal("********601", view.CaseContext.BigNummer); Assert.Equal("arts", view.CaseContext.Beroep); Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam)); Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie)); } + // --- Field-level PII reveal (PRD-0002 §5c/§5d, phase P2) --- + + [Fact] + public async Task Reveal_returns_the_unmasked_BIG_nummer_for_the_drafter_with_step_up() + { + BriefStore.Reset(); + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); + req.Headers.Add("X-Step-Up", "true"); // no X-Role → drafter (the capable role) + var res = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + var body = await res.Content.ReadFromJsonAsync(); + Assert.Equal("19012345601", body!.BigNummer); + } + + [Fact] + public async Task Reveal_is_forbidden_without_the_step_up() + { + BriefStore.Reset(); + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); // drafter, no step-up + var res = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode); + } + + [Fact] + public async Task Reveal_is_forbidden_for_a_role_without_the_capability() + { + BriefStore.Reset(); + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); + req.Headers.Add("X-Role", "approver"); + req.Headers.Add("X-Step-Up", "true"); // capability missing → still denied + var res = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode); + } + [Fact] public async Task Save_is_drafter_only() { diff --git a/docs/backlog/WP-18-abac-capability-spine.md b/docs/backlog/WP-18-abac-capability-spine.md index 7e2598a..067e343 100644 --- a/docs/backlog/WP-18-abac-capability-spine.md +++ b/docs/backlog/WP-18-abac-capability-spine.md @@ -3,6 +3,18 @@ Status: done (7ec13d8) Phase: 5 — productie-volwassenheid +> **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine: +> - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked +> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a +> step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks +> it. Realized on the BIG-nummer, not the BSN, because **no BSN travels the wire** (see +> PRD-0002 §5c note). +> - **P3-lite audit + guard (§8, §6):** a no-PII `AuditAuthz` log line records reveal +> attempts (allow/deny) and org-admin denials; the already-built `capabilityGuard` is +> now wired onto the `brief/huisstijl` admin route. +> +> Still unbuilt: data-scoping (§5b), real step-up/MFA, break-glass. + ## Why The single biggest gap between this POC and a production SSP: identity carries no diff --git a/docs/prd/0002-attribute-based-access-control.md b/docs/prd/0002-attribute-based-access-control.md index 92bec75..4b5862f 100644 --- a/docs/prd/0002-attribute-based-access-control.md +++ b/docs/prd/0002-attribute-based-access-control.md @@ -137,6 +137,12 @@ canonical case (art. 9 / special-category data): - A `canRevealBsn` flag gates an explicit reveal action; reveal requires **step-up** (§5d) and is **audited** (§8). +> **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in +> the faked login and is never persisted). The sensitive identifier the backend *does* serve is the +> **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal +> is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire +> equivalent of this BSN illustration. + Precedent already in the code: the client persists **only `naam`, never the BSN**, to `sessionStorage` (`src/app/auth/application/session.store.ts:40-47`) — this PRD generalizes that instinct to every PII field, enforced server-side. @@ -224,7 +230,14 @@ action, resource, env)`), used **on every endpoint** — not merely to _emit_ fl Convert the brief drafter/approver gate from `currentRole()` to a real `brief:approve` capability (verified principal, keep the SoD `approver != drafter` check). - **P2 — Data + field.** Row-level scoping on list endpoints; server-side PII redaction + `canRevealBsn`. + - _Field-level reveal delivered_ (WP-18 follow-up): the backoffice case screen ships the + **BIG-nummer** masked (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`), + revealed by the step-up-gated, audited `POST /brief/reveal-bignummer`. Realized on the + BIG-nummer, **not the BSN** — see the §5c note. Row-level scoping (§5b) still unbuilt. - **P3 — Step-up & audit.** MFA/assurance preconditions, break-glass, and the authorization audit log. + - _Audit log delivered (lite)_: `AuditAuthz` logs reveal attempts (allow/deny) and + org-admin denials, no PII (§8). Step-up is stubbed as the `X-Step-Up` header (§5d); the + `capabilityGuard` is wired onto the admin route (§6). MFA and break-glass still unbuilt. ## 10. Cross-references diff --git a/package-lock.json b/package-lock.json index cc16133..ef75c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -809,71 +809,6 @@ } } }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@angular/core": { "version": "22.0.5", "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.0.5.tgz", @@ -944,71 +879,6 @@ "@angular/compiler-cli": "22.0.5" } }, - "node_modules/@angular/localize/node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/localize/node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@angular/localize/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular/localize/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@angular/platform-browser": { "version": "22.0.5", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.5.tgz", @@ -1156,21 +1026,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1186,6 +1056,23 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3218,47 +3105,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@compodoc/compodoc/node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@compodoc/compodoc/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@compodoc/compodoc/node_modules/@babel/plugin-transform-private-methods": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", @@ -3397,13 +3243,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@compodoc/compodoc/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/@compodoc/compodoc/node_modules/ora": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", diff --git a/package.json b/package.json index 643f229..b5fb9af 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.", "overrides": { + "@babel/core": "^7.29.7", "picomatch": "^4.0.4", "esbuild": "^0.28.1", "http-proxy-middleware": "^3.0.7", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 5f023a5..06baada 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,6 @@ import { Routes } from '@angular/router'; import { ShellComponent } from '@shared/layout/shell/shell.component'; -import { authGuard } from '@auth/auth.guard'; +import { authGuard, capabilityGuard } from '@auth/auth.guard'; export const routes: Routes = [ { @@ -53,7 +53,10 @@ export const routes: Routes = [ }, { path: 'brief/huisstijl', - canActivate: [authGuard], + // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default + // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces + // via the OrgAdmin gate — the guard just avoids loading a page that would 403. + canActivate: [capabilityGuard('orgtemplate:edit')], loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), }, diff --git a/src/app/brief/application/brief.store.spec.ts b/src/app/brief/application/brief.store.spec.ts index 080cac3..59ab573 100644 --- a/src/app/brief/application/brief.store.spec.ts +++ b/src/app/brief/application/brief.store.spec.ts @@ -5,6 +5,7 @@ import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/b import { OrgTemplate } from '@brief/domain/org-template'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; +import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { BriefStore } from './brief.store'; const decisions: BriefDecisions = { @@ -12,6 +13,7 @@ const decisions: BriefDecisions = { canApprove: true, canReject: true, canSend: true, + canRevealBigNummer: true, }; const brief: Brief = { @@ -249,3 +251,37 @@ describe('BriefStore.previewLetter', () => { expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.'); }); }); + +describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => { + afterEach(() => vi.restoreAllMocks()); + + // Loaded with a MASKED BIG-nummer, as the server ships it by default. + const maskedView: BriefView = { ...view, caseContext: { ...caseContext, bigNummer: '********601' } }; + + it('swaps the masked value for the revealed one on success', async () => { + const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) }); + await store.load(); + expect(store.caseContext()?.bigNummer).toBe('********601'); + vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({ + ok: true, + value: '19012345601', + }); + + await store.revealBigNummer(); + expect(store.caseContext()?.bigNummer).toBe('19012345601'); + expect(store.lastError()).toBeNull(); + }); + + it('keeps the value masked and surfaces the error on failure', async () => { + const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) }); + await store.load(); + vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({ + ok: false, + error: 'geweigerd', + }); + + await store.revealBigNummer(); + expect(store.caseContext()?.bigNummer).toBe('********601'); // unchanged + expect(store.lastError()).toBe('geweigerd'); + }); +}); diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index 0b1f542..da7f84c 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -15,6 +15,7 @@ import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-di import { OrgTemplate } from '@brief/domain/org-template'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; +import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { uploadContentUrl } from '@shared/upload/upload.adapter'; /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union @@ -40,6 +41,7 @@ type LoadedBriefState = Extract; export class BriefStore { private adapter = inject(BriefAdapter); private previewAdapter = inject(LetterPreviewAdapter); + private revealAdapter = inject(RevealBigNummerAdapter); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -122,6 +124,8 @@ export class BriefStore { readonly canApprove = computed(() => this.decisions()?.canApprove ?? false); readonly canReject = computed(() => this.decisions()?.canReject ?? false); readonly canSend = computed(() => this.decisions()?.canSend ?? false); + /** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */ + readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false); private decisions = computed(() => { const s = this.model(); @@ -246,6 +250,19 @@ export class BriefStore { window.open(URL.createObjectURL(r.value), '_blank'); } + /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability + + step-up and audits the attempt; on success we swap the masked value in the + already-loaded caseContext (a field update, not a reload). The step-up gesture + itself is the UI's concern — this command just runs the audited server call. */ + async revealBigNummer() { + const r = await this.revealAdapter.reveal(); + if (!r.ok) { + this.actionState.set({ tag: 'Failed', error: r.error }); + return; + } + this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c)); + } + // A transition: flush any pending save, call the server (authoritative), then mirror // the returned status through the pure reducer's guarded transition. private async transition(action: () => Promise>) { diff --git a/src/app/brief/domain/brief.machine.spec.ts b/src/app/brief/domain/brief.machine.spec.ts index bd24fcd..f8f9344 100644 --- a/src/app/brief/domain/brief.machine.spec.ts +++ b/src/app/brief/domain/brief.machine.spec.ts @@ -61,6 +61,7 @@ const decisions: BriefDecisions = { canApprove: true, canReject: true, canSend: true, + canRevealBigNummer: true, }; const loaded = ( @@ -252,6 +253,7 @@ describe('brief.machine reduce', () => { canApprove: false, canReject: false, canSend: false, + canRevealBigNummer: false, }; const approved = reduce(submitted, { tag: 'Approved', diff --git a/src/app/brief/domain/brief.ts b/src/app/brief/domain/brief.ts index 78a5ea4..e913282 100644 --- a/src/app/brief/domain/brief.ts +++ b/src/app/brief/domain/brief.ts @@ -135,4 +135,7 @@ export interface BriefDecisions { readonly canApprove: boolean; readonly canReject: boolean; readonly canSend: boolean; + /** Field-level PII (PRD-0002 §5c): may the acting principal unmask the case + BIG-nummer, which the server ships masked? Status-independent. */ + readonly canRevealBigNummer: boolean; } diff --git a/src/app/brief/infrastructure/brief.adapter.spec.ts b/src/app/brief/infrastructure/brief.adapter.spec.ts index 13d8534..6085678 100644 --- a/src/app/brief/infrastructure/brief.adapter.spec.ts +++ b/src/app/brief/infrastructure/brief.adapter.spec.ts @@ -62,7 +62,7 @@ const view: BriefViewDto = { reason: 'onvoldoende_scholing', }, ], - decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false }, + decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false, canRevealBigNummer: false }, orgTemplate: { subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', @@ -107,6 +107,7 @@ describe('brief.adapter parse boundary', () => { canApprove: true, canReject: true, canSend: false, + canRevealBigNummer: false, }); // Guided-drafting tags survive the boundary; the untagged passage has neither. expect(r.value.availablePassages[0].besluit).toBeUndefined(); @@ -155,6 +156,13 @@ describe('brief.adapter parse boundary', () => { expect( parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok, ).toBe(false); + // The PII-reveal flag (PRD-0002 §5c) is required at the boundary too. + expect( + parseBriefView({ + ...view, + decisions: { ...view.decisions, canRevealBigNummer: undefined as never }, + }).ok, + ).toBe(false); }); it('narrows node variants and rejects unknown ones', () => { diff --git a/src/app/brief/infrastructure/brief.adapter.ts b/src/app/brief/infrastructure/brief.adapter.ts index a38159d..9bef9c2 100644 --- a/src/app/brief/infrastructure/brief.adapter.ts +++ b/src/app/brief/infrastructure/brief.adapter.ts @@ -314,7 +314,8 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result> { + let res: Response; + try { + res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, { + method: 'POST', + headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }, + }); + } catch { + return err(REVEAL_FAILED); + } + if (!res.ok) return err(await errorMessage(res)); + const body: unknown = await res.json().catch(() => null); + // Trust boundary: validate the shape before handing back a plain string. + if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') { + return ok((body as { bigNummer: string }).bigNummer); + } + return err(REVEAL_FAILED); + } +} + +async function errorMessage(res: Response): Promise { + try { + return problemDetail(await res.json(), REVEAL_FAILED); + } catch { + return REVEAL_FAILED; + } +} diff --git a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts index 464ac17..a3a6c40 100644 --- a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts +++ b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts @@ -91,7 +91,12 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
{{ caseContext().aanvraagReferentie }} {{ caseContext().zorgverlenerNaam }} - {{ bigLabel() }} {{ caseContext().bigNummer }} + + {{ bigLabel() }} {{ caseContext().bigNummer }} + @if (canRevealBigNummer() && isMasked()) { + {{ revealLabel() }} + } + {{ caseContext().beroep }}
@@ -159,11 +164,24 @@ export class BehandelSchermComponent { caseContext = input.required(); canSubmit = input(false); busy = input(false); + /** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */ + canRevealBigNummer = input(false); edit = output(); submit = output(); preview = output(); locate = output(); + revealBigNummer = output(); + + /** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has + no `*`, so the reveal action hides itself — no separate "revealed" flag needed. */ + protected isMasked = computed(() => this.caseContext().bigNummer.includes('*')); + + /** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture + before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */ + protected onReveal() { + if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit(); + } private previewDialog = viewChild>('previewDialog'); @@ -214,6 +232,10 @@ export class BehandelSchermComponent { protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`); protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`); protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`); + protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`); + protected stepUpPrompt = input( + $localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`, + ); protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`); protected openDocumentLabel = input( $localize`:@@brief.preview.openDocument:Openen als document (PDF)`, diff --git a/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts b/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts index a90ff23..e9722e5 100644 --- a/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts +++ b/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts @@ -79,6 +79,19 @@ export const WithContent: Story = { }, }; +/** Field-level PII (PRD-0002 §5c): the case BIG-nummer arrives MASKED, as the server + ships it. The behandelaar holds the reveal capability, so the "Toon BIG-nummer" + action shows — it runs a step-up confirm and an audited server call before unmasking. */ +export const MaskedBigNummer: Story = { + args: { + brief: brief({ tag: 'draft' }), + diagnostics: [], + canSubmit: false, + caseContext: { ...caseContext, bigNummer: '********601' }, + canRevealBigNummer: true, + }, +}; + /** Rejected: the drafter reopens; the rejection comments show above the editor. */ export const Rejected: Story = { render: (args) => { diff --git a/src/app/brief/ui/brief.page.ts b/src/app/brief/ui/brief.page.ts index 2f480dd..3022fd7 100644 --- a/src/app/brief/ui/brief.page.ts +++ b/src/app/brief/ui/brief.page.ts @@ -95,9 +95,11 @@ import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-sche [caseContext]="caseContext" [canSubmit]="store.canSubmit()" [busy]="store.busy()" + [canRevealBigNummer]="store.canRevealBigNummer()" (edit)="store.edit($event)" (submit)="store.submit()" (preview)="store.previewLetter()" + (revealBigNummer)="store.revealBigNummer()" /> } @else { diff --git a/src/app/brief/ui/letter-composer/letter-composer.stories.ts b/src/app/brief/ui/letter-composer/letter-composer.stories.ts index 7e72354..5e7d519 100644 --- a/src/app/brief/ui/letter-composer/letter-composer.stories.ts +++ b/src/app/brief/ui/letter-composer/letter-composer.stories.ts @@ -144,6 +144,7 @@ export const SubmittedApprover: Story = { canApprove: true, canReject: true, canSend: false, + canRevealBigNummer: false, }), }; export const ApprovedSender: Story = { @@ -153,6 +154,7 @@ export const ApprovedSender: Story = { canApprove: false, canReject: false, canSend: true, + canRevealBigNummer: false, }), }; export const Sent: Story = { @@ -162,5 +164,6 @@ export const Sent: Story = { canApprove: false, canReject: false, canSend: false, + canRevealBigNummer: false, }), }; diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index c2d28c9..6c5f654 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -1579,6 +1579,7 @@ export interface BriefDecisionsDto { canApprove?: boolean; canReject?: boolean; canSend?: boolean; + canRevealBigNummer?: boolean; } export interface BriefDto {