test: split multi-assertion specs into single-behavior tests

One behavior per test across FE machine/store specs and backend endpoint
tests, so a failure names exactly what broke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 20:33:25 +02:00
co-authored by Claude Opus 4.8
parent 5cae44f163
commit 55a0a2d166
48 changed files with 178 additions and 34 deletions
@@ -0,0 +1,219 @@
# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview
Status: Implemented · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002)
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as
> actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD
> _materializes_ that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener
> self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate,
> unbuilt context.
---
## 1. Problem
A logged-in Zorgverlener cannot see what they have started or submitted, and cannot review what they
uploaded. Concretely, today:
- Wizard drafts live in **per-wizard `sessionStorage`** (`registratie-v2`, `intake-v3`); the
**herregistratie wizard has no persistence at all**. Nothing enumerates "my in-progress applications."
- There is **no application list** — only a single optimistic boolean, `pendingHerregistratie`, in
`src/app/registratie/application/big-profile.store.ts:53`, surfaced as one dashboard alert.
- Uploaded documents **cannot be previewed or downloaded**. The backend `DocumentStore`
(`backend/src/BigRegister.Api/Data/DocumentStore.cs`) deliberately stores **metadata only, no bytes**,
and there is **no GET-content endpoint**.
- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a
422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a
submission to _succeed_ and sit in a pending (manual-review) state instead.
## 2. Goals
1. Show **all running (concept) applications** as dynamic blocks at the **top of the dashboard**, so the
user immediately sees what they initiated, with **Verder gaan** (resume) and **Annuleren** (cancel →
start fresh).
2. On **re-opening** a concept wizard, let the user **preview/download** the documents they already
uploaded.
3. Show **submitted-but-unprocessed** applications in a **pending** state on the dashboard.
4. Provide **two registratie flows** that both submit successfully (never disallow submission):
- **Auto-approved** (`diplomaHerkomst = 'duo'`) → resolves to **Goedgekeurd**.
- **Manual backoffice** (`diplomaHerkomst = 'handmatig'`) → stays **In behandeling** (pending).
5. Make the backend the **system of record** for applications (concept + submitted), per the chosen
architecture.
## 3. Non-goals / Out of scope (POC)
- The **Behandeling/backoffice application** that advances manual aanvragen (ADR-0002). Manual cases
stay pending forever in this build.
- Real auth / multi-user — a single `DemoOwner` owns everything (matches the faked DigiD session).
- Real blob storage, virus scanning, retention — document **bytes are held in-memory** and reset when
the backend restarts.
- **Withdrawing** a submitted aanvraag ("intrekken") — cancel applies to concepts only.
- Document preview for the **dev upload simulation** (`?scenario=upload-slow|upload-fail`) — that path
returns a fake `demo-*` documentId with no bytes; preview requires a real upload.
## 4. Personas
Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002,
"who may advance a manual application" is the **Behandelaar**, an actor in a _separate_ backoffice
context that is not part of this app.
## 5. Domain model — the `Aanvraag` aggregate (backend-owned)
The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads.
| Field | Type | Notes |
| ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
| `id` | string (uuid) | client-visible handle; used in the resume deep link |
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
| `status` | discriminated union (below) | computed on read for auto-approval |
| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) |
| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block |
| `documentIds` | string[] | documents linked to this aanvraag |
| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) |
| `owner` | string | `DemoOwner` |
| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto |
| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | |
### Status lifecycle
```mermaid
stateDiagram-v2
[*] --> Concept: create (first wizard step)
Concept --> Concept: draft sync (per step)
Concept --> [*]: Annuleren (cancel, delete)
Concept --> InBehandeling: submit (accepted)
Concept --> Afgewezen: submit invalid (e.g. 0 uren)
InBehandeling --> Goedgekeurd: auto (duo) after processing window Δ
InBehandeling --> InBehandeling: manual (handmatig) — awaits backoffice (not built)
Goedgekeurd --> [*]
Afgewezen --> [*]
```
FE-mirrored discriminated union (illegal states unrepresentable, same reflex as `RemoteData`):
```ts
type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number }
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
```
### Deterministic auto-approval (no background timer)
Auto-approval is **computed on read**: for an `autoApprovable` aanvraag, if
`now > submittedAt + PROCESSING_WINDOW` (≈8s) the status reports **Goedgekeurd**, else **In behandeling**.
Manual (`autoApprovable === false`) aanvragen never auto-advance. This yields a visible
pending→approved transition for the auto flow and a persistent pending for the manual flow, with no
timers or background jobs — purely a function of stored timestamps.
## 6. UX
### Dashboard — new "Mijn aanvragen" section (top, above "Wat moet ik regelen")
Renders `ApplicationsStore.applications()` through `<app-async>` as a list of **blocks**, sorted:
Concept → In behandeling → resolved. Empty list → the section is hidden.
Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a
pure `blockActions(status)`):
| Status | Badge | Body | Actions |
| ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ |
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) |
| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — |
| **Afgewezen** | red "Afgewezen" | referentie + reden | — |
- **Verder gaan** deep-links to the wizard with `?aanvraag=<id>`; the wizard loads the draft from the
backend and seeds its machine.
- **Annuleren** confirms, then `DELETE /applications/{id}` (removes the aanvraag and its unlinked
documents); the block disappears and the user can start a new one from the existing action cards.
### Document preview/download
`document-chip` (completed uploads) gains a **"Voorbeeld / Download"** affordance linking to
`GET /api/v1/uploads/{documentId}/content` — opens inline for `application/pdf` and images, downloads
otherwise. Available both inside the wizard (Concept) and from an In-behandeling block's document view.
## 7. Backend design (ASP.NET Core, in-memory — extends existing patterns)
- **`ApplicationStore`** (new, mirrors `DocumentStore`): in-memory dict + lock; create/get/list/
upsert-draft/delete; `Submit()` transition; status computed on read (auto-approval window).
- **Endpoints** (`Program.cs`, `/api/v1`):
- `GET /applications``List<ApplicationSummaryDto>` for the owner.
- `GET /applications/{id}``ApplicationDetailDto` (includes `draft` to resume).
- `POST /applications` → create Concept (returns id); `PUT /applications/{id}` → draft sync (idempotent).
- `DELETE /applications/{id}` → cancel Concept (cascades to unlinked documents).
- `POST /applications/{id}/submit` → runs `SubmissionRules`, sets `autoApprovable`, transitions to
In behandeling (or Afgewezen), links documents, returns `{ referentie, status }`.
- **`SubmissionRules` change**: `handmatig` no longer 422s — it yields `autoApprovable = false`
(manual/pending). Keep genuine validation rejects (0 uren → Afgewezen).
- **`DocumentStore` change**: store `byte[] Content` + `string ContentType`; add
`GET /uploads/{documentId}/content` returning bytes (Content-Disposition inline for pdf/image,
attachment otherwise). The multipart `POST /uploads` now captures bytes + content-type.
- **Tests** (`dotnet test`): lifecycle (concept→submit→auto/manual/afgewezen), auto-approve-on-read
window boundary, content endpoint returns bytes+type, cancel cascades to unlinked docs only.
## 8. Frontend design (Angular — TEA + atomic + BFF-lite)
- **Contracts** (`contracts/`): `ApplicationSummaryDto`, `ApplicationDetailDto`, `AanvraagStatusDto`.
- **`applications.adapter.ts`** (`infrastructure/`, the only new network surface): `httpResource` for
the list + commands (create/sync/delete/submit), with a hand-written `parseApplication*` boundary
(DTO→domain), per ADR-0001. Upload adapter gains a `contentUrl(documentId)` helper.
- **`ApplicationsStore`** (`application/`, `providedIn: 'root'`): `applications` RemoteData list rendered
via `<app-async>`; optimistic begin/confirm/rollback around cancel + submit; `reload()` after
mutations — same pattern as `BigProfileStore`. Dashboard polls/reloads to reflect auto-approval.
- **Wizards**: replace `sessionStorage` persistence with a **debounced backend draft-sync** command on
each step change; open via `?aanvraag=<id>` (load detail → seed the machine). Apply the same to the
herregistratie wizard (it gains persistence). The registratie machine already carries
`diplomaHerkomst`, which flows into the submit payload to set `autoApprovable`.
- **Domain**: FE `AanvraagStatus` union + pure `blockActions(status)` (badge + allowed actions);
co-located `*.spec.ts`.
- **UI (atomic, mostly composition)**: new `aanvraag-block`; new "Mijn aanvragen" dashboard section;
`document-chip` preview/download affordance. Reuse existing atoms (`card`, `button`, `alert`,
`heading`, status-icon/badge). All user-facing copy via `$localize`; shared/English components stay
language-agnostic (props with localizable defaults). Stories for each block status + chip preview
(a11y addon on).
## 9. Delivery phases
Each phase must leave every gate green (`npm run lint`, `npm test`, `npm run build`,
`cd backend && dotnet test`). Build the **registratie vertical slice first**; herregistratie/intake
blocks are the same pattern copied.
- **A — Backend Aanvraag store + endpoints + lifecycle** (+ auto-approve-on-read) + tests.
- **B — Backend document bytes + `GET /uploads/{id}/content`** + tests.
- **C — Contracts + adapters** (applications parse boundary; upload content URL).
- **D — `ApplicationsStore` + wizard backend draft-sync + resume-by-link** (retire sessionStorage keys).
- **E — FE `AanvraagStatus` union + `blockActions` + registratie two-flow wiring + herregistratie persistence.**
- **F — Dashboard "Mijn aanvragen" blocks + `document-chip` preview affordance** + stories.
## 10. Testing / verification
Unit (co-located): `blockActions`, `AanvraagStatus` transitions, `parseApplication*` boundary; backend
`ApplicationStore` lifecycle + auto-approve window + content endpoint + cancel cascade. UI via Storybook
stories. End-to-end demo:
1. `docker compose up` (Swagger at `:5000/swagger`) or `npm start` + backend; log in.
2. Start each wizard partway → blocks appear at the top of the dashboard (Concept, "Stap X van Y").
3. Reopen a Concept → previously uploaded documents **preview/download** (real upload, not `?scenario=upload-*`).
4. **Annuleren** a Concept → block disappears; start a fresh one.
5. Registratie with **DUO** diploma → submit → block shows **In behandeling**, then **Goedgekeurd**
after the processing window (on dashboard reload).
6. Registratie with **handmatig** diploma → submit (not blocked) → block stays **In behandeling**
("wordt handmatig beoordeeld").
## 11. Risks & notes
- **Retiring sessionStorage** changes existing wizard behavior; ensure resume-by-link and draft-sync
cover the previous "reload keeps progress" guarantee. Bump/remove the old `registratie-v2` / `intake-v3`
keys (no migration — ponytail).
- **Chatty sync**: debounce the per-step `PUT /applications/{id}`; keep it optimistic so typing stays snappy.
- **Dev simulation** uploads have no bytes → hide the preview affordance when the documentId is a
`demo-*` sentinel (or when content 404s).
- **In-memory reset**: backend restart clears all aanvragen/documents — acceptable for the POC; call it
out so testers aren't surprised.
ponytail: the only genuinely new pieces are the backend `Aanvraag` store, document byte storage, and
the `aanvraag-block` component. Everything else reuses existing store/adapter/`<app-async>`/atom
patterns. Don't build the backoffice, real storage, or withdrawal — YAGNI until asked.
@@ -0,0 +1,247 @@
# PRD 0002 — Attribute-Based Access Control (ABAC) in the UI
Status: Proposed · Date: 2026-07-02 · Context: SSP / backoffice actors (see ADR-0002)
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs), **ADR-0002** (user groups as
> actors; identity vs authorization), and **PRD-0001** (the `Aanvraag` lifecycle those decisions gate).
> This PRD _materializes_ ADR-0002's authorization half: the AD server authenticates and supplies
> **coarse roles**; the app layers a **fine-grained, app-owned** access model on top, resolved by the
> backend and rendered — never decided — by the UI.
---
## 1. Problem
The AD (Active Directory) server is the identity provider: it authenticates the user and returns
**coarse, role-based attributes** — group memberships that map to a handful of roles. That is all AD
owns. The product needs access controls that are **finer than a role** and that AD does **not**
administer:
- **Capability gating** — one role, many buttons: some users in a role may approve letters, reveal a
BSN, or advance a manual application; others may not.
- **Data-scoping** — the same role sees _different rows_: only their own region / office / caseload.
- **Field / PII-level** — restrict _which fields_ (notably the BSN and other special-category personal
data under GDPR/AVG art. 9) a user may see or edit, independently of their role.
- **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes,
recent MFA, time-boxed break-glass.
Today the codebase has none of this, and what stands in for a "role" is not a security control at all:
- `Session` (`src/app/auth/domain/session.ts:2-9`) carries only `bsn` + `naam` — **no roles, claims,
or attributes**. `SessionStore` (`src/app/auth/application/session.store.ts:32`) is
`providedIn:'root'`.
- The only "role" is a **dev-only, unverified** query param: `currentRole()` reads
`?role=drafter|approver` from the URL (`src/app/shared/infrastructure/role.ts`), stamped onto brief
requests as an `X-Role` header by a dev-only interceptor (`src/app/shared/infrastructure/role.interceptor.ts`,
registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel
admin stand-in.
- One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure _authentication_
check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper
anywhere.
- The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or
authorization middleware, no `[Authorize]`, and never reads `HttpContext.User`. Identity is faked via
a single `DemoOwner` id (`DocumentStore.cs:26`) plus the client-asserted `X-Role` / `X-Admin`
headers. The brief's two-person rule _is_ enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`:
`if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so
any caller can assert `X-Role: approver`.
The building block we need already exists in one place: the **decision-flag seam**. The backend
computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside
`DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by
`HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This
PRD extends that same seam from _business_ decisions to _authorization_ decisions.
## 2. Goals
1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and
step-up/SoD** — as one coherent model.
2. **Backend is the authority** for every access decision (per ADR-0001). The UI _mirrors_ decisions
for UX; it never computes them.
3. **AD roles are the base; the app owns a fine-grained overlay.** The two merge **server-side** into a
single `Principal`; capabilities are resolved server-side.
4. **Deny-by-default.** Absence of a decision means denied — in the guard, the template, and the
endpoint.
5. **Privacy by design (data minimization).** The FE receives only the decisions it needs to render —
resolved capability flags, already-scoped rows, redacted PII — never the policy matrix, raw AD group
dumps, or other users' attributes.
6. **Auditable.** Every authorization decision that matters (denials, PII reveals, step-up,
break-glass) is logged server-side against the acting principal.
## 3. Non-goals / Out of scope (POC)
- **Real AD / OIDC / SAML integration.** The AD roles remain _simulated_; how claims actually arrive
(token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend
authn middleware.
- **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed
in plain code, not a rules DSL. Add an engine only if the capability set outgrows hand-written rules.
- **An admin UI for the overlay.** The app-owned overlay is seeded/hardcoded in this build; who
administers it is a separate backoffice concern (ADR-0002).
- **A real MFA provider / real break-glass workflow.** Step-up is modelled (an environment attribute +
precondition) but satisfied by a stub in the POC.
- The **unverified `X-Role` / `X-Admin` header seam stays** as the POC's identity stub — but it is
explicitly relabelled in code and docs as **"dev stub — NOT a security boundary."** Production
replaces it with a verified principal (§7).
## 4. Personas & attributes
Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice**
actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions _within_ a role —
diverge without a folder-per-role explosion.
An access decision is a function of four attribute sets:
| Attribute set | Source | Examples |
| --------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office |
| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag |
> AD owns only the first column's first row (coarse roles). Everything else is the app's overlay and
> the entity's own attributes — the reason a role alone is too blunt.
## 5. Access model — four mechanisms, each server-authoritative
Every mechanism follows one rule: **the backend decides and enforces; the UI renders the decision.**
### 5a. Capability gating (feature/UI)
The atomic unit is a **named capability** — a stable, namespaced string, e.g. `brief:approve`,
`aanvraag:beoordelen`, `registratie:reveal-bsn`. The backend resolves the subject's capabilities for a
given resource+environment and ships them as **decision flags on the screen DTO** — exactly the
`HerregistratieDecisionsDto` pattern, extended:
```csharp
// contracts: capability flags travel with the screen they gate (data-minimized: only this screen's)
public sealed record BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanRevealBsn,
bool RequiresStepUp, string? DeniedReason);
```
The UI reads the flag and shows/hides. It **never** re-derives the flag from roles. (Contrast today's
`BriefStore.editable`, `src/app/brief/application/brief.store.ts:34-37`, which computes the gate FE-side
from `currentRole()` — this PRD moves that authority to the server flag.)
### 5b. Data-scoping (row-level)
The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region
_Noord_ receives only _Noord_ aanvragen. The FE never receives out-of-scope records and so cannot leak
them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied
in the query, not a UI filter.
### 5c. Field / PII-level
Sensitive fields are **redacted or omitted server-side** when the capability is absent. The BSN is the
canonical case (art. 9 / special-category data):
- Default DTO carries a **masked** BSN (`******601`) or omits it entirely.
- 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.
### 5d. Step-up / segregation-of-duty
Expressed as **preconditions on a capability**, evaluated server-side:
- **SoD (four-eyes)** — already real for the brief: approve/reject require `actingId != drafterId`
(`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`). Generalize to a reusable
precondition, and enforce it against a **verified** principal instead of the `X-Role` header.
- **Step-up (assurance)** — a capability may require a minimum MFA/assurance level or recent
re-authentication (e.g. `registratie:reveal-bsn`). The DTO surfaces `requiresStepUp`; the UI prompts;
the server re-checks the environment attribute before permitting.
- **Break-glass** — an explicit, **time-boxed, heavily-audited** override attribute for emergency
access. Modelled here, stubbed in the POC.
## 6. Frontend design ("in the UI")
The FE's job is to **mirror** server decisions cleanly and deny-by-default. It reuses existing patterns
— no new libraries.
- **`Session``Principal`** (`src/app/auth/domain/`, per ADR-0002): the authenticated identity gains
`roles: readonly Role[]` (from AD) and a resolved `capabilities: ReadonlySet<Capability>`. `Capability`
is a branded/union string type in `shared/`. The FE treats capabilities as **opaque, server-resolved
facts** — it stores them, it does not compute them from roles.
- **`AccessStore`** (`src/app/shared/application/access.store.ts`, `providedIn:'root'`, built on the
Elm `store.ts` + signals like `SessionStore`): holds the `Principal`. Two feeds:
- **Global capabilities** (nav/menu visibility) from a small **`GET /me`** endpoint, loaded once at
login as `RemoteData`.
- **Screen capabilities** read from each screen's decision DTO (§5a) — no extra round-trip.
- **`can(capability): boolean`** — a signal-friendly helper on `AccessStore`; unknown/absent capability
`false` (deny-by-default).
- **`capabilityGuard(cap): CanActivateFn`** — a factory guard extending the `authGuard` shape
(`src/app/auth/auth.guard.ts`): authenticated **and** `access.can(cap)` ⇒ allow, else redirect /
403 page. Wired in `app.routes.ts` alongside `authGuard`.
- **Template gating** — declarative `@if (access.can('brief:approve')) { … }`. A `*appCan` structural
directive is **optional** and only worth adding if the `@if` repeats enough to hurt (YAGNI until then).
- **Retire the dev role hack** — `currentRole()` / `X-Role` (`src/app/shared/infrastructure/role.ts`,
`role.interceptor.ts`) is replaced by Principal-derived capabilities. A dev **role/scenario toggle**
may stay as a POC affordance, but behind the same `Principal` seam (it sets simulated capabilities),
never read directly by feature code.
> **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the
> browser changes only what they _see_; every gated route, action, and field is independently enforced
> by the backend (§7).
## 7. Backend design
Extends ADR-0001's decision-DTO pattern; closes the "fully open" gap.
- **Authenticate, then build a `Principal` server-side.** Replace the unverified `X-Role`/`X-Admin`
headers with a verified principal derived from the AD claims (stubbed in the POC, real
authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the
FE never sees the merge.
- **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal,
action, resource, env)`), used **on every endpoint** — not merely to _emit_ flags but to _gate_ the
operation. Forbidden ⇒ 403 (reuse the existing `Outcome.Forbidden → 403` mapping,
`backend/.../Program.cs:330-335`). Emitting a flag and forgetting to enforce it is the classic
broken-object-level-authorization bug; the helper makes emit and enforce the same code path.
- **Publish decisions as DTO fields** on the screen DTOs (§5a) — the FE's only source of truth for what
to render.
- **Scope + redact at the source** (§5b, §5c): apply the scope filter in the query and redact PII in
the mapper, so out-of-scope / unauthorized data never enters a response body.
## 8. Privacy & audit (the security-expert layer)
- **Data-minimized DTOs.** Ship resolved decisions + only-visible, already-scoped data. Never the
permission matrix, AD group lists, or other subjects' attributes. Smaller payload = smaller attack
surface and a smaller GDPR footprint.
- **Server-side PII redaction** as the default; reveal is the exception, gated + stepped-up + logged.
- **Audit log** of authorization-relevant events — denials, PII reveals, approvals/rejections,
step-up, break-glass — recording acting principal, action, resource, decision, and timestamp. (An
`Actor`/audit-entry seam is already noted in ADR-0002.)
- **Avoid resource-existence enumeration.** For resources the subject may not even know exist, prefer
**404 over 403** so the response doesn't confirm existence; use 403 only where existence is already
known to the caller. Document the choice per endpoint.
- **Break-glass is time-boxed and alerting** — every use raises an audit event and expires
automatically.
## 9. Phasing
- **P1 — Capability spine.** `Principal` (roles + capabilities); `AccessStore` + `can()`;
`capabilityGuard`; `GET /me`; capability flags on screen DTOs; enforce server-side via `Authz.Can`.
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
- **ADR-0001** — BFF-lite endpoints + decision DTOs (the seam this PRD reuses for authz).
- **ADR-0002** — identity vs authorization; `Principal` union; authz enforced backend-side, published
as decision DTOs.
- **PRD-0001** — the `Aanvraag` lifecycle whose actions (`beoordelen`, advance) these capabilities gate.