feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)

Replace the FE-computed authorization anti-pattern in BriefStore.editable
(derived from the unverified X-Role header) with server-computed decision
flags, mirroring the existing HerregistratieDecisionsDto pattern:

- Backend: Authz.cs is the single authorization helper — the SAME check
  (Authz.CanActOn) both gates BriefStore.Review's mutations and computes
  the BriefDecisionsDto flags shipped on every brief response, so emit
  and enforce can never drift. New GET /me returns coarse, role-derived
  capabilities (PRD-0002 SS6).
- Every brief endpoint (including send, previously ungated on HttpContext)
  now returns a fresh BriefViewDto so decisions never go stale after a
  mutation.
- FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the
  loaded decisions instead of computing them from currentRole(); the
  brief.machine carries decisions through every status transition.
- New shared/domain/capability.ts + shared/application/access.store.ts +
  shared/infrastructure/me.adapter.ts: the general capability-spine
  infrastructure (AccessStore.can(), capabilityGuard) for future routes.

Deviates from the original WP-18 draft by NOT renaming auth/domain's
Session to a Principal union — ADR-0002 explicitly defers that refactor
until a second actor exists, and the brief workflow's drafter/approver
identity turned out to be a separate axis from the SSP login session
entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full
as-built record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-03 20:31:53 +02:00
co-authored by Claude Sonnet 5
parent cbb8ae548c
commit 7ec13d8b59
26 changed files with 4520 additions and 3185 deletions
@@ -0,0 +1,36 @@
import { Injectable, computed, inject } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { Capability } from '@shared/domain/capability';
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
type Err = Error | undefined;
/**
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
* to a specific resource's live status — no extra round-trip needed for that.
*
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
* resolve to `false`. This store never derives a capability from a role — it only
* mirrors what the server already resolved.
*/
@Injectable({ providedIn: 'root' })
export class AccessStore {
private adapter = inject(MeAdapter);
private meRes = this.adapter.meResource();
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
const rd = fromResource(this.meRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseMe(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
can(capability: Capability): boolean {
const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability);
}
}