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:
2026-07-03 20:31:53 +02:00
parent cbb8ae548c
commit 7ec13d8b59
26 changed files with 4520 additions and 3185 deletions

View File

@@ -1,8 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import {
Brief,
allDiagnostics,
@@ -11,13 +9,15 @@ import {
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`,
* `unresolved`, `canSubmit` are DERIVED here — never stored.
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore {
@@ -25,7 +25,6 @@ export class BriefStore {
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
readonly role: Role = currentRole();
readonly busy = signal(false);
readonly lastError = signal<string | null>(null);
/** Surfaced autosave state for the indicator + aria-live region. */
@@ -36,13 +35,14 @@ export class BriefStore {
return s.tag === 'loaded' ? s.brief : null;
});
/** The single source of truth for edit permission: drafter, and a status the reducer
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
readonly editable = computed(() => {
const b = this.brief();
return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
);
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
private decisions = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -54,12 +54,7 @@ export class BriefStore {
async load() {
const r = await this.adapter.load();
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -71,7 +66,7 @@ export class BriefStore {
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.editable()) return;
if (!this.canEdit()) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
@@ -97,12 +92,7 @@ export class BriefStore {
const r = await this.adapter.reset();
this.busy.set(false);
this.saveState.set('idle');
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.lastError.set(r.error);
}
@@ -113,7 +103,7 @@ export class BriefStore {
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, Brief>>) {
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.busy.set(true);
this.lastError.set(null);
clearTimeout(this.saveTimer);
@@ -127,14 +117,15 @@ export class BriefStore {
this.applyServerStatus(r.value);
}
private applyServerStatus(brief: Brief) {
private applyServerStatus(view: BriefView) {
const { brief, decisions } = view;
const s = brief.status;
switch (s.tag) {
case 'submitted':
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
break;
case 'approved':
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
this.store.dispatch({
@@ -142,10 +133,11 @@ export class BriefStore {
by: s.rejectedBy,
at: s.rejectedAt,
comments: s.comments,
decisions,
});
break;
case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
break;
case 'draft':
// reopened by a save on a rejected letter — reducer already handled it locally.