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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-03 20:31:53 +02:00
co-authored by Claude Sonnet 5
parent cbb8ae548c
commit 7ec13d8b59
26 changed files with 4520 additions and 3185 deletions
@@ -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);
+14 -8
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);
} }
} }
@@ -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");
}
+39 -34
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),
}; };
+58 -5
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": {
@@ -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));
}
}
@@ -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]
+1 -1
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 |
+140 -115
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.
+3690 -2889
View File
File diff suppressed because one or more lines are too long
+21
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']);
};
}
+23 -31
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.
+42 -8
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 {
+47 -24
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 };
} }
+9
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;
}
@@ -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', () => {
+33 -11
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) ---
+4 -2
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)"
@@ -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,9 +124,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
} }
} }
@case ('approved') { @case ('approved') {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ @if (canSend()) {
sendLabel() <app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
}}</app-button> sendLabel()
}}</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);
@@ -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,
}),
}; };
@@ -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);
}
}
+5
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';
+68 -20
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;
@@ -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);
});
});
@@ -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)));
}
+4 -2
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'