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