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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, BriefStatus, LibraryPassage } from './brief';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
||||
@@ -37,6 +37,15 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
};
|
||||
}
|
||||
|
||||
// A machine test cares about status transitions, not who may act — a fixed,
|
||||
// unrestrictive fixture keeps every existing assertion focused on that.
|
||||
const decisions: BriefDecisions = {
|
||||
canEdit: true,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: true,
|
||||
};
|
||||
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
@@ -44,6 +53,7 @@ const loaded = (
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
decisions,
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
@@ -56,6 +66,7 @@ describe('brief.machine reduce', () => {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [],
|
||||
decisions,
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
@@ -175,10 +186,10 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||
// required 'aanhef' empty → no-op
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't' })).toEqual(loaded());
|
||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||
// fill the required section, then submit
|
||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
@@ -189,15 +200,21 @@ describe('brief.machine reduce', () => {
|
||||
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
});
|
||||
// reject carries comments
|
||||
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
||||
const rejected = reduce(submitted, {
|
||||
tag: 'Rejected',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
comments: 'nee',
|
||||
decisions,
|
||||
});
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
@@ -205,10 +222,27 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'nee',
|
||||
});
|
||||
// send only from approved
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
});
|
||||
|
||||
it('a status transition replaces decisions with the fresh server value', () => {
|
||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
const staleApprover: BriefDecisions = {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
};
|
||||
const approved = reduce(submitted, {
|
||||
tag: 'Approved',
|
||||
by: 'u2',
|
||||
at: 't2',
|
||||
decisions: staleApprover,
|
||||
});
|
||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||
});
|
||||
});
|
||||
|
||||
function initialLoading(): BriefState {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -107,3 +107,12 @@ export function unresolvedPlaceholders(brief: Brief): string[] {
|
||||
export function canSubmit(brief: Brief): boolean {
|
||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||
}
|
||||
|
||||
/** Server-computed decision flags for the acting principal + this brief's live
|
||||
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||
export interface BriefDecisions {
|
||||
readonly canEdit: boolean;
|
||||
readonly canApprove: boolean;
|
||||
readonly canReject: boolean;
|
||||
readonly canSend: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user