docs(prd): add PRD-0002 — attribute-based access control (ABAC)
Specifies fine-grained, app-owned access control layered on the AD roles: capability gating, data-scoping, field/PII-level, and step-up/SoD. Backend-authoritative (per ADR-0001), UI mirrors decisions; extends ADR-0002. Privacy-by-design: data-minimized decision DTOs, server-side PII redaction, audit, deny-by-default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
234
docs/prd/0002-attribute-based-access-control.md
Normal file
234
docs/prd/0002-attribute-based-access-control.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 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).
|
||||
|
||||
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`.
|
||||
- **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.
|
||||
Reference in New Issue
Block a user