fix(brief): keep the dev hatches out of production builds (RB-11)

BIO-012: roleInterceptor/subjectInterceptor are correctly registered
only under isDevMode(), but three hand-written fetch adapters
(reveal-bignummer, letter-preview, org-template's proefbrief) bypass
HttpClient and set X-Role/X-Subject themselves with no guard. The
readers underneath, role.ts and subject.ts, were ungated too: they
read ?role=/?subject= and wrote it into sessionStorage on any
navigation, in any build -- for ?subject= that value is a BSN, which
is exactly what SessionStore's G1 comment promises never happens.

Gate both layers: currentRole()/currentSubject() return their safe
default immediately outside isDevMode() (no query-param read, no
sessionStorage write), and the three adapters additionally wrap their
headers in isDevMode() so a production request carries neither header
at all, matching what an HttpClient request already does once the
interceptors aren't registered.

TE-002: reveal-bignummer's response-shape validation was a "Trust
boundary" a spec could only reach by stubbing globalThis.fetch.
Exported it as parseRevealed(body), matching the other 30 parse*
boundaries in the repo. Same treatment for letter-preview's
errorMessage and org-template's proefbrief error mapping (extracted
from an inline try/catch into a named, exported function first, since
it wasn't already separate).

BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally,
so the backend's step-up precondition constrained nothing. reveal()
now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable
only after the UI's confirm() gesture -- is the one that supplies it,
so the literal no longer lives in the transport adapter.

BIO-006(b): documented in roles-and-access.md that drafter is also
the backend's fallback identity (StubIdentityProvider's catch-all
arm), not just the dev switcher's initial choice -- so the
least-privilege consequence of it also being the only role that may
reveal a BSN is visible.

Doc correction, same diff: roles-and-access.md's "wired only under
isDevMode()" claim was false for the three hand-written fetch paths;
it now says where the gate lives (interceptor registration and the
reader functions) so it doesn't go stale the same way again.
CLAUDE.md's dev-only claims needed no correction -- they already
noted these three calls bypass the interceptor.

Every fix has a test confirmed red by temporarily reverting the
source change and rerunning the suite before restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 14:20:47 +02:00
co-authored by Claude Opus 5
parent ae7781efef
commit 772c47ea43
14 changed files with 632 additions and 37 deletions
@@ -233,9 +233,12 @@ export class BriefStore implements PendingSave {
/** 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. */
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
— this command is only reachable once that gesture has happened, so it is the one
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
hardcodes the header). */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
const r = await this.revealAdapter.reveal(true);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
// globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('errorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
'Geen toegang.',
);
});
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
});
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
const okResponse = () =>
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
history.pushState({}, '', '/');
sessionStorage.clear();
});
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
setDevMode(false);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
});
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
setDevMode(true);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Role']).toBeDefined();
expect(headers['X-Subject']).toBe('111222333');
});
});
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { currentSubject } from '@shared/infrastructure/subject';
@@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in).
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
*
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
@@ -43,7 +46,9 @@ export class LetterPreviewAdapter {
const subject = currentSubject();
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
cache: 'no-store',
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) },
headers: isDevMode()
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
: {},
});
} catch {
return err(PREVIEW_FAILED);
@@ -53,7 +58,9 @@ export class LetterPreviewAdapter {
}
}
async function errorMessage(res: Response): Promise<string> {
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
can call it directly instead of stubbing `globalThis.fetch`. */
export async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
import {
parseOrgTemplateAdminView,
proefbriefErrorMessage,
PROEFBRIEF_FAILED,
} from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
expect(r.ok).toBe(false);
});
});
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
).toBe('Niet gevonden.');
});
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
});
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
});
});
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
@@ -26,10 +26,13 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
* build never sends it from this hand-written call either (BIO-012).
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
if (!res.ok) return err(await proefbriefErrorMessage(res));
return ok(await res.blob());
}
}
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
export async function proefbriefErrorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
} catch {
return PROEFBRIEF_FAILED;
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
describe('parseRevealed (TE-002 trust boundary)', () => {
it('accepts a well-formed body', () => {
const r = parseRevealed({ bigNummer: '12345678' });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('12345678');
});
// The finding's own named case: a numeric bigNummer must be rejected, not
// coerced — this is a PII reveal, not a display formatter.
it('rejects a bigNummer sent as a number', () => {
const r = parseRevealed({ bigNummer: 42 });
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
});
it('rejects a missing bigNummer field', () => {
expect(parseRevealed({}).ok).toBe(false);
});
it('rejects null and non-object bodies', () => {
expect(parseRevealed(null).ok).toBe(false);
expect(parseRevealed(undefined).ok).toBe(false);
expect(parseRevealed('12345678').ok).toBe(false);
expect(parseRevealed(42).ok).toBe(false);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
const okResponse = () =>
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
beforeEach(() => setDevMode(true));
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
});
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new RevealBigNummerAdapter().reveal(false);
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
await new RevealBigNummerAdapter().reveal(true);
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(headersWithStepUp['X-Step-Up']).toBe('true');
});
it('sends X-Role only under isDevMode()', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
setDevMode(false);
await new RevealBigNummerAdapter().reveal(true);
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(prodHeaders['X-Role']).toBeUndefined();
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
setDevMode(true);
await new RevealBigNummerAdapter().reveal(true);
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(devHeaders['X-Role']).toBeDefined();
});
});
@@ -1,36 +1,52 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } 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 '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/** Exported so specs can assert against the same message id instead of retyping the
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
export 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.
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
* so the header now reflects that gesture instead of being a constant baked into this
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
*
* 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.
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
* from this hand-written call either (BIO-012).
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
async reveal(stepUp: boolean): 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' },
headers: {
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
...(stepUp ? { '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.
return parseRevealed(await res.json().catch(() => null));
}
}
/** Trust boundary: validate the untrusted response shape before handing back a plain
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
export function parseRevealed(body: unknown): Result<string, string> {
if (
typeof body === 'object' &&
body !== null &&
@@ -40,7 +56,6 @@ export class RevealBigNummerAdapter {
}
return err(REVEAL_FAILED);
}
}
async function errorMessage(res: Response): Promise<string> {
try {
@@ -0,0 +1,179 @@
# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012,
TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11
## What was wrong
**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` /
`subjectInterceptor` chain is correctly registered only under `isDevMode()`
(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers
themselves with no guard at all:
- `reveal-bignummer.adapter.ts:26``'X-Role': currentRole(), 'X-Step-Up': 'true'`
- `letter-preview.adapter.ts:46``'X-Role': currentRole()`, plus `'X-Subject'` when present
- `org-template.adapter.ts:79``'X-Role': currentRole()`
The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the
`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any
navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc
comment argued at length that the BSN must never leave `SessionStore` and then routed it
through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both
are wired only under `isDevMode()` — they do not exist in a production build", which was
false for exactly these three call sites.
**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation
in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary")
lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A
spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred,
un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the
finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a
function) in `org-template.adapter.ts`'s `proefbrief()`.
**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent
`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's
`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached
the endpoint and constrained nothing.
**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.**
`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an
unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer`
grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a
BSN" without noting that it is also the fallback identity, so the least-privilege
consequence was invisible.
## What changed
| File | Change |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write |
| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN |
| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported |
| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported |
| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported |
| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter |
| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity |
| 6 new/extended `*.spec.ts` | see "Verification" below |
**The fix is two layers, not one, because the finding named both.** Gating only
`currentRole()`/`currentSubject()` would already stop the query param and the
`sessionStorage` write from working outside `isDevMode()` — the three adapters would
then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production.
The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the
three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly
matching what a `HttpClient` request already does once `roleInterceptor` is not
registered — the two paths now agree on production behaviour instead of merely agreeing
on the resulting header value.
**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a
`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must
survive into production (in stubbed form) until a real step-up exists. Nesting it inside
`isDevMode()` would make the reveal endpoint permanently unreachable in a production
build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when
`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s
`onReveal()` confirm — calls it.
## Judgement calls
- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate
function".** The finding's remediation text says "the proefbrief error mapping in
`org-template.adapter.ts` — both are already separate functions and only need
`export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading
the file: the other two adapters do have a standalone `errorMessage`/similar function,
but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the
`try { … } catch { … }` block, not a named function. This is a minor factual
imprecision in the finding, not a blocker — I extracted the same inline logic into a
named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two
siblings. The result matches the finding's intent (a callable, spec'd trust boundary)
even though the starting shape needed one extra step the finding didn't mention.
- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.**
`behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can
reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own
docstring says the step-up gesture "is the UI's concern". Threading a boolean through
the component's `output<void>()` and the page's template binding would touch three more
files for no behavioural change, since the call graph already guarantees confirmation
happened first. I moved the literal one layer up instead — out of the adapter (the
transport) and into the store (the command that is exclusively reachable via the
confirmed gesture) — which is the smallest change consistent with "not from the
adapter's literal" and with this repo's ui → application → infrastructure layering (ui
cannot call infrastructure directly to pass the flag down any other way).
- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating
`currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around
the headers object is not strictly load-bearing for `X-Subject` (already `undefined`
outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'`
vs. no header) rather than any security outcome (the backend treats both identically).
I kept the adapter-level gate anyway so the security posture is visible by inspection
at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern
— rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it.
- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via
`inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient`
purely to reach a method that doesn't use either. I judged that disproportionate to the
marginal coverage gained, since the identical `isDevMode()` pattern is already
exercised end-to-end (via `fetch` stubbing) on the other two adapters
(`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the
underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a
residual rather than silently skipped.
- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names
the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked
from `debug-state.component.ts`, which is itself rendered only under
`shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but
wasn't asked for and has no reachable production call site to protect — left alone to
keep the diff to what the finding actually named.
## Consequences worth knowing
- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under
`isDevMode()`" line is accurate as of this commit — the gate now lives in the
interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves.
Before this commit, the sentence was false for the three hand-written `fetch` paths; the
doc has been extended, not merely left as-is, to say _where_ the gate lives so a future
reader doesn't have to rediscover why the interceptor site alone wasn't sufficient.
- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod
builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three
hand-written `fetch` adapters specifically (the accompanying sentence already says they
"bypass the interceptor", which stays true — they still don't go through
`HttpClient`). Those claims were already compatible with a fix landing here; they made
no false statement that needed walking back.
- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012
evidence and this ticket's brief both discuss it, but the doc file never named
`?subject=`/`X-Subject` before this change and still doesn't get a dedicated section —
only the new paragraph under "How to switch role" mentions it in passing. A full
`?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is
arguably worth a follow-up doc pass, but out of scope for a security-focused ticket
about production leakage.
- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from
the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's
drift check would otherwise fail on the 6 new `describe` blocks this ticket adds.
## Verification
Every fix below was confirmed **red without it** by temporarily reverting the source
change (tests unchanged) and re-running the affected suite, then restoring the fix:
- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard
from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured,
still written to `sessionStorage`).
- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()`
turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written
to `sessionStorage`).
- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting
`reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }`
turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the
`stepUp` argument; `X-Role` sent regardless of `isDevMode()`).
New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no
`globalThis.fetch` stub needed for the pure halves):
| File | Covers |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` |
| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` |
| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` |
| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour |
| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour |
`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`,
full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend
`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift
checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and
`dotnet test` (241 passed).
+19 -1
View File
@@ -20,7 +20,15 @@ acting role to exercise the drafter/approver/admin flows.
## How to switch role (dev only)
Both are wired only under `isDevMode()` — they do not exist in a production build.
Both are wired only under `isDevMode()` — they do not exist in a production build. That
gate lives in two places: the `roleInterceptor` registration (`app.config.ts`) for every
`HttpClient` request, **and** inside `role.ts`'s `currentRole()` itself, because three
hand-written `fetch` calls (`reveal-bignummer.adapter.ts`, `letter-preview.adapter.ts`,
`org-template.adapter.ts`'s proefbrief) read the role directly and bypass the
interceptor entirely (RB-11/BIO-012). Before RB-11, `currentRole()` had no such gate, so
`?role=` kept working through those three calls in a production build even though this
page said otherwise; the same defect applied to `?subject=` and `subject.ts`, which is
how a BSN reached `sessionStorage` in any build.
- **Dev switcher (easiest):** open the `⚙ state` panel (bottom-right in a dev build) and pick a
role from the **role** dropdown. The page reloads with the new role.
@@ -66,6 +74,16 @@ The admin pages appear in the header nav and in the dashboard **"Beheer"** secti
matching capability is present — otherwise they are reachable only by URL (and the route guard
redirects a user who lacks the capability back to `/dashboard`).
**`drafter` is also the backend's fallback identity (BIO-006).** It is not only the dev
switcher's initial selection — `StubIdentityProvider`'s role switch resolves **any**
request with no `X-Role` header at all (or an unrecognised one) to `drafter` too. Because
`drafter` is also the _only_ role that may reveal a BIG-nummer, the least-privilege
consequence is real: an unauthenticated or misconfigured caller inherits the PII-reveal
capability by default, rather than the weakest one. This is acceptable only because the
POC has no real identity or step-up yet (see the pre-production compliance checklist —
binding the reveal to an app-overlay attribute instead of the coarse role is a named,
not-yet-built item); it must not survive real identity and step-up.
## The one principle
Identity (AD/OIDC, faked here) supplies **coarse roles**; the app owns a **fine-grained capability**
+53 -1
View File
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 406 frontend behaviours across
**is** the suite, reshaped for a business reader. 431 frontend behaviours across
8 contexts; 217 backend behaviours across 36 test
classes.
@@ -186,6 +186,16 @@ classes.
- swaps the masked value for the revealed one on success
- keeps the value masked and surfaces the error on failure
#### LetterPreviewAdapter.preview (BIO-012)
- sends no X-Role/X-Subject headers outside isDevMode()
- sends X-Role (and X-Subject when known) under isDevMode()
#### RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)
- sends X-Step-Up only when the caller passes stepUp: true
- sends X-Role only under isDevMode()
#### besluitGuidance
- positief: counts inserted passages, no reden needed (positief has no redenen)
@@ -237,6 +247,12 @@ classes.
- marks added, removed, changed and unchanged by blockId
- changedBlocks drops unchanged and keeps added/removed/changed
#### errorMessage (TE-002 trust boundary)
- surfaces the ProblemDetails detail when present
- falls back to PREVIEW_FAILED when the body has no detail
- falls back to PREVIEW_FAILED when the body is not JSON
#### inferSelection
- round-trips a positief selection
@@ -275,6 +291,13 @@ classes.
- rejects a missing count field
- rejects a malformed history entry
#### parseRevealed (TE-002 trust boundary)
- accepts a well-formed body
- rejects a bigNummer sent as a number
- rejects a missing bigNummer field
- rejects null and non-object bodies
#### passagesForBesluit
- positief = shared intro + the positief passage, no negatief/reason passages
@@ -283,6 +306,12 @@ classes.
- preserves library order (= reading order)
- never offers non-kern passages
#### proefbriefErrorMessage (TE-002 trust boundary)
- surfaces the ProblemDetails detail when present
- falls back to PROEFBRIEF_FAILED when the body has no detail
- falls back to PROEFBRIEF_FAILED when the body is not JSON
#### redenenFor
- derives reason checkboxes (code + label) from the negatief reason passages
@@ -635,6 +664,29 @@ classes.
- applies the pure update on dispatch
- dispatch from inside an effect does not self-loop
#### currentRole (dev mechanism)
- lists the three roles
- reads a valid ?role= from the URL and persists it for the tab
- falls back to drafter when nothing is set or the value is invalid
#### currentRole (dev mechanism) outside isDevMode() (production build)
- ignores a ?role= in the URL and returns the default
- never touches sessionStorage
- ignores a role already sitting in sessionStorage from a prior dev session
#### currentSubject (dev mechanism)
- reads a ?subject= from the URL and persists it for the tab
- returns undefined when nothing has ever been set
#### currentSubject (dev mechanism) outside isDevMode() (production build)
- ignores a ?subject= (a BSN) in the URL and returns undefined
- never writes the BSN into sessionStorage
- ignores a subject already sitting in sessionStorage from a prior dev session
#### delete flow (optimistic, revertible)
- UploadDeleteRequested keeps the documentId for revert
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { currentRole, ROLES } from './role';
const setUrl = (search: string) => history.pushState({}, '', search || '/');
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('currentRole (dev mechanism)', () => {
beforeEach(() => {
sessionStorage.clear();
setUrl('/');
setDevMode(true);
});
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
});
it('lists the three roles', () => {
expect(ROLES).toEqual(['drafter', 'approver', 'admin']);
});
it('reads a valid ?role= from the URL and persists it for the tab', () => {
setUrl('?role=admin');
expect(currentRole()).toBe('admin');
setUrl('/'); // navigation drops the query param — value stays sticky
expect(currentRole()).toBe('admin');
});
it('falls back to drafter when nothing is set or the value is invalid', () => {
expect(currentRole()).toBe('drafter');
setUrl('?role=nonsense');
expect(currentRole()).toBe('drafter');
});
// BIO-012: the three hand-written `fetch` adapters call this function directly,
// bypassing `roleInterceptor`'s own isDevMode()-gated registration — so the gate
// has to hold here, not just at the interceptor, or `?role=` keeps working in a
// production build through that side door.
describe('outside isDevMode() (production build)', () => {
beforeEach(() => setDevMode(false));
it('ignores a ?role= in the URL and returns the default', () => {
setUrl('?role=admin');
expect(currentRole()).toBe('drafter');
});
it('never touches sessionStorage', () => {
setUrl('?role=admin');
currentRole();
expect(sessionStorage.getItem('dev-role')).toBeNull();
});
it('ignores a role already sitting in sessionStorage from a prior dev session', () => {
sessionStorage.setItem('dev-role', 'admin');
expect(currentRole()).toBe('drafter');
});
});
});
+11 -1
View File
@@ -1,3 +1,4 @@
import { isDevMode } from '@angular/core';
import { Role } from '@shared/domain/role';
/**
@@ -14,13 +15,22 @@ import { Role } from '@shared/domain/role';
* don't carry it), which would silently revert an admin to drafter mid-session and
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
* reset.
*
* **Gated here, not only at the interceptor (BIO-012):** the `roleInterceptor` that
* consumes this for `HttpClient` traffic is only registered under `isDevMode()`
* (`app.config.ts`), but `brief`'s three hand-written `fetch` adapters call this
* function directly, bypassing that interceptor entirely. Reading `?role=` and
* writing it to `sessionStorage` is therefore gated in the function itself — outside
* `isDevMode()` the query param is never read, `sessionStorage` is never touched, and
* the fixed default (`drafter`, the least-privileged role) is returned every time.
*/
const STORAGE_KEY = 'dev-role';
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
export function currentRole(): Role {
if (!isDevMode()) return 'drafter';
const fromUrl = new URLSearchParams(window.location.search).get('role');
if (isRole(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { currentSubject } from './subject';
const setUrl = (search: string) => history.pushState({}, '', search || '/');
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('currentSubject (dev mechanism)', () => {
beforeEach(() => {
sessionStorage.clear();
setUrl('/');
setDevMode(true);
});
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
});
it('reads a ?subject= from the URL and persists it for the tab', () => {
setUrl('?subject=111222333');
expect(currentSubject()).toBe('111222333');
setUrl('/'); // navigation drops the query param — value stays sticky
expect(currentSubject()).toBe('111222333');
});
it('returns undefined when nothing has ever been set', () => {
expect(currentSubject()).toBeUndefined();
});
// BIO-012: a BSN is art. 9 GDPR special-category data. Prior to this fix this
// function wrote it into sessionStorage on any navigation, in any build.
describe('outside isDevMode() (production build)', () => {
beforeEach(() => setDevMode(false));
it('ignores a ?subject= (a BSN) in the URL and returns undefined', () => {
setUrl('?subject=111222333');
expect(currentSubject()).toBeUndefined();
});
it('never writes the BSN into sessionStorage', () => {
setUrl('?subject=111222333');
currentSubject();
expect(sessionStorage.getItem('dev-subject')).toBeNull();
});
it('ignores a subject already sitting in sessionStorage from a prior dev session', () => {
sessionStorage.setItem('dev-subject', '111222333');
expect(currentSubject()).toBeUndefined();
});
});
});
+10
View File
@@ -1,3 +1,5 @@
import { isDevMode } from '@angular/core';
/**
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
@@ -13,6 +15,13 @@
* hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason
* that adapter already sets `X-Role` explicitly via `currentRole()`).
*
* **Gated here, not only at the interceptor (BIO-012):** `subjectInterceptor` is only
* registered under `isDevMode()`, but `letter-preview.adapter.ts` calls this function
* directly and bypasses that interceptor. The value read here is a **BSN** — a GDPR
* special-category identifier — so outside `isDevMode()` the query param is never
* read and `sessionStorage` is never written; `undefined` is returned unconditionally,
* exactly as if no `?subject=` had ever been seen.
*
* `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike
* `currentRole()` (a closed enum with a sensible default), there is no "default
* subject" to fall back to here — omitting the header entirely lets the backend's
@@ -21,6 +30,7 @@
const STORAGE_KEY = 'dev-subject';
export function currentSubject(): string | undefined {
if (!isDevMode()) return undefined;
const fromUrl = new URLSearchParams(window.location.search).get('subject');
if (fromUrl) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);