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:
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user