Gap analysis found the POC's designed-but-unbuilt strategic gaps: ABAC authorization (ADR-0002/PRD-0002 phase P1), no e2e coverage, unproven i18n second-locale seam, thin resilience seams (correlation-id, idempotency, retry), and in-memory-only persistence. Each WP is grounded in the current code (file paths + line numbers), not just the analysis. Also corrects PRD-0001's stale 'Proposed' status header — the Mijn aanvragen vertical is fully built. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.1 KiB
WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: todo Phase: 5 — productie-volwassenheid
Why
The single biggest gap between this POC and a production SSP: identity carries no
roles/capabilities (Session { bsn, naam } only), the only "role" is an unverified
?role= query param stamped as an X-Role header, and BriefStore.editable
computes its authorization gate in the frontend from that header — the exact
anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
The backend is fully open: no [Authorize], no principal, ownership is a constant
DemoOwner. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This
WP implements PRD-0002's P1 — Capability spine only (§9), the smallest slice
that closes the anti-pattern and gives every later phase (data-scoping, PII
redaction, step-up/audit) a real foundation to extend.
Read first
docs/architecture/0002-user-groups-and-bounded-contexts.md(thePrincipalunion, identity-vs-authorization split)docs/prd/0002-attribute-based-access-control.md§5a, §6, §7, §9-P1 (this WP implements exactly P1 — don't reach into P2/P3)src/app/auth/domain/session.ts(flatSessionto replace)src/app/auth/application/session.store.ts,src/app/auth/auth.guard.ts(seams that already localise theSession → Principalswap, per ADR-0002)src/app/shared/domain/role.ts,src/app/shared/infrastructure/role.ts+role.interceptor.ts(the dev stub being retired as an authority, kept as a dev toggle)src/app/brief/application/brief.store.ts:28,41-46(readonly role = currentRole()and theeditablecomputed — the FE-computed gate to remove)backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27(HerregistratieDecisionsDto— the decision-flag pattern this WP extends to aBriefDecisionsDto)backend/src/BigRegister.Api/Program.cs:318-335(IsAdmin, theX-Rolereader around brief endpoints — becomesAuthz.Can)backend/src/BigRegister.Api/Data/BriefStore.cs(Review, theactingId == e.DrafterId → ForbiddenSoD check — keep this check, move it behind the verified principal)
Decisions (pre-made, don't relitigate)
- P1 scope only. No data-scoping, no PII redaction/BSN reveal, no step-up or
audit log — those are PRD-0002 §9 P2/P3, separate future WPs. This WP:
Principal,AccessStore/can(),capabilityGuard,GET /me, capability flags on the brief screen DTO, and server-side enforcement via one sharedAuthz.Canhelper. - The AD/OIDC identity provider stays simulated (PRD-0002 §3 non-goal). The
Principalis still built server-side from the existing dev stand-ins (X-Role/X-Adminheaders), but it becomes the backend's own construct — the FE never re-derives capabilities from the header, it only reads what the backend sends. - Capability naming: stable, namespaced strings per PRD-0002 §5a — start with
exactly
brief:approve,brief:reject,brief:send(the brief flow is the only role-gated flow that exists today). Do not invent capabilities for flows that don't exist yet (e.g.aanvraag:beoordelen— that's the backoffice, ADR-0002, out of scope). - Emit and enforce are the same code path.
Authz.Can(principal, action, resource)is called both to compute the DTO flag and to gate the endpoint — never two separate checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out). - Dev role toggle survives, but moves behind the
Principalseam:?role=still picks an identity for demo purposes, but it flows into building thePrincipalserver-side (still asserted by the client — this is not real auth, just moving the authority from FE-computed to BE-computed within the POC's honesty envelope). Keep it explicitly commented// dev stub — NOT a security boundaryper PRD-0002 §3.
Files
src/app/auth/domain/session.ts— replaceSessionwith thePrincipaldiscriminated union from ADR-0002 ({ kind: 'zorgverlener'; bsn; naam }— nomedewerkervariant yet, that's ADR-0002/backoffice scope; keep the union shape so it's additive later). UpdateisAuthenticated.src/app/auth/application/session.store.ts— carries thePrincipal; unchanged persistence rules (never persist the BSN, per existing comment).- New
src/app/shared/domain/capability.ts— branded/unionCapabilitytype ('brief:approve' | 'brief:reject' | 'brief:send'), framework-free. - New
src/app/shared/application/access.store.ts—providedIn: 'root', holds resolved capabilities asRemoteDatafromGET /me;can(capability): boolean, deny-by-default on absence. - New
src/app/shared/infrastructure/me.adapter.ts(+ spec) — callsGET /me,parseMe(): Resultboundary. - New
capabilityGuardinsrc/app/auth/auth.guard.ts(or co-locatedcapability.guard.ts) — factoryCanActivateFnextendingauthGuard's shape. src/app/brief/application/brief.store.ts— deletereadonly role = currentRole()and the FE-computededitable; readcanApprove/canReject/canSendoff the loadedBriefViewDto's newBriefDecisionsDtoinstead.src/app/shared/infrastructure/role.interceptor.ts— keep (still asserts the dev identity), retitle its comment to "feeds Principal construction, not an authority the FE reads back."- Backend:
backend/src/BigRegister.Api/Contracts/Dtos.cs— addBriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend); add it toBriefViewDto. - New
backend/src/BigRegister.Api/Domain/Authorization/Principal.cs+Authz.cs—Principal(mirrors the FE union),Authz.Can(principal, action)covering the three brief capabilities + the existing SoD rule. backend/src/BigRegister.Api/Program.cs— addGET /api/v1/mereturning the resolvedPrincipal's capabilities; replace the ad-hocX-Rolereads around brief endpoints withAuthz.Can; computeBriefDecisionsDtovia the same helper.- New backend test
backend/tests/BigRegister.Tests/AuthzTests.cs—Authz.Canunit tests (approve/reject/send × drafter/approver × SoD).
Steps
- Backend:
Principal,Authz.Can,GET /me,BriefDecisionsDtowired into the existing brief endpoints (replaceIsDrafter/X-Rolereads one at a time, keepingBriefEndpointTests.csgreen after each). - FE:
Capabilitytype,access.store.ts,me.adapter.ts,capabilityGuard. - FE:
Session → Principalinauth/domain; thread throughSessionStore,auth.guard.ts(both keep working —isAuthenticatedstill means "has a Principal"). - FE:
brief.store.tsreadscanApprove/canReject/canSendfrom the DTO; deletecurrentRole()import and the FE-computededitable. Update the brief UI components consuming.editable/.roleto consume the new flags. - Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP).
Acceptance criteria
brief.store.tscontains nocurrentRole()call and no FE-computed permission boolean;canApprove/canReject/canSendcome from the DTO.- Forging
?role=approverin the browser with a stale/absent server capability still gets a 403 from the backend (verified by a test hitting the endpoint directly, bypassing the FE). Authz.Canis the only place brief authorization logic lives; the emit path (DTO flags) and the enforce path (endpoint gating) both call it.GET /mereturns capabilities;AccessStore.can()defaults tofalsefor an unknown capability.- The existing SoD rule (approver ≠ drafter) still holds, now expressed as an
Authz.Canprecondition rather than inline inBriefStore.Review. capabilityGuardcompiles and is demonstrated on at least one route (or documented as available-but-unwired if no route needs it yet — brief has no route today, it's a page section).
Verification
GREEN (docs/backlog/README.md) + cd backend && dotnet test. Manual smoke:
npm start with ?role=drafter — draft-only actions enabled; ?role=approver —
approve/reject enabled, editing disabled. Confirm via browser devtools that removing
the X-Role header (or backend patched to ignore it) makes every capability false
— i.e. deny-by-default actually denies.
Out of scope
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
log) — separate future WPs. The Behandeling/backoffice app and medewerker
Principal variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
AD/OIDC integration (identity provider stays simulated).
Risks
Threading Principal through SessionStore/auth.guard.ts touches the one
authenticated-session seam every route depends on — keep the observable shape
(isAuthenticated(): boolean) identical so no route wiring needs to change, only
what's inside Session/Principal. Backend Authz.Can replacing inline X-Role
reads must preserve the existing 403 Outcome.Forbidden mapping
(Program.cs:330-335) exactly, or BriefEndpointTests.cs breaks.