Files
atomic-design-poc/docs/project/prd/0002-attribute-based-access-control.md
ehoandClaude Opus 4.8 5761b13dd2 style: format the repo with prettier (green format:check)
`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>
2026-07-21 17:20:42 +02:00

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 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 + naamno 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:

// 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.

  • SessionPrincipal (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 hackcurrentRole() / 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.