Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: done (7ec13d8)
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
computed 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 was fully open: no [Authorize], no principal, ownership is a constant
DemoOwner. ADR-0002 and PRD-0002 already designed the fix; 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 — see the deviation noted below)docs/prd/0002-attribute-based-access-control.md§5a, §6, §7, §9-P1backend/src/BigRegister.Api/Domain/Authorization/Authz.cs(new — the single authorization helper)backend/src/BigRegister.Api/Data/BriefStore.cs(Review— now delegates its SoD guard toAuthz.CanActOn)src/app/brief/application/brief.store.ts(the FE-computed gate that was removed)
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.
- The AD/OIDC identity provider stays simulated (PRD-0002 §3 non-goal). The
Principalis built server-side from the existing dev stand-in (X-Roleheader), 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 — exactly
brief:approve,brief:reject,brief:send(the only role-gated flow that exists today). The brief screen's fourth flag,canEdit, is a screen decision onBriefDecisionsDto, not a named capability string — it's resource/state-scoped (draft/rejected + drafter role) the same wayHerregistratieDecisionsDtoblends business state into a decision flag, andGET /me's coarseRoleCapabilitiesset stays exactly the three above. - Emit and enforce are the same code path for approve/reject.
Authz.CanActOn(action, principal, drafterId)is the SAME checkBriefStore.Reviewuses to gate the mutation andAuthz.Decisionsuses to compute the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out).Sendis deliberately not role-gated (see Risks) — that parity is preserved exactly, decisions only mirror it. - Dev role toggle survives as the POC's identity stub:
?role=still picks an identity for demo purposes, resolved into aPrincipalserver-side viaAuthz.ResolvePrincipal. Commenteddev stub — NOT a security boundaryper PRD-0002 §3. - Deviation from the original plan —
auth/domain/session.tsis untouched. An earlier draft of this WP planned aSession → Principalrename in the SSP's login domain. That's out of scope: ADR-0002 explicitly lists that refactor as "deferred until a second actor is actually introduced" (§"Out of scope here"), and no second actor exists yet — renaming a type to a one-variant union ahead of that need is exactly the premature abstraction the ADR warns against. It also turned out unnecessary: the brief workflow's drafter/approver "acting identity" is a separate axis from the SSP login session (a Zorgverlener logs in via BSN; drafter/approver is an independent?role=toggle, not tied to that login). This WP'sPrincipaltherefore lives entirely in the backend'sBigRegister.Domain.Authorizationnamespace and never touchesauth/.
Files (as built)
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs(new) —Principal,PrincipalRole,BriefAction,Authz.ResolvePrincipal/ActingId/RoleCapabilities/ CanActOn/Decisions.backend/src/BigRegister.Api/Contracts/Dtos.cs— addedBriefDecisionsDto(CanEdit, CanApprove, CanReject, CanSend)onBriefViewDto; addedMeDto(Capabilities).backend/src/BigRegister.Api/Data/BriefStore.cs—Approve/Reject/Reviewtake aPrincipal+BriefActionand delegate the SoD check toAuthz.CanActOn(same Forbidden-before-Conflict ordering as before).backend/src/BigRegister.Api/Program.cs—GET /api/v1/me; every brief endpoint (includingsend, which had noHttpContextbefore) now returns a freshBriefViewDto(via a sharedToView/BriefResulthelper) so decisions are never stale after a mutation.backend/tests/BigRegister.Tests/AuthzTests.cs(new) — unit tests forAuthz.backend/tests/BigRegister.Tests/BriefEndpointTests.cs— updated to deserializeBriefViewDto(not bareBriefDto) from submit/approve/reject/send; two new tests for live decisions and/me.src/app/shared/domain/capability.ts(new) — theCapabilityunion type.src/app/shared/infrastructure/me.adapter.ts(+ spec, new) —GET /meadapter +parseMeboundary (unknown capability strings are dropped, not rejected).src/app/shared/application/access.store.ts(new) —AccessStore.can(), deny-by-default.src/app/auth/auth.guard.ts— addedcapabilityGuard(capability)factory. Built but deliberately unwired: no route in this app needs a capability gate today (both drafter and approver land on the same/briefpage; the gating is per-action, not per-page). It's the available building block for a future approver-only page.src/app/brief/domain/brief.ts— added theBriefDecisionsdomain type.src/app/brief/domain/brief.machine.ts(+ spec) —BriefState.loadedand theBriefLoaded/Submitted/Approved/Rejected/Sentmessages now carrydecisions; the puretransition()helper replaces them with each fresh server value.src/app/brief/infrastructure/brief.adapter.ts(+ spec) —save/submit/approve/ reject/sendnow returnResult<string, BriefView>(wasBrief) viaparseBriefView, which also parsesdecisions.src/app/brief/application/brief.store.ts— deletedcurrentRole()/editable; addedcanEdit/canApprove/canReject/canSendcomputed straight fromBriefState.loaded.decisions.src/app/brief/ui/letter-composer/letter-composer.component.ts(+ stories) —editable/roleinputs replaced by the fourcan*inputs; the approve/reject block gates oncanApprove() || canReject(), the send button oncanSend().src/app/brief/ui/brief.page.ts— passes the fourcan*signals through.src/app/shared/infrastructure/role.ts— comment updated (no longer claims the FE deriveseditablefrom the role reader).- Regenerated
backend/swagger.json+src/app/shared/infrastructure/api-client.tsvianpm run gen:api(new/meendpoint + DTO shapes).
Steps (as executed)
- Backend:
Authz.cs, DTOs,BriefStoredelegation,Program.cswiring (GET /me+BriefResult/ToView) — keptdotnet testgreen throughout (79/79 including 10 new tests). npm run gen:apito pick up the new endpoint/DTOs before touching the FE.- FE domain:
BriefDecisions, machine state/messages, machine spec fixtures. - FE infrastructure:
parseDecisions/parseBriefViewinbrief.adapter.ts(+spec). - FE application:
brief.store.ts's computed flags;access.store.ts+me.adapter.ts(+spec) as the general capability-spine infrastructure. - FE UI:
letter-composerinputs/template,brief.page.tsbindings, stories. - Full GREEN gate + a live curl smoke test against the running backend (submit as drafter → 403 on approve as drafter → 200 on approve as approver, with decisions flipping correctly at each step).
Acceptance criteria
brief.store.tscontains nocurrentRole()call and no FE-computed permission boolean;canApprove/canReject/canSendcome from the DTO.- The SoD rule is enforced server-side regardless of FE state — verified by
curl directly against the backend (drafter calling
/brief/approve→ 403) and byAuthzTests/BriefEndpointTests, bypassing the FE entirely. Authz.CanActOn/Authz.Decisionsis the only place brief authorization logic lives; the emit path (DTO flags) and the enforce path (BriefStore.Review) both call it.GET /mereturns capabilities;AccessStore.can()defaults tofalsefor an unknown capability (deny-by-default, verified inme.adapter.spec.ts).- The existing SoD rule (approver ≠ drafter) still holds, expressed as
Authz.CanActOninstead of the old inline check inBriefStore.Review. capabilityGuardcompiles; documented as available-but-unwired (no route needs it yet — see Files).
Verification
GREEN gate, all green: npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook && npm run test-storybook:ci (189 unit tests, 137
Storybook/a11y tests) + cd backend && dotnet test (79/79) +
dotnet format --verify-no-changes. Manual smoke via curl against a running
backend: default (drafter) GET /brief → canEdit: true; submit → decisions
recompute; drafter POST /brief/approve → 403; approver POST /brief/approve →
200, canSend: true afterward. GET /me → [] for drafter,
["brief:approve","brief:reject","brief:send"] for approver.
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 a medewerker
Principal variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
AD/OIDC integration (identity provider stays simulated). The auth/domain/session.ts
Session → Principal rename (see the Decisions deviation above — ADR-0002 defers
it explicitly).
Risks
Send was already unauthenticated/unauthorized before this WP (no role check on
POST /brief/send) — Authz.CanActOn(Send, …) preserves that exactly
(=> true, a mechanical dispatch step) rather than silently introducing a new gate
that would have broken the existing Send_only_from_approved test (which calls
send as the default drafter identity and expects success). If a future WP decides
send should be approver-only, that's a deliberate behavior change, not a bug fix.
Every brief mutation endpoint now returns BriefViewDto instead of bare BriefDto
— a wire-shape change; the generated api-client.ts was regenerated and every FE
call site updated, but any other caller of these endpoints outside this repo would
need the same update.