feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)

Replace the FE-computed authorization anti-pattern in BriefStore.editable
(derived from the unverified X-Role header) with server-computed decision
flags, mirroring the existing HerregistratieDecisionsDto pattern:

- Backend: Authz.cs is the single authorization helper — the SAME check
  (Authz.CanActOn) both gates BriefStore.Review's mutations and computes
  the BriefDecisionsDto flags shipped on every brief response, so emit
  and enforce can never drift. New GET /me returns coarse, role-derived
  capabilities (PRD-0002 SS6).
- Every brief endpoint (including send, previously ungated on HttpContext)
  now returns a fresh BriefViewDto so decisions never go stale after a
  mutation.
- FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the
  loaded decisions instead of computing them from currentRole(); the
  brief.machine carries decisions through every status transition.
- New shared/domain/capability.ts + shared/application/access.store.ts +
  shared/infrastructure/me.adapter.ts: the general capability-spine
  infrastructure (AccessStore.can(), capabilityGuard) for future routes.

Deviates from the original WP-18 draft by NOT renaming auth/domain's
Session to a Principal union — ADR-0002 explicitly defers that refactor
until a second actor exists, and the brief workflow's drafter/approver
identity turned out to be a separate axis from the SSP login session
entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full
as-built record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:31:53 +02:00
parent cbb8ae548c
commit 7ec13d8b59
26 changed files with 4520 additions and 3185 deletions

View File

@@ -147,7 +147,14 @@ public sealed record BriefDto(
IReadOnlyList<LetterSectionDto> Sections, IReadOnlyList<LetterSectionDto> Sections,
BriefStatusDto Status, string DrafterId); 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 SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments); 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.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -72,11 +73,13 @@ public static class BriefStore
} }
} }
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) => public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: 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) => public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments)); 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) 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 // Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter). // from the drafter (a drafter cannot approve their own letter). The SoD check is
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next) // 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) lock (_gate)
{ {
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); 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); if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e); e.Status = next();
return (Outcome.Ok, e); 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 System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents; using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake; using BigRegister.Domain.Intake;
@@ -239,76 +240,81 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Brief (letter composition). One demo brief per owner; the server owns the // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the // tied to a specific brief's live status — see BriefDecisionsDto for that).
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. --- 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); var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); return ToView(ctx, e);
}) })
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{ {
var (_, isDrafter) = BriefRole(ctx); var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); 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.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/submit", (HttpContext ctx) => 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()); var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r); 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` .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r); 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.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r); 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.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .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 // 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()); var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r); 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); .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. // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner); var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); return ToView(ctx, e);
}) })
.WithName("briefReset") .WithName("briefReset")
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
@@ -319,17 +325,16 @@ static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"
static string Now() => DateTimeOffset.UtcNow.ToString("o"); static string Now() => DateTimeOffset.UtcNow.ToString("o");
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
// else (default) acts as the drafter. e.ToDto(),
static (string acting, bool isDrafter) BriefRole(HttpContext ctx) BriefSeed.PassagesFor(e.Beroep),
{ Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}
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), 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), _ => 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": { "/api/v1/brief": {
"get": { "get": {
"tags": [ "tags": [
@@ -679,7 +698,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -719,7 +738,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -758,7 +777,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -807,7 +826,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -846,7 +865,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -1030,6 +1049,24 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"BriefDecisionsDto": {
"type": "object",
"properties": {
"canEdit": {
"type": "boolean"
},
"canApprove": {
"type": "boolean"
},
"canReject": {
"type": "boolean"
},
"canSend": {
"type": "boolean"
}
},
"additionalProperties": false
},
"BriefDto": { "BriefDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1123,6 +1160,9 @@
"$ref": "#/components/schemas/LibraryPassageDto" "$ref": "#/components/schemas/LibraryPassageDto"
}, },
"nullable": true "nullable": true
},
"decisions": {
"$ref": "#/components/schemas/BriefDecisionsDto"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1469,6 +1509,19 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"MeDto": {
"type": "object",
"properties": {
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
},
"additionalProperties": false
},
"ParagraphDto": { "ParagraphDto": {
"type": "object", "type": "object",
"properties": { "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")); var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>(); var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("submitted", submitted!.Status.Tag); Assert.Equal("submitted", submitted!.Brief.Status.Tag);
} }
[Fact] [Fact]
@@ -106,7 +106,7 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag); Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
} }
[Fact] [Fact]
@@ -117,13 +117,13 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejected = await (await _client.SendAsync( var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>(); Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("rejected", rejected!.Status.Tag); Assert.Equal("rejected", rejected!.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Status.Comments); Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
// A drafter save on a rejected letter reopens it to draft. // 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>(); var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", reopened!.Status.Tag); Assert.Equal("draft", reopened!.Brief.Status.Tag);
} }
[Fact] [Fact]
@@ -139,7 +139,36 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send")); var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode(); 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] [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-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-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-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-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-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 | | [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) # WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: todo Status: done (pending commit — see repo history for the final hash)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -8,146 +8,171 @@ Phase: 5 — productie-volwassenheid
The single biggest gap between this POC and a production SSP: identity carries no 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 roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable` `?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). 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 The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This `DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
WP implements PRD-0002's **P1 — Capability spine** only (§9), the smallest slice PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
that closes the anti-pattern and gives every later phase (data-scoping, PII anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
redaction, step-up/audit) a real foundation to extend. a real foundation to extend.
## Read first ## Read first
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal` - `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
union, identity-vs-authorization split) union, identity-vs-authorization split — see the deviation noted below)
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 (this WP - `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
implements exactly P1 — don't reach into P2/P3) - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
- `src/app/auth/domain/session.ts` (flat `Session` to replace) authorization helper)
- `src/app/auth/application/session.store.ts`, `src/app/auth/auth.guard.ts` (seams - `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
that already localise the `Session → Principal` swap, per ADR-0002) SoD guard to `Authz.CanActOn`)
- `src/app/shared/domain/role.ts`, `src/app/shared/infrastructure/role.ts` + - `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
`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)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or - **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`, audit log — those are PRD-0002 §9 P2/P3, separate future WPs.
`AccessStore`/`can()`, `capabilityGuard`, `GET /me`, capability flags on the brief
screen DTO, and server-side enforcement via one shared `Authz.Can` helper.
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The - **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 `Principal` is built server-side from the existing dev stand-in (`X-Role` header),
(`X-Role`/`X-Admin` headers), but it becomes the backend's own construct — the FE but it becomes the backend's own construct — the FE never re-derives capabilities
never re-derives capabilities from the header, it only reads what the backend sends. from the header, it only reads what the backend sends.
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — start with - **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
exactly `brief:approve`, `brief:reject`, `brief:send` (the brief flow is the only `brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
role-gated flow that exists today). Do not invent capabilities for flows that don't exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
exist yet (e.g. `aanvraag:beoordelen` — that's the backoffice, ADR-0002, out of scope). on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
- **Emit and enforce are the same code path.** `Authz.Can(principal, action, resource)` (draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
is called both to compute the DTO flag and to gate the endpoint — never two separate business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out). stays exactly the three above.
- **Dev role toggle survives**, but moves behind the `Principal` seam: `?role=` still - **Emit and enforce are the same code path for approve/reject.**
picks an identity for demo purposes, but it flows into building the `Principal` `Authz.CanActOn(action, principal, drafterId)` is the SAME check
server-side (still asserted by the client — this is _not_ real auth, just moving `BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
the authority from FE-computed to BE-computed within the POC's honesty envelope). the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
Keep it explicitly commented `// dev stub — NOT a security boundary` per PRD-0002 §3. 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` - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
discriminated union from ADR-0002 (`{ kind: 'zorgverlener'; bsn; naam }` — no `PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
`medewerker` variant yet, that's ADR-0002/backoffice scope; keep the union shape so CanActOn/Decisions`.
it's additive later). Update `isAuthenticated`. - `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
- `src/app/auth/application/session.store.ts` — carries the `Principal`; unchanged CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
persistence rules (never persist the BSN, per existing comment). - `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
- New `src/app/shared/domain/capability.ts` — branded/union `Capability` type a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
(`'brief:approve' | 'brief:reject' | 'brief:send'`), framework-free. (same Forbidden-before-Conflict ordering as before).
- New `src/app/shared/application/access.store.ts``providedIn: 'root'`, holds - `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
resolved capabilities as `RemoteData` from `GET /me`; `can(capability): boolean`, (including `send`, which had no `HttpContext` before) now returns a fresh
deny-by-default on absence. `BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
- New `src/app/shared/infrastructure/me.adapter.ts` (+ spec) — calls `GET /me`, stale after a mutation.
`parseMe(): Result` boundary. - `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
- New `capabilityGuard` in `src/app/auth/auth.guard.ts` (or co-located - `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
`capability.guard.ts`) — factory `CanActivateFn` extending `authGuard`'s shape. `BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
- `src/app/brief/application/brief.store.ts` — delete `readonly role = currentRole()` tests for live decisions and `/me`.
and the FE-computed `editable`; read `canApprove`/`canReject`/`canSend` off the - `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
loaded `BriefViewDto`'s new `BriefDecisionsDto` instead. - `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
- `src/app/shared/infrastructure/role.interceptor.ts` — keep (still asserts the dev `parseMe` boundary (unknown capability strings are dropped, not rejected).
identity), retitle its comment to "feeds Principal construction, not an authority - `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
the FE reads back." deny-by-default.
- Backend: `backend/src/BigRegister.Api/Contracts/Dtos.cs` — add - `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
`BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend)`; add it to **Built but deliberately unwired**: no route in this app needs a capability gate
`BriefViewDto`. today (both drafter and approver land on the same `/brief` page; the gating is
- New `backend/src/BigRegister.Api/Domain/Authorization/Principal.cs` + per-action, not per-page). It's the available building block for a future
`Authz.cs``Principal` (mirrors the FE union), `Authz.Can(principal, action)` approver-only page.
covering the three brief capabilities + the existing SoD rule. - `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
- `backend/src/BigRegister.Api/Program.cs` — add `GET /api/v1/me` returning the - `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
resolved `Principal`'s capabilities; replace the ad-hoc `X-Role` reads around `BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
brief endpoints with `Authz.Can`; compute `BriefDecisionsDto` via the same helper. `decisions`; the pure `transition()` helper replaces them with each fresh
- New backend test `backend/tests/BigRegister.Tests/AuthzTests.cs``Authz.Can` server value.
unit tests (approve/reject/send × drafter/approver × SoD). - `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 1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
existing brief endpoints (replace `IsDrafter`/`X-Role` reads one at a time, (`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
keeping `BriefEndpointTests.cs` green after each). (79/79 including 10 new tests).
2. FE: `Capability` type, `access.store.ts`, `me.adapter.ts`, `capabilityGuard`. 2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
3. FE: `Session → Principal` in `auth/domain`; thread through `SessionStore`, 3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
`auth.guard.ts` (both keep working — `isAuthenticated` still means "has a 4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
Principal"). 5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
4. FE: `brief.store.ts` reads `canApprove`/`canReject`/`canSend` from the DTO; `me.adapter.ts` (+spec) as the general capability-spine infrastructure.
delete `currentRole()` import and the FE-computed `editable`. Update the brief 6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
UI components consuming `.editable`/`.role` to consume the new flags. 7. Full GREEN gate + a live curl smoke test against the running backend (submit as
5. Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP). flipping correctly at each step).
## Acceptance criteria ## 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. permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
- [ ] Forging `?role=approver` in the browser with a stale/absent server capability - [x] The SoD rule is enforced server-side regardless of FE state — verified by
still gets a 403 from the backend (verified by a test hitting the endpoint curl directly against the backend (drafter calling `/brief/approve` → 403)
directly, bypassing the FE). and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
- [ ] `Authz.Can` is the only place brief authorization logic lives; the emit path - [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
(DTO flags) and the enforce path (endpoint gating) both call it. lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
- [ ] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an both call it.
unknown capability. - [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
- [ ] The existing SoD rule (approver ≠ drafter) still holds, now expressed as an unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
`Authz.Can` precondition rather than inline in `BriefStore.Review`. - [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
- [ ] `capabilityGuard` compiles and is demonstrated on at least one route (or `Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
documented as available-but-unwired if no route needs it yet — brief has no - [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
route today, it's a page section). needs it yet — see Files).
## Verification ## Verification
GREEN (`docs/backlog/README.md`) + `cd backend && dotnet test`. Manual smoke: GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
`npm start` with `?role=drafter` — draft-only actions enabled; `?role=approver` build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
approve/reject enabled, editing disabled. Confirm via browser devtools that removing Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
the `X-Role` header (or backend patched to ignore it) makes every capability `false` `dotnet format --verify-no-changes`. Manual smoke via curl against a running
— i.e. deny-by-default actually denies. 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 ## Out of scope
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit 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 `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 ## Risks
Threading `Principal` through `SessionStore`/`auth.guard.ts` touches the one `Send` was already unauthenticated/unauthorized before this WP (no role check on
authenticated-session seam every route depends on — keep the observable shape `POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
(`isAuthenticated(): boolean`) identical so no route wiring needs to change, only (`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
what's inside `Session`/`Principal`. Backend `Authz.Can` replacing inline `X-Role` that would have broken the existing `Send_only_from_approved` test (which calls
reads must preserve the existing 403 `Outcome.Forbidden` mapping `send` as the default drafter identity and expects success). If a future WP decides
(`Program.cs:330-335`) exactly, or `BriefEndpointTests.cs` breaks. `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 { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; 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'; import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */ /** Route guard: only let authenticated users in; otherwise redirect to /login. */
@@ -8,3 +10,22 @@ export const authGuard: CanActivateFn = () => {
const router = inject(Router); const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']); 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 { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import { import {
Brief, Brief,
allDiagnostics, allDiagnostics,
@@ -11,13 +9,15 @@ import {
unresolvedPlaceholders, unresolvedPlaceholders,
} from '@brief/domain/brief'; } from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; 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 * 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 * read-model, and the commands (effects) that call the adapter and dispatch the
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`, * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `unresolved`, `canSubmit` are DERIVED here — never stored. * `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' }) @Injectable({ providedIn: 'root' })
export class BriefStore { export class BriefStore {
@@ -25,7 +25,6 @@ export class BriefStore {
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
readonly role: Role = currentRole();
readonly busy = signal(false); readonly busy = signal(false);
readonly lastError = signal<string | null>(null); readonly lastError = signal<string | null>(null);
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
@@ -36,13 +35,14 @@ export class BriefStore {
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'loaded' ? s.brief : null;
}); });
/** The single source of truth for edit permission: drafter, and a status the reducer readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
treats as editable (draft, or rejected — which reopens to draft on the first edit). */ readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
readonly editable = computed(() => { readonly canReject = computed(() => this.decisions()?.canReject ?? false);
const b = this.brief(); readonly canSend = computed(() => this.decisions()?.canSend ?? false);
return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter' private decisions = computed(() => {
); const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
}); });
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -54,12 +54,7 @@ export class BriefStore {
async load() { async load() {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
} }
@@ -71,7 +66,7 @@ export class BriefStore {
private saveTimer?: ReturnType<typeof setTimeout>; private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() { private scheduleSave() {
if (!this.editable()) return; if (!this.canEdit()) return;
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record. // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600); this.saveTimer = setTimeout(() => void this.flushSave(), 600);
@@ -97,12 +92,7 @@ export class BriefStore {
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.busy.set(false); this.busy.set(false);
this.saveState.set('idle'); this.saveState.set('idle');
if (r.ok) if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.lastError.set(r.error); 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 // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // 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.busy.set(true);
this.lastError.set(null); this.lastError.set(null);
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
@@ -127,14 +117,15 @@ export class BriefStore {
this.applyServerStatus(r.value); this.applyServerStatus(r.value);
} }
private applyServerStatus(brief: Brief) { private applyServerStatus(view: BriefView) {
const { brief, decisions } = view;
const s = brief.status; const s = brief.status;
switch (s.tag) { switch (s.tag) {
case 'submitted': 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; break;
case 'approved': 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; break;
case 'rejected': case 'rejected':
this.store.dispatch({ this.store.dispatch({
@@ -142,10 +133,11 @@ export class BriefStore {
by: s.rejectedBy, by: s.rejectedBy,
at: s.rejectedAt, at: s.rejectedAt,
comments: s.comments, comments: s.comments,
decisions,
}); });
break; break;
case 'sent': case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt }); this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
break; break;
case 'draft': case 'draft':
// reopened by a save on a rejected letter — reducer already handled it locally. // 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 { 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 { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, BriefMsg, reduce } from './brief.machine'; 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 = ( const loaded = (
status: BriefStatus = { tag: 'draft' }, status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'], sections?: Brief['sections'],
@@ -44,6 +53,7 @@ const loaded = (
tag: 'loaded', tag: 'loaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
decisions,
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
@@ -56,6 +66,7 @@ describe('brief.machine reduce', () => {
tag: 'BriefLoaded', tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }), brief: briefWith({ tag: 'draft' }),
availablePassages: [], availablePassages: [],
decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('loaded');
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ 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', () => { it('Submitted fires only from draft and only when required sections are filled', () => {
// required 'aanhef' empty → no-op // 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 // fill the required section, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); 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({ expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
submittedBy: 'u1', submittedBy: 'u1',
@@ -189,15 +200,21 @@ describe('brief.machine reduce', () => {
it('approve/reject fire only from submitted; send only from approved', () => { it('approve/reject fire only from submitted; send only from approved', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' }); const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
tag: 'approved', tag: 'approved',
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
}); });
// reject carries comments // 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({ expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
tag: 'rejected', tag: 'rejected',
rejectedBy: 'u2', rejectedBy: 'u2',
@@ -205,10 +222,27 @@ describe('brief.machine reduce', () => {
comments: 'nee', comments: 'nee',
}); });
// send only from approved // send only from approved
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3' }); const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); 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 { function initialLoading(): BriefState {

View File

@@ -1,6 +1,7 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { import {
Brief, Brief,
BriefDecisions,
BriefStatus, BriefStatus,
LetterBlock, LetterBlock,
LetterSection, 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 * it back to `draft`. Sections can never be added, removed, or reordered — there
* is no Msg for it, so it is unrepresentable. * is no Msg for it, so it is unrepresentable.
* *
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from * Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
* role+status and simply doesn't dispatch edits when the actor may not edit. The * canSend) arrives from the server on every load and every status transition (PRD-0002
* reducer guards the status invariant; the UI guards the role invariant. * 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 * 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 * 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 = export type BriefState =
| { tag: 'loading' } | { tag: 'loading' }
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] } | {
tag: 'loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string }; | { tag: 'failed'; reason: string };
export const initial: BriefState = { tag: 'loading' }; export const initial: BriefState = { tag: 'loading' };
export type BriefMsg = export type BriefMsg =
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] } | {
tag: 'BriefLoaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'BriefLoadFailed'; reason: string } | { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select | { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
| { tag: 'FreeTextBlockAdded'; sectionKey: string } | { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock } | { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string } | { tag: 'BlockRemoved'; blockId: string }
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number } | { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
| { tag: 'Submitted'; by: string; at: string } // draft → submitted | { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
| { tag: 'Approved'; by: string; at: string } // submitted → approved | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string } // approved → sent | { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState }; | { tag: 'Seed'; state: BriefState };
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ /** 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 { export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) { switch (m.tag) {
case 'BriefLoaded': 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': case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'failed', reason: m.reason };
case 'Seed': case 'Seed':
@@ -203,36 +221,41 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
s, s,
'draft', 'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
m.decisions,
canSubmit, canSubmit,
); );
case 'Approved': case 'Approved':
return transition(s, 'submitted', () => ({ return transition(
tag: 'approved', s,
approvedBy: m.by, 'submitted',
approvedAt: m.at, () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
})); m.decisions,
);
case 'Rejected': case 'Rejected':
return transition(s, 'submitted', () => ({ return transition(
tag: 'rejected', s,
rejectedBy: m.by, 'submitted',
rejectedAt: m.at, () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
comments: m.comments, m.decisions,
})); );
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at })); return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
default: default:
return assertNever(m); 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( function transition(
s: BriefState, s: BriefState,
from: BriefStatus['tag'], from: BriefStatus['tag'],
next: () => BriefStatus, next: () => BriefStatus,
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true, guard: (b: Brief) => boolean = () => true,
): BriefState { ): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; 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 { export function canSubmit(brief: Brief): boolean {
return brief.sections.every((s) => !s.required || s.blocks.length > 0); 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, version: 1,
}, },
], ],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
}; };
describe('brief.adapter parse boundary', () => { describe('brief.adapter parse boundary', () => {
@@ -67,6 +68,19 @@ describe('brief.adapter parse boundary', () => {
autoResolvable: true, autoResolvable: true,
fillable: false, 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', () => { 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 { runSubmit } from '@shared/application/submit';
import { import {
ApiClient, ApiClient,
BriefDecisionsDto,
BriefDto, BriefDto,
BriefStatusDto, BriefStatusDto,
BriefViewDto, BriefViewDto,
@@ -15,6 +16,7 @@ import {
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { import {
Brief, Brief,
BriefDecisions,
BriefStatus, BriefStatus,
LetterBlock, LetterBlock,
LetterSection, LetterSection,
@@ -35,6 +37,7 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
export interface BriefView { export interface BriefView {
readonly brief: Brief; readonly brief: Brief;
readonly availablePassages: LibraryPassage[]; readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions;
} }
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; 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; 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( const r = await runSubmit(
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), () => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
BRIEF_ACTION_FAILED, 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); 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); 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); 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); 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. */ /** 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> { export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!dto.brief) return err('brief-view: missing brief'); if (!dto.brief) return err('brief-view: missing brief');
const brief = parseBrief(dto.brief); const brief = parseBrief(dto.brief);
if (!brief.ok) return brief; if (!brief.ok) return brief;
const decisions = parseDecisions(dto.decisions);
if (!decisions.ok) return decisions;
const availablePassages: LibraryPassage[] = []; const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) { for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p); const parsed = parsePassage(p);
if (!parsed.ok) return parsed; if (!parsed.ok) return parsed;
availablePassages.push(parsed.value); 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) --- // --- 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()!" [brief]="brief()!"
[availablePassages]="availablePassages()" [availablePassages]="availablePassages()"
[diagnostics]="store.diagnostics()" [diagnostics]="store.diagnostics()"
[editable]="store.editable()" [canEdit]="store.canEdit()"
[role]="store.role" [canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[canSubmit]="store.canSubmit()" [canSubmit]="store.canSubmit()"
[busy]="store.busy()" [busy]="store.busy()"
(edit)="store.edit($event)" (edit)="store.edit($event)"

View File

@@ -1,5 +1,4 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { Role } from '@shared/domain/role';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.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 /** Organism: the whole letter — status badge, the editable sections (drafter) or a
read-only preview (approver / locked), the diagnostics panel, and the action bar 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({ @Component({
selector: 'app-letter-composer', selector: 'app-letter-composer',
imports: [ imports: [
@@ -67,7 +68,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<app-rejection-comments mode="show" [comments]="rejectComments()" /> <app-rejection-comments mode="show" [comments]="rejectComments()" />
} }
@if (editable()) { @if (canEdit()) {
<div class="sections"> <div class="sections">
@for (section of brief().sections; track section.sectionKey) { @for (section of brief().sections; track section.sectionKey) {
<app-letter-section <app-letter-section
@@ -90,7 +91,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<div class="bar"> <div class="bar">
@switch (status()) { @switch (status()) {
@case ('draft') { @case ('draft') {
@if (editable()) { @if (canEdit()) {
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!canSubmit() || busy()" [disabled]="!canSubmit() || busy()"
@@ -103,7 +104,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
} }
} }
@case ('rejected') { @case ('rejected') {
@if (editable()) { @if (canEdit()) {
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!canSubmit() || busy()" [disabled]="!canSubmit() || busy()"
@@ -113,7 +114,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
} }
} }
@case ('submitted') { @case ('submitted') {
@if (role() === 'approver') { @if (canApprove() || canReject()) {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ <app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
approveLabel() approveLabel()
}}</app-button> }}</app-button>
@@ -123,10 +124,12 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
} }
} }
@case ('approved') { @case ('approved') {
@if (canSend()) {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ <app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel() sendLabel()
}}</app-button> }}</app-button>
} }
}
@case ('sent') { @case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert> <app-alert type="ok">{{ sentText() }}</app-alert>
} }
@@ -138,8 +141,10 @@ export class LetterComposerComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
availablePassages = input<readonly LibraryPassage[]>([]); availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
editable = input(false); canEdit = input(false);
role = input.required<Role>(); canApprove = input(false);
canReject = input(false);
canSend = input(false);
canSubmit = input(false); canSubmit = input(false);
busy = input(false); busy = input(false);

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { LetterComposerComponent } from './letter-composer.component'; 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'; import { allDiagnostics } from '@brief/domain/brief';
const passages: LibraryPassage[] = [ 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: { props: {
brief: b, brief: b,
availablePassages: passages, availablePassages: passages,
diagnostics: allDiagnostics(b), diagnostics: allDiagnostics(b),
editable, ...decisions,
role,
canSubmit: true, canSubmit: true,
busy: false, busy: false,
}, },
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics" 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> = { const meta: Meta<LetterComposerComponent> = {
@@ -125,15 +125,22 @@ export default meta;
type Story = StoryObj<LetterComposerComponent>; type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = { 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 = { export const SubmittedApprover: Story = {
render: () => render: () =>
render( render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), canEdit: false,
false, canApprove: true,
'approver', canReject: true,
), canSend: false,
}),
}; };
export const Rejected: Story = { export const Rejected: Story = {
render: () => render: () =>
@@ -144,10 +151,15 @@ export const Rejected: Story = {
rejectedAt: '2026-07-01', rejectedAt: '2026-07-01',
comments: 'Graag de aanhef formeler.', comments: 'Graag de aanhef formeler.',
}), }),
true, { canEdit: true, canApprove: false, canReject: false, canSend: false },
'drafter',
), ),
}; };
export const Sent: Story = { 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 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 * @return OK
*/ */
@@ -969,7 +1005,7 @@ export class ApiClient {
/** /**
* @return OK * @return OK
*/ */
briefPUT(body: SaveBriefRequest): Promise<BriefDto> { briefPUT(body: SaveBriefRequest): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief"; let url_ = this.baseUrl + "/api/v1/brief";
url_ = url_.replace(/[?&]$/, ""); 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; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -1015,13 +1051,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); 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 * @return OK
*/ */
briefSubmit(): Promise<BriefDto> { briefSubmit(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/submit"; let url_ = this.baseUrl + "/api/v1/brief/submit";
url_ = url_.replace(/[?&]$/, ""); 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; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -1063,13 +1099,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); 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 * @return OK
*/ */
approve(): Promise<BriefDto> { approve(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/approve"; let url_ = this.baseUrl + "/api/v1/brief/approve";
url_ = url_.replace(/[?&]$/, ""); 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; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -1111,13 +1147,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); 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 * @return OK
*/ */
reject(body: RejectBriefRequest): Promise<BriefDto> { reject(body: RejectBriefRequest): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/reject"; let url_ = this.baseUrl + "/api/v1/brief/reject";
url_ = url_.replace(/[?&]$/, ""); 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; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -1163,13 +1199,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); 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 * @return OK
*/ */
send(): Promise<BriefDto> { send(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief/send"; let url_ = this.baseUrl + "/api/v1/brief/send";
url_ = url_.replace(/[?&]$/, ""); 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; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 409) { } else if (status === 409) {
@@ -1205,7 +1241,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); 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; submittedAt?: string | undefined;
} }
export interface BriefDecisionsDto {
canEdit?: boolean;
canApprove?: boolean;
canReject?: boolean;
canSend?: boolean;
}
export interface BriefDto { export interface BriefDto {
briefId?: string | undefined; briefId?: string | undefined;
beroep?: string | undefined; beroep?: string | undefined;
@@ -1312,6 +1355,7 @@ export interface BriefStatusDto {
export interface BriefViewDto { export interface BriefViewDto {
brief?: BriefDto; brief?: BriefDto;
availablePassages?: LibraryPassageDto[] | undefined; availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto;
} }
export interface BrpAddressDto { export interface BrpAddressDto {
@@ -1423,6 +1467,10 @@ export interface ManualDiplomaPolicyDto {
policyQuestions?: PolicyQuestionDto[] | undefined; policyQuestions?: PolicyQuestionDto[] | undefined;
} }
export interface MeDto {
capabilities?: string[] | undefined;
}
export interface ParagraphDto { export interface ParagraphDto {
nodes?: RichTextNodeDto[] | undefined; nodes?: RichTextNodeDto[] | undefined;
list?: string | 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 * 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 — * 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 * 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; * `X-Role` header (see role.interceptor), resolves it into a `Principal`
* the FE derives `editable` from it. * 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 { export function currentRole(): Role {
return new URLSearchParams(window.location.search).get('role') === 'approver' return new URLSearchParams(window.location.search).get('role') === 'approver'