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
+47 -24
View File
@@ -1,6 +1,7 @@
import { assertNever } from '@shared/kernel/fp';
import {
Brief,
BriefDecisions,
BriefStatus,
LetterBlock,
LetterSection,
@@ -21,9 +22,11 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
* it back to `draft`. Sections can never be added, removed, or reordered — there
* is no Msg for it, so it is unrepresentable.
*
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from
* role+status and simply doesn't dispatch edits when the actor may not edit. The
* reducer guards the status invariant; the UI guards the role invariant.
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
* canSend) arrives from the server on every load and every status transition (PRD-0002
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
* The reducer guards the status invariant; the server is the sole authority on who may
* act on it.
*
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
@@ -33,23 +36,33 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
export type BriefState =
| { tag: 'loading' }
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
| {
tag: 'loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export type BriefMsg =
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
| {
tag: 'BriefLoaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string }
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
| { tag: 'Submitted'; by: string; at: string } // draft → submitted
| { tag: 'Approved'; by: string; at: string } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected
| { tag: 'Sent'; at: string } // approved → sent
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState };
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
@@ -158,7 +171,12 @@ function moveWithinSection(
export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) {
case 'BriefLoaded':
return { tag: 'loaded', brief: m.brief, availablePassages: m.availablePassages };
return {
tag: 'loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
};
case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason };
case 'Seed':
@@ -203,36 +221,41 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
s,
'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
m.decisions,
canSubmit,
);
case 'Approved':
return transition(s, 'submitted', () => ({
tag: 'approved',
approvedBy: m.by,
approvedAt: m.at,
}));
return transition(
s,
'submitted',
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
m.decisions,
);
case 'Rejected':
return transition(s, 'submitted', () => ({
tag: 'rejected',
rejectedBy: m.by,
rejectedAt: m.at,
comments: m.comments,
}));
return transition(
s,
'submitted',
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
m.decisions,
);
case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
default:
return assertNever(m);
}
}
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
`decisions` replaces the prior server-computed flags — always fresh from the
same response that carried the new status. */
function transition(
s: BriefState,
from: BriefStatus['tag'],
next: () => BriefStatus,
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true,
): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() } };
return { ...s, brief: { ...s.brief, status: next() }, decisions };
}