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
+4 -2
View File
@@ -58,8 +58,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
[brief]="brief()!"
[availablePassages]="availablePassages()"
[diagnostics]="store.diagnostics()"
[editable]="store.editable()"
[role]="store.role"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[canSubmit]="store.canSubmit()"
[busy]="store.busy()"
(edit)="store.edit($event)"
@@ -1,5 +1,4 @@
import { Component, computed, input, output } from '@angular/core';
import { Role } from '@shared/domain/role';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
@@ -15,7 +14,9 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
read-only preview (approver / locked), the diagnostics panel, and the action bar
appropriate to status × role. Presentational: emits edit + transition intents. */
appropriate to status × permission. Presentational: emits edit + transition
intents; the canEdit/canApprove/canReject/canSend inputs are server-computed
decision flags (PRD-0002 phase P1) — this component never derives them. */
@Component({
selector: 'app-letter-composer',
imports: [
@@ -67,7 +68,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (editable()) {
@if (canEdit()) {
<div class="sections">
@for (section of brief().sections; track section.sectionKey) {
<app-letter-section
@@ -90,7 +91,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<div class="bar">
@switch (status()) {
@case ('draft') {
@if (editable()) {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
@@ -103,7 +104,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
}
}
@case ('rejected') {
@if (editable()) {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
@@ -113,7 +114,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
}
}
@case ('submitted') {
@if (role() === 'approver') {
@if (canApprove() || canReject()) {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
approveLabel()
}}</app-button>
@@ -123,9 +124,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
}
}
@case ('approved') {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel()
}}</app-button>
@if (canSend()) {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel()
}}</app-button>
}
}
@case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert>
@@ -138,8 +141,10 @@ export class LetterComposerComponent {
brief = input.required<Brief>();
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
editable = input(false);
role = input.required<Role>();
canEdit = input(false);
canApprove = input(false);
canReject = input(false);
canSend = input(false);
canSubmit = input(false);
busy = input(false);
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
const passages: LibraryPassage[] = [
@@ -103,18 +103,18 @@ function brief(status: BriefStatus): Brief {
};
}
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
const render = (b: Brief, decisions: BriefDecisions) => ({
props: {
brief: b,
availablePassages: passages,
diagnostics: allDiagnostics(b),
editable,
role,
...decisions,
canSubmit: true,
busy: false,
},
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
[canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [canSend]="canSend"
[canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
});
const meta: Meta<LetterComposerComponent> = {
@@ -125,15 +125,22 @@ export default meta;
type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = {
render: () => render(brief({ tag: 'draft' }), true, 'drafter'),
render: () =>
render(brief({ tag: 'draft' }), {
canEdit: true,
canApprove: false,
canReject: false,
canSend: false,
}),
};
export const SubmittedApprover: Story = {
render: () =>
render(
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }),
false,
'approver',
),
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
}),
};
export const Rejected: Story = {
render: () =>
@@ -144,10 +151,15 @@ export const Rejected: Story = {
rejectedAt: '2026-07-01',
comments: 'Graag de aanhef formeler.',
}),
true,
'drafter',
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
),
};
export const Sent: Story = {
render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter'),
render: () =>
render(brief({ tag: 'sent', sentAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
}),
};