`npm run format:check` (a CI gate) had drifted red across 44 files — pre-existing files plus recently-added ones committed without formatting. Ran `prettier --write .`; no logic changes. Also regenerates documentation.json (compodoc reflects the reformatted component sources). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
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
Aanvraaglifecycle 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 onlybsn+naam— no roles, claims, or attributes.SessionStore(src/app/auth/application/session.store.ts:32) isprovidedIn:'root'.- The only "role" is a dev-only, unverified query param:
currentRole()reads?role=drafter|approverfrom the URL (src/app/shared/infrastructure/role.ts), stamped onto brief requests as anX-Roleheader by a dev-only interceptor (src/app/shared/infrastructure/role.interceptor.ts, registered only underisDevMode()insrc/app/app.config.ts:22).X-Admin: trueis 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 nocan/hasRole/isAuthorizedhelper anywhere. - The backend is fully open:
backend/src/BigRegister.Api/Program.cshas no authentication or authorization middleware, no[Authorize], and never readsHttpContext.User. Identity is faked via a singleDemoOwnerid (DocumentStore.cs:26) plus the client-assertedX-Role/X-Adminheaders. 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 unverifiedX-Roleheader, so any caller can assertX-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
- Support all four control types above — capability gating, data-scoping, field/PII-level, and step-up/SoD — as one coherent model.
- Backend is the authority for every access decision (per ADR-0001). The UI mirrors decisions for UX; it never computes them.
- 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. - Deny-by-default. Absence of a decision means denied — in the guard, the template, and the endpoint.
- 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.
- 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-Adminheader 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:
// 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
canRevealBsnflag 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 theX-Roleheader. - Step-up (assurance) — a capability may require a minimum MFA/assurance level or recent
re-authentication (e.g.
registratie:reveal-bsn). The DTO surfacesrequiresStepUp; 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 gainsroles: readonly Role[](from AD) and a resolvedcapabilities: ReadonlySet<Capability>.Capabilityis a branded/union string type inshared/. 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 Elmstore.ts+ signals likeSessionStore): holds thePrincipal. Two feeds:- Global capabilities (nav/menu visibility) from a small
GET /meendpoint, loaded once at login asRemoteData. - Screen capabilities read from each screen's decision DTO (§5a) — no extra round-trip.
- Global capabilities (nav/menu visibility) from a small
can(capability): boolean— a signal-friendly helper onAccessStore; unknown/absent capability ⇒false(deny-by-default).capabilityGuard(cap): CanActivateFn— a factory guard extending theauthGuardshape (src/app/auth/auth.guard.ts): authenticated andaccess.can(cap)⇒ allow, else redirect / 403 page. Wired inapp.routes.tsalongsideauthGuard.- Template gating — declarative
@if (access.can('brief:approve')) { … }. A*appCanstructural directive is optional and only worth adding if the@ifrepeats 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 samePrincipalseam (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
Principalserver-side. Replace the unverifiedX-Role/X-Adminheaders 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 onePrincipalhere — 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 existingOutcome.Forbidden → 403mapping,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 viaAuthz.Can. Convert the brief drafter/approver gate fromcurrentRole()to a realbrief:approvecapability (verified principal, keep the SoDapprover != draftercheck). - 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, auditedPOST /brief/reveal-bignummer. Realized on the BIG-nummer, not the BSN — see the §5c note. Row-level scoping (§5b) still unbuilt.
- Field-level reveal delivered (WP-18 follow-up): the backoffice case screen ships the
BIG-nummer masked (
- P3 — Step-up & audit. MFA/assurance preconditions, break-glass, and the authorization audit log.
- Audit log delivered (lite):
AuditAuthzlogs reveal attempts (allow/deny) and org-admin denials, no PII (§8). Step-up is stubbed as theX-Step-Upheader (§5d); thecapabilityGuardis wired onto the admin route (§6). MFA and break-glass still unbuilt.
- Audit log delivered (lite):
10. Cross-references
- ADR-0001 — BFF-lite endpoints + decision DTOs (the seam this PRD reuses for authz).
- ADR-0002 — identity vs authorization;
Principalunion; authz enforced backend-side, published as decision DTOs. - PRD-0001 — the
Aanvraaglifecycle whose actions (beoordelen, advance) these capabilities gate.