Files
atomic-design-poc/docs/prd/0002-attribute-based-access-control.md
Edwin van den Houdt e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
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>
2026-07-03 13:39:31 +02:00

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

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.
  • 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; Principal union; authz enforced backend-side, published as decision DTOs.
  • PRD-0001 — the Aanvraag lifecycle whose actions (beoordelen, advance) these capabilities gate.