Compare commits

...

2 Commits

Author SHA1 Message Date
5d6a78d4ec docs(backlog): record WP-18 commit hash
Some checks failed
CI / frontend (push) Successful in 1m22s
CI / backend (push) Successful in 1m3s
CI / api-client-drift (push) Successful in 1m36s
CI / storybook-a11y (push) Successful in 4m4s
CI / codeql (csharp) (push) Failing after 1m45s
CI / codeql (javascript-typescript) (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:32:03 +02:00
7ec13d8b59 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>
2026-07-03 20:31:53 +02:00
26 changed files with 4520 additions and 3185 deletions

View File

@@ -147,7 +147,14 @@ public sealed record BriefDto(
IReadOnlyList<LetterSectionDto> Sections,
BriefStatusDto Status, string DrafterId);
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
// Decision flags for the CURRENT acting principal + this brief's live status
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions);
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
public sealed record MeDto(IReadOnlyList<string> Capabilities);

View File

@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -72,11 +73,13 @@ public static class BriefStore
}
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
Review(owner, principal, BriefAction.Approve, () =>
new BriefStatusDto("approved", ApprovedBy: Authz.ActingId(principal), ApprovedAt: at));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
Review(owner, principal, BriefAction.Reject, () =>
new BriefStatusDto("rejected", RejectedBy: Authz.ActingId(principal), RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Send(string owner, string at)
{
@@ -109,15 +112,18 @@ public static class BriefStore
}
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
// from the drafter (a drafter cannot approve their own letter). The SoD check is
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
// inline check exactly.
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
e.Status = next();
return (Outcome.Ok, e);
}
}

View File

@@ -0,0 +1,59 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization;
public enum PrincipalRole { Drafter, Approver }
/// <summary>
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
/// from the client-asserted X-Role header (mirrors the FE's `?role=` toggle). A real
/// system builds this from verified AD/OIDC claims (PRD-0002 §3, §7); everything else
/// in this file — the capability model, the single Authz.Can check both emitting and
/// enforcing — carries over unchanged once that swap happens.
/// </summary>
public sealed record Principal(PrincipalRole Role);
public enum BriefAction { Approve, Reject, Send }
/// <summary>
/// Single source of truth for brief authorization (PRD-0002 phase P1). The SAME
/// check both computes the decision flags shipped on the screen DTO (emit) and
/// gates the mutation endpoints (enforce) — so the two can never drift, closing the
/// classic broken-object-level-authorization gap (PRD-0002 §7).
/// </summary>
public static class Authz
{
public static Principal ResolvePrincipal(HttpContext ctx) =>
new(ctx.Request.Headers["X-Role"].ToString() == "approver" ? PrincipalRole.Approver : PrincipalRole.Drafter);
public static string ActingId(Principal principal) =>
principal.Role == PrincipalRole.Approver ? BriefStore.ApproverId : BriefStore.DrafterId;
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
/// tied to any specific brief's live status; contrast Decisions below).
public static IReadOnlyList<string> RoleCapabilities(Principal principal) =>
principal.Role == PrincipalRole.Approver
? new[] { "brief:approve", "brief:reject", "brief:send" }
: Array.Empty<string>();
/// Role + four-eyes (SoD) check only — no status. This is the exact check
/// BriefStore.Review enforces before its status guard; kept separate from
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
/// today's behavior exactly.
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
{
BriefAction.Approve or BriefAction.Reject => ActingId(principal) != drafterId,
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
_ => false,
};
/// Resource-aware decision for the screen DTO: "would this action succeed right
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
/// it never re-derives these booleans itself.
public static BriefDecisionsDto Decisions(Principal principal, string status, string drafterId) => new(
CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected",
CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted",
CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted",
CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved");
}

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
@@ -239,76 +240,81 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that).
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
.Produces<MeDto>();
api.MapGet("/brief", () =>
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
// toggle) — no real identities in this POC. ---
api.MapGet("/brief", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
return ToView(ctx, e);
})
.Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
return BriefResult(ctx, BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/submit", (HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen.");
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/approve", (HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/send", () =>
api.MapPost("/brief/send", (HttpContext ctx) =>
{
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition.
// port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status.");
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reset", () =>
api.MapPost("/brief/reset", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
return ToView(ctx, e);
})
.WithName("briefReset")
.Produces<BriefViewDto>();
@@ -319,17 +325,16 @@ static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"
static string Now() => DateTimeOffset.UtcNow.ToString("o");
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
// else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
e.ToDto(),
BriefSeed.PassagesFor(e.Beroep),
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};

View File

@@ -641,6 +641,25 @@
}
}
},
"/api/v1/me": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeDto"
}
}
}
}
}
}
},
"/api/v1/brief": {
"get": {
"tags": [
@@ -679,7 +698,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -719,7 +738,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -758,7 +777,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -807,7 +826,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -846,7 +865,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -1030,6 +1049,24 @@
},
"additionalProperties": false
},
"BriefDecisionsDto": {
"type": "object",
"properties": {
"canEdit": {
"type": "boolean"
},
"canApprove": {
"type": "boolean"
},
"canReject": {
"type": "boolean"
},
"canSend": {
"type": "boolean"
}
},
"additionalProperties": false
},
"BriefDto": {
"type": "object",
"properties": {
@@ -1123,6 +1160,9 @@
"$ref": "#/components/schemas/LibraryPassageDto"
},
"nullable": true
},
"decisions": {
"$ref": "#/components/schemas/BriefDecisionsDto"
}
},
"additionalProperties": false
@@ -1469,6 +1509,19 @@
},
"additionalProperties": false
},
"MeDto": {
"type": "object",
"properties": {
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
},
"additionalProperties": false
},
"ParagraphDto": {
"type": "object",
"properties": {

View File

@@ -0,0 +1,68 @@
using BigRegister.Domain.Authorization;
using Xunit;
namespace BigRegister.Tests;
/// <summary>
/// Unit tests for the single authorization helper (PRD-0002 phase P1) that both
/// computes the brief screen's decision flags and gates the mutation endpoints.
/// </summary>
public class AuthzTests
{
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
private static readonly Principal Approver = new(PrincipalRole.Approver);
private const string DrafterId = "demo-drafter";
[Fact]
public void Drafter_may_not_approve_or_reject_even_when_submitted()
{
Assert.False(Authz.CanActOn(BriefAction.Approve, Drafter, DrafterId));
Assert.False(Authz.CanActOn(BriefAction.Reject, Drafter, DrafterId));
}
[Fact]
public void Approver_may_approve_and_reject_a_different_drafters_letter()
{
Assert.True(Authz.CanActOn(BriefAction.Approve, Approver, DrafterId));
Assert.True(Authz.CanActOn(BriefAction.Reject, Approver, DrafterId));
}
[Fact]
public void Send_is_not_role_gated()
{
Assert.True(Authz.CanActOn(BriefAction.Send, Drafter, DrafterId));
Assert.True(Authz.CanActOn(BriefAction.Send, Approver, DrafterId));
}
[Theory]
[InlineData("draft")]
[InlineData("rejected")]
public void Decisions_CanEdit_true_for_drafter_in_editable_statuses(string status)
{
Assert.True(Authz.Decisions(Drafter, status, DrafterId).CanEdit);
Assert.False(Authz.Decisions(Approver, status, DrafterId).CanEdit);
}
[Fact]
public void Decisions_CanApprove_requires_submitted_status_and_approver_role()
{
Assert.True(Authz.Decisions(Approver, "submitted", DrafterId).CanApprove);
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanApprove);
Assert.False(Authz.Decisions(Drafter, "submitted", DrafterId).CanApprove);
}
[Fact]
public void Decisions_CanSend_requires_approved_status_only()
{
Assert.True(Authz.Decisions(Drafter, "approved", DrafterId).CanSend);
Assert.True(Authz.Decisions(Approver, "approved", DrafterId).CanSend);
Assert.False(Authz.Decisions(Approver, "submitted", DrafterId).CanSend);
}
[Fact]
public void RoleCapabilities_are_empty_for_drafter_and_the_three_brief_capabilities_for_approver()
{
Assert.Empty(Authz.RoleCapabilities(Drafter));
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver));
}
}

View File

@@ -90,8 +90,8 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("submitted", submitted!.Status.Tag);
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
}
[Fact]
@@ -106,7 +106,7 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
}
[Fact]
@@ -117,13 +117,13 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("rejected", rejected!.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
// A drafter save on a rejected letter reopens it to draft.
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("draft", reopened!.Status.Tag);
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", reopened!.Brief.Status.Tag);
}
[Fact]
@@ -139,7 +139,36 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode();
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
}
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await Get();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
Assert.False(view.Decisions.CanApprove);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var asApprover = await _client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.True(approverView!.Decisions.CanApprove);
Assert.False(approverView.Decisions.CanEdit); // approver never edits
}
[Fact]
public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver()
{
var asDrafter = await _client.GetFromJsonAsync<MeDto>("/api/v1/me");
Assert.Empty(asDrafter!.Capabilities);
var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } });
var asApprover = await res.Content.ReadFromJsonAsync<MeDto>();
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
}
[Fact]

View File

@@ -57,7 +57,7 @@ for its existing violations, so every WP ends green.
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | todo |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo |

View File

@@ -1,6 +1,6 @@
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: todo
Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid
## Why
@@ -8,146 +8,171 @@ Phase: 5 — productie-volwassenheid
The single biggest gap between this POC and a production SSP: identity carries no
roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
computes its authorization gate **in the frontend** from that header — the exact
computed its authorization gate **in the frontend** from that header — the exact
anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
The backend is fully open: no `[Authorize]`, no principal, ownership is a constant
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This
WP implements PRD-0002's **P1 — Capability spine** only (§9), the smallest slice
that closes the anti-pattern and gives every later phase (data-scoping, PII
redaction, step-up/audit) a real foundation to extend.
The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
a real foundation to extend.
## Read first
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
union, identity-vs-authorization split)
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 (this WP
implements exactly P1 — don't reach into P2/P3)
- `src/app/auth/domain/session.ts` (flat `Session` to replace)
- `src/app/auth/application/session.store.ts`, `src/app/auth/auth.guard.ts` (seams
that already localise the `Session → Principal` swap, per ADR-0002)
- `src/app/shared/domain/role.ts`, `src/app/shared/infrastructure/role.ts` +
`role.interceptor.ts` (the dev stub being retired as an authority, kept as a dev
toggle)
- `src/app/brief/application/brief.store.ts:28,41-46` (`readonly role = currentRole()`
and the `editable` computed — the FE-computed gate to remove)
- `backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27` (`HerregistratieDecisionsDto`
— the decision-flag pattern this WP extends to a `BriefDecisionsDto`)
- `backend/src/BigRegister.Api/Program.cs:318-335` (`IsAdmin`, the `X-Role` reader
around brief endpoints — becomes `Authz.Can`)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review`, the
`actingId == e.DrafterId → Forbidden` SoD check — keep this check, move it behind
the verified principal)
union, identity-vs-authorization split — see the deviation noted below)
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
authorization helper)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
SoD guard to `Authz.CanActOn`)
- `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
## Decisions (pre-made, don't relitigate)
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or
audit log — those are PRD-0002 §9 P2/P3, separate future WPs. This WP: `Principal`,
`AccessStore`/`can()`, `capabilityGuard`, `GET /me`, capability flags on the brief
screen DTO, and server-side enforcement via one shared `Authz.Can` helper.
audit log — those are PRD-0002 §9 P2/P3, separate future WPs.
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
`Principal` is still built server-side from the existing dev stand-ins
(`X-Role`/`X-Admin` headers), but it becomes the backend's own construct — the FE
never re-derives capabilities from the header, it only reads what the backend sends.
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — start with
exactly `brief:approve`, `brief:reject`, `brief:send` (the brief flow is the only
role-gated flow that exists today). Do not invent capabilities for flows that don't
exist yet (e.g. `aanvraag:beoordelen` — that's the backoffice, ADR-0002, out of scope).
- **Emit and enforce are the same code path.** `Authz.Can(principal, action, resource)`
is called both to compute the DTO flag and to gate the endpoint — never two separate
checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out).
- **Dev role toggle survives**, but moves behind the `Principal` seam: `?role=` still
picks an identity for demo purposes, but it flows into building the `Principal`
server-side (still asserted by the client — this is _not_ real auth, just moving
the authority from FE-computed to BE-computed within the POC's honesty envelope).
Keep it explicitly commented `// dev stub — NOT a security boundary` per PRD-0002 §3.
`Principal` is built server-side from the existing dev stand-in (`X-Role` header),
but it becomes the backend's own construct — the FE never re-derives capabilities
from the header, it only reads what the backend sends.
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
`brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
(draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
stays exactly the three above.
- **Emit and enforce are the same code path for approve/reject.**
`Authz.CanActOn(action, principal, drafterId)` is the SAME check
`BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
BOLA bug it calls out). `Send` is deliberately **not** role-gated (see Risks) —
that parity is preserved exactly, decisions only mirror it.
- **Dev role toggle survives** as the POC's identity stub: `?role=` still picks an
identity for demo purposes, resolved into a `Principal` server-side via
`Authz.ResolvePrincipal`. Commented `dev stub — NOT a security boundary` per
PRD-0002 §3.
- **Deviation from the original plan — `auth/domain/session.ts` is untouched.** An
earlier draft of this WP planned a `Session → Principal` rename in the SSP's login
domain. That's **out of scope**: ADR-0002 explicitly lists that refactor as
"deferred until a second actor is actually introduced" (§"Out of scope here"), and
no second actor exists yet — renaming a type to a one-variant union ahead of that
need is exactly the premature abstraction the ADR warns against. It also turned
out unnecessary: the brief workflow's drafter/approver "acting identity" is a
**separate axis** from the SSP login session (a Zorgverlener logs in via BSN;
drafter/approver is an independent `?role=` toggle, not tied to that login). This
WP's `Principal` therefore lives entirely in the backend's
`BigRegister.Domain.Authorization` namespace and never touches `auth/`.
## Files
## Files (as built)
- `src/app/auth/domain/session.ts` — replace `Session` with the `Principal`
discriminated union from ADR-0002 (`{ kind: 'zorgverlener'; bsn; naam }` — no
`medewerker` variant yet, that's ADR-0002/backoffice scope; keep the union shape so
it's additive later). Update `isAuthenticated`.
- `src/app/auth/application/session.store.ts` — carries the `Principal`; unchanged
persistence rules (never persist the BSN, per existing comment).
- New `src/app/shared/domain/capability.ts` — branded/union `Capability` type
(`'brief:approve' | 'brief:reject' | 'brief:send'`), framework-free.
- New `src/app/shared/application/access.store.ts``providedIn: 'root'`, holds
resolved capabilities as `RemoteData` from `GET /me`; `can(capability): boolean`,
deny-by-default on absence.
- New `src/app/shared/infrastructure/me.adapter.ts` (+ spec) — calls `GET /me`,
`parseMe(): Result` boundary.
- New `capabilityGuard` in `src/app/auth/auth.guard.ts` (or co-located
`capability.guard.ts`) — factory `CanActivateFn` extending `authGuard`'s shape.
- `src/app/brief/application/brief.store.ts` — delete `readonly role = currentRole()`
and the FE-computed `editable`; read `canApprove`/`canReject`/`canSend` off the
loaded `BriefViewDto`'s new `BriefDecisionsDto` instead.
- `src/app/shared/infrastructure/role.interceptor.ts` — keep (still asserts the dev
identity), retitle its comment to "feeds Principal construction, not an authority
the FE reads back."
- Backend: `backend/src/BigRegister.Api/Contracts/Dtos.cs` — add
`BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend)`; add it to
`BriefViewDto`.
- New `backend/src/BigRegister.Api/Domain/Authorization/Principal.cs` +
`Authz.cs``Principal` (mirrors the FE union), `Authz.Can(principal, action)`
covering the three brief capabilities + the existing SoD rule.
- `backend/src/BigRegister.Api/Program.cs` — add `GET /api/v1/me` returning the
resolved `Principal`'s capabilities; replace the ad-hoc `X-Role` reads around
brief endpoints with `Authz.Can`; compute `BriefDecisionsDto` via the same helper.
- New backend test `backend/tests/BigRegister.Tests/AuthzTests.cs``Authz.Can`
unit tests (approve/reject/send × drafter/approver × SoD).
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
`PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
CanActOn/Decisions`.
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
(same Forbidden-before-Conflict ordering as before).
- `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
(including `send`, which had no `HttpContext` before) now returns a fresh
`BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
stale after a mutation.
- `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
- `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
`BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
tests for live decisions and `/me`.
- `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
- `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
`parseMe` boundary (unknown capability strings are dropped, not rejected).
- `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
deny-by-default.
- `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
**Built but deliberately unwired**: no route in this app needs a capability gate
today (both drafter and approver land on the same `/brief` page; the gating is
per-action, not per-page). It's the available building block for a future
approver-only page.
- `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
- `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
`BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
`decisions`; the pure `transition()` helper replaces them with each fresh
server value.
- `src/app/brief/infrastructure/brief.adapter.ts` (+ spec) — `save/submit/approve/
reject/send` now return `Result<string, BriefView>` (was `Brief`) via
`parseBriefView`, which also parses `decisions`.
- `src/app/brief/application/brief.store.ts` — deleted `currentRole()`/`editable`;
added `canEdit`/`canApprove`/`canReject`/`canSend` computed straight from
`BriefState.loaded.decisions`.
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (+ stories) —
`editable`/`role` inputs replaced by the four `can*` inputs; the approve/reject
block gates on `canApprove() || canReject()`, the send button on `canSend()`.
- `src/app/brief/ui/brief.page.ts` — passes the four `can*` signals through.
- `src/app/shared/infrastructure/role.ts` — comment updated (no longer claims the
FE derives `editable` from the role reader).
- Regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`
via `npm run gen:api` (new `/me` endpoint + DTO shapes).
## Steps
## Steps (as executed)
1. Backend: `Principal`, `Authz.Can`, `GET /me`, `BriefDecisionsDto` wired into the
existing brief endpoints (replace `IsDrafter`/`X-Role` reads one at a time,
keeping `BriefEndpointTests.cs` green after each).
2. FE: `Capability` type, `access.store.ts`, `me.adapter.ts`, `capabilityGuard`.
3. FE: `Session → Principal` in `auth/domain`; thread through `SessionStore`,
`auth.guard.ts` (both keep working — `isAuthenticated` still means "has a
Principal").
4. FE: `brief.store.ts` reads `canApprove`/`canReject`/`canSend` from the DTO;
delete `currentRole()` import and the FE-computed `editable`. Update the brief
UI components consuming `.editable`/`.role` to consume the new flags.
5. Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or
leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP).
1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
(`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
(79/79 including 10 new tests).
2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
`me.adapter.ts` (+spec) as the general capability-spine infrastructure.
6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
7. Full GREEN gate + a live curl smoke test against the running backend (submit as
drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
flipping correctly at each step).
## Acceptance criteria
- [ ] `brief.store.ts` contains no `currentRole()` call and no FE-computed
- [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
- [ ] Forging `?role=approver` in the browser with a stale/absent server capability
still gets a 403 from the backend (verified by a test hitting the endpoint
directly, bypassing the FE).
- [ ] `Authz.Can` is the only place brief authorization logic lives; the emit path
(DTO flags) and the enforce path (endpoint gating) both call it.
- [ ] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
unknown capability.
- [ ] The existing SoD rule (approver ≠ drafter) still holds, now expressed as an
`Authz.Can` precondition rather than inline in `BriefStore.Review`.
- [ ] `capabilityGuard` compiles and is demonstrated on at least one route (or
documented as available-but-unwired if no route needs it yet — brief has no
route today, it's a page section).
- [x] The SoD rule is enforced server-side regardless of FE state — verified by
curl directly against the backend (drafter calling `/brief/approve` → 403)
and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
- [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
both call it.
- [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
- [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
`Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
- [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
needs it yet — see Files).
## Verification
GREEN (`docs/backlog/README.md`) + `cd backend && dotnet test`. Manual smoke:
`npm start` with `?role=drafter` — draft-only actions enabled; `?role=approver`
approve/reject enabled, editing disabled. Confirm via browser devtools that removing
the `X-Role` header (or backend patched to ignore it) makes every capability `false`
— i.e. deny-by-default actually denies.
GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
`dotnet format --verify-no-changes`. Manual smoke via curl against a running
backend: default (drafter) `GET /brief` → `canEdit: true`; submit → decisions
recompute; drafter `POST /brief/approve` → 403; approver `POST /brief/approve` →
200, `canSend: true` afterward. `GET /me` → `[]` for drafter,
`["brief:approve","brief:reject","brief:send"]` for approver.
## Out of scope
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
log) — separate future WPs. The Behandeling/backoffice app and `medewerker`
log) — separate future WPs. The Behandeling/backoffice app and a `medewerker`
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
AD/OIDC integration (identity provider stays simulated).
AD/OIDC integration (identity provider stays simulated). The `auth/domain/session.ts`
`Session → Principal` rename (see the Decisions deviation above — ADR-0002 defers
it explicitly).
## Risks
Threading `Principal` through `SessionStore`/`auth.guard.ts` touches the one
authenticated-session seam every route depends on — keep the observable shape
(`isAuthenticated(): boolean`) identical so no route wiring needs to change, only
what's inside `Session`/`Principal`. Backend `Authz.Can` replacing inline `X-Role`
reads must preserve the existing 403 `Outcome.Forbidden` mapping
(`Program.cs:330-335`) exactly, or `BriefEndpointTests.cs` breaks.
`Send` was already unauthenticated/unauthorized before this WP (no role check on
`POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
(`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
that would have broken the existing `Send_only_from_approved` test (which calls
`send` as the default drafter identity and expects success). If a future WP decides
`send` should be approver-only, that's a deliberate behavior change, not a bug fix.
Every brief mutation endpoint now returns `BriefViewDto` instead of bare `BriefDto`
— a wire-shape change; the generated `api-client.ts` was regenerated and every FE
call site updated, but any other caller of these endpoints outside this repo would
need the same update.

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
@@ -8,3 +10,22 @@ export const authGuard: CanActivateFn = () => {
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. No route in this app currently needs a capability gate — brief's
* canApprove/canReject/canSend are per-action, not per-page (both actors land on
* the same `/brief` page and see different actions) — so this exists as the
* available building block for the day a route-level gate is needed, e.g. a future
* approver-only page.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
return session.isAuthenticated() && access.can(capability)
? true
: router.createUrlTree(['/login']);
};
}

View File

@@ -1,8 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import {
Brief,
allDiagnostics,
@@ -11,13 +9,15 @@ import {
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`,
* `unresolved`, `canSubmit` are DERIVED here — never stored.
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore {
@@ -25,7 +25,6 @@ export class BriefStore {
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
readonly role: Role = currentRole();
readonly busy = signal(false);
readonly lastError = signal<string | null>(null);
/** Surfaced autosave state for the indicator + aria-live region. */
@@ -36,13 +35,14 @@ export class BriefStore {
return s.tag === 'loaded' ? s.brief : null;
});
/** The single source of truth for edit permission: drafter, and a status the reducer
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
readonly editable = computed(() => {
const b = this.brief();
return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
);
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
private decisions = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -54,12 +54,7 @@ export class BriefStore {
async load() {
const r = await this.adapter.load();
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -71,7 +66,7 @@ export class BriefStore {
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.editable()) return;
if (!this.canEdit()) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
@@ -97,12 +92,7 @@ export class BriefStore {
const r = await this.adapter.reset();
this.busy.set(false);
this.saveState.set('idle');
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.lastError.set(r.error);
}
@@ -113,7 +103,7 @@ export class BriefStore {
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, Brief>>) {
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.busy.set(true);
this.lastError.set(null);
clearTimeout(this.saveTimer);
@@ -127,14 +117,15 @@ export class BriefStore {
this.applyServerStatus(r.value);
}
private applyServerStatus(brief: Brief) {
private applyServerStatus(view: BriefView) {
const { brief, decisions } = view;
const s = brief.status;
switch (s.tag) {
case 'submitted':
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
break;
case 'approved':
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
this.store.dispatch({
@@ -142,10 +133,11 @@ export class BriefStore {
by: s.rejectedBy,
at: s.rejectedAt,
comments: s.comments,
decisions,
});
break;
case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
break;
case 'draft':
// reopened by a save on a rejected letter — reducer already handled it locally.

View File

@@ -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 {

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 };
}

View File

@@ -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;
}

View File

@@ -46,6 +46,7 @@ const view: BriefViewDto = {
version: 1,
},
],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
};
describe('brief.adapter parse boundary', () => {
@@ -67,6 +68,19 @@ describe('brief.adapter parse boundary', () => {
autoResolvable: true,
fillable: false,
});
expect(r.value.decisions).toEqual({
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
});
});
it('rejects a view whose decisions are missing or malformed', () => {
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
expect(
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
).toBe(false);
});
it('narrows node variants and rejects unknown ones', () => {

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) ---

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)"

View File

@@ -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,10 +124,12 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
}
}
@case ('approved') {
@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);

View File

@@ -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,
}),
};

View File

@@ -0,0 +1,36 @@
import { Injectable, computed, inject } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { Capability } from '@shared/domain/capability';
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
type Err = Error | undefined;
/**
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
* to a specific resource's live status — no extra round-trip needed for that.
*
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
* resolve to `false`. This store never derives a capability from a role — it only
* mirrors what the server already resolved.
*/
@Injectable({ providedIn: 'root' })
export class AccessStore {
private adapter = inject(MeAdapter);
private meRes = this.adapter.meResource();
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
const rd = fromResource(this.meRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseMe(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
can(capability: Capability): boolean {
const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability);
}
}

View File

@@ -0,0 +1,5 @@
/**
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
* Server-resolved and opaque to the FE — never derived from a role client-side.
*/
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send';

View File

@@ -930,6 +930,42 @@ export class ApiClient {
return Promise.resolve<SubmitApplicationResponse>(null as any);
}
/**
* @return OK
*/
me(): Promise<MeDto> {
let url_ = this.baseUrl + "/api/v1/me";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processMe(_response);
});
}
protected processMe(response: Response): Promise<MeDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as MeDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<MeDto>(null as any);
}
/**
* @return OK
*/
@@ -969,7 +1005,7 @@ export class ApiClient {
/**
* @return OK
*/
briefPUT(body: SaveBriefRequest): Promise<BriefDto> {
briefPUT(body: SaveBriefRequest): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief";
url_ = url_.replace(/[?&]$/, "");
@@ -989,13 +1025,13 @@ export class ApiClient {
});
}
protected processBriefPUT(response: Response): Promise<BriefDto> {
protected processBriefPUT(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status === 403) {
@@ -1015,13 +1051,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
briefSubmit(): Promise<BriefDto> {
briefSubmit(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/submit";
url_ = url_.replace(/[?&]$/, "");
@@ -1037,13 +1073,13 @@ export class ApiClient {
});
}
protected processBriefSubmit(response: Response): Promise<BriefDto> {
protected processBriefSubmit(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status === 403) {
@@ -1063,13 +1099,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
approve(): Promise<BriefDto> {
approve(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/approve";
url_ = url_.replace(/[?&]$/, "");
@@ -1085,13 +1121,13 @@ export class ApiClient {
});
}
protected processApprove(response: Response): Promise<BriefDto> {
protected processApprove(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status === 403) {
@@ -1111,13 +1147,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
reject(body: RejectBriefRequest): Promise<BriefDto> {
reject(body: RejectBriefRequest): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/reject";
url_ = url_.replace(/[?&]$/, "");
@@ -1137,13 +1173,13 @@ export class ApiClient {
});
}
protected processReject(response: Response): Promise<BriefDto> {
protected processReject(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status === 403) {
@@ -1163,13 +1199,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
send(): Promise<BriefDto> {
send(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/send";
url_ = url_.replace(/[?&]$/, "");
@@ -1185,13 +1221,13 @@ export class ApiClient {
});
}
protected processSend(response: Response): Promise<BriefDto> {
protected processSend(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status === 409) {
@@ -1205,7 +1241,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
return Promise.resolve<BriefViewDto>(null as any);
}
/**
@@ -1287,6 +1323,13 @@ export interface ApplicationSummaryDto {
submittedAt?: string | undefined;
}
export interface BriefDecisionsDto {
canEdit?: boolean;
canApprove?: boolean;
canReject?: boolean;
canSend?: boolean;
}
export interface BriefDto {
briefId?: string | undefined;
beroep?: string | undefined;
@@ -1312,6 +1355,7 @@ export interface BriefStatusDto {
export interface BriefViewDto {
brief?: BriefDto;
availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto;
}
export interface BrpAddressDto {
@@ -1423,6 +1467,10 @@ export interface ManualDiplomaPolicyDto {
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface MeDto {
capabilities?: string[] | undefined;
}
export interface ParagraphDto {
nodes?: RichTextNodeDto[] | undefined;
list?: string | undefined;

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { parseMe } from './me.adapter';
describe('parseMe (trust boundary)', () => {
it('parses a known capability list', () => {
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
});
it('parses an empty list (drafter — no capabilities)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
});
it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
});
it('rejects malformed responses instead of trusting them', () => {
expect(parseMe(null).ok).toBe(false);
expect(parseMe({}).ok).toBe(false);
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Capability } from '@shared/domain/capability';
import { ApiClient } from '@shared/infrastructure/api-client';
const KNOWN: readonly Capability[] = ['brief:approve', 'brief:reject', 'brief:send'];
/**
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
*/
@Injectable({ providedIn: 'root' })
export class MeAdapter {
private client = inject(ApiClient);
meResource() {
return resource({ loader: () => this.client.me() });
}
}
/**
* Trust-boundary parse. An unrecognized capability string is dropped rather than
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
* returns false for anything not in the set), and it lets the backend grow the
* capability list without breaking an older FE build.
*/
export function parseMe(json: unknown): Result<string, Capability[]> {
if (typeof json !== 'object' || json === null) return err('me: not an object');
const dto = json as { capabilities?: unknown };
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
}

View File

@@ -5,8 +5,10 @@ import { Role } from '@shared/domain/role';
* POC has one faked self-service user and no real identities, so the two-person
* letter workflow (drafter vs approver) is driven by a `?role=` query param —
* exactly the pattern of the `?scenario=` toggle. The backend receives it as an
* `X-Role` header (see role.interceptor) and enforces the approver≠drafter rule;
* the FE derives `editable` from it.
* `X-Role` header (see role.interceptor), resolves it into a `Principal`
* server-side, and is the sole authority on what that principal may do (PRD-0002
* phase P1, `Authz.Can`) — the FE only renders the resulting decision flags, it no
* longer derives permission from this value itself.
*/
export function currentRole(): Role {
return new URLSearchParams(window.location.search).get('role') === 'approver'