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

@@ -3,6 +3,7 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import {
ApiClient,
BriefDecisionsDto,
BriefDto,
BriefStatusDto,
BriefViewDto,
@@ -15,6 +16,7 @@ import {
} from '@shared/infrastructure/api-client';
import {
Brief,
BriefDecisions,
BriefStatus,
LetterBlock,
LetterSection,
@@ -35,6 +37,7 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
export interface BriefView {
readonly brief: Brief;
readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions;
}
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
@@ -49,32 +52,32 @@ export class BriefAdapter {
return r.ok ? parseBriefView(r.value) : r;
}
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
const r = await runSubmit(
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
BRIEF_ACTION_FAILED,
);
return r.ok ? parseBrief(r.value) : r;
return r.ok ? parseBriefView(r.value) : r;
}
async submit(): Promise<Result<string, Brief>> {
async submit(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
return r.ok ? parseBriefView(r.value) : r;
}
async approve(): Promise<Result<string, Brief>> {
async approve(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
return r.ok ? parseBriefView(r.value) : r;
}
async reject(comments: string): Promise<Result<string, Brief>> {
async reject(comments: string): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
return r.ok ? parseBriefView(r.value) : r;
}
async send(): Promise<Result<string, Brief>> {
async send(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
return r.ok ? parseBriefView(r.value) : r;
}
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
@@ -281,17 +284,36 @@ export function parseBrief(dto: BriefDto): Result<string, Brief> {
});
}
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
if (
typeof dto?.canEdit !== 'boolean' ||
typeof dto.canApprove !== 'boolean' ||
typeof dto.canReject !== 'boolean' ||
typeof dto.canSend !== 'boolean'
) {
return err('brief-view: missing/invalid decisions');
}
return ok({
canEdit: dto.canEdit,
canApprove: dto.canApprove,
canReject: dto.canReject,
canSend: dto.canSend,
});
}
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!dto.brief) return err('brief-view: missing brief');
const brief = parseBrief(dto.brief);
if (!brief.ok) return brief;
const decisions = parseDecisions(dto.decisions);
if (!decisions.ok) return decisions;
const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p);
if (!parsed.ok) return parsed;
availablePassages.push(parsed.value);
}
return ok({ brief: brief.value, availablePassages });
return ok({ brief: brief.value, availablePassages, decisions: decisions.value });
}
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---