One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 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).
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. - P3 — Step-up & audit. MFA/assurance preconditions, break-glass, and the authorization audit log.
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.