feat(security): ABAC P2/P3-lite — BIG-nummer redaction, authz audit, guard; clear dev audit

- 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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 19:59:20 +02:00
co-authored by Claude Opus 4.8
parent 0edfbba2a9
commit 5cae44f163
24 changed files with 353 additions and 211 deletions
+5 -2
View File
@@ -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),
},
@@ -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');
});
});
+17
View File
@@ -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<BriefState, { tag: 'loaded' }>;
export class BriefStore {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter);
private store = createStore<BriefState, BriefMsg>(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<Result<string, BriefView>>) {
@@ -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',
+3
View File
@@ -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;
}
@@ -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', () => {
@@ -314,7 +314,8 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
typeof dto?.canEdit !== 'boolean' ||
typeof dto.canApprove !== 'boolean' ||
typeof dto.canReject !== 'boolean' ||
typeof dto.canSend !== 'boolean'
typeof dto.canSend !== 'boolean' ||
typeof dto.canRevealBigNummer !== 'boolean'
) {
return err('brief-view: missing/invalid decisions');
}
@@ -323,6 +324,7 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
canApprove: dto.canApprove,
canReject: dto.canReject,
canSend: dto.canSend,
canRevealBigNummer: dto.canRevealBigNummer,
});
}
@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
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<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
} catch {
return REVEAL_FAILED;
}
}
@@ -91,7 +91,12 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
<div class="case-meta">
<span>{{ caseContext().aanvraagReferentie }}</span>
<span>{{ caseContext().zorgverlenerNaam }}</span>
<span>{{ bigLabel() }} {{ caseContext().bigNummer }}</span>
<span>
{{ bigLabel() }} {{ caseContext().bigNummer }}
@if (canRevealBigNummer() && isMasked()) {
<app-button variant="subtle" (click)="onReveal()">{{ revealLabel() }}</app-button>
}
</span>
<span>{{ caseContext().beroep }}</span>
</div>
</div>
@@ -159,11 +164,24 @@ export class BehandelSchermComponent {
caseContext = input.required<CaseContext>();
canSubmit = input(false);
busy = input(false);
/** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */
canRevealBigNummer = input(false);
edit = output<BriefMsg>();
submit = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
revealBigNummer = output<void>();
/** 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<ElementRef<HTMLDialogElement>>('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)`,
@@ -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) => {
+2
View File
@@ -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 {
<!-- Approver / read-only: review + approve/reject/send. -->
@@ -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,
}),
};
@@ -1579,6 +1579,7 @@ export interface BriefDecisionsDto {
canApprove?: boolean;
canReject?: boolean;
canSend?: boolean;
canRevealBigNummer?: boolean;
}
export interface BriefDto {