using BigRegister.Api.Contracts; using BigRegister.Api.Data; namespace BigRegister.Domain.Authorization; public enum PrincipalRole { Drafter, Approver, Admin } /// /// 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. /// public sealed record Principal(PrincipalRole Role); public enum BriefAction { Approve, Reject, Send } /// /// 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). /// public static class Authz { public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch { "approver" => PrincipalRole.Approver, "admin" => PrincipalRole.Admin, _ => PrincipalRole.Drafter, }); public static string ActingId(Principal principal) => principal.Role switch { PrincipalRole.Approver => BriefStore.ApproverId, PrincipalRole.Admin => BriefStore.AdminId, _ => 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 RoleCapabilities(Principal principal) => principal.Role switch { PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" }, PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, _ => Array.Empty(), }; /// 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. The explicit Approver condition keeps the new Admin /// role out of the review flow (WP-23) — SoD alone would have let it through. public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch { BriefAction.Approve or BriefAction.Reject => principal.Role == PrincipalRole.Approver && ActingId(principal) != drafterId, BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today _ => false, }; /// Org-template management (WP-23): admin-only, resource-independent — templates /// have no per-resource state to weigh, so role IS the whole decision here. public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin; /// Stamdata maintenance (ADR-0004): admin-only, resource-independent — same shape as /// org-template management (role IS the decision). Gates the read-only /stamdata endpoints /// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here. public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin; /// Case management (WP-36): admin-only, resource-independent — same shape as /// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases /// list + admin delete. public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin; /// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships /// masked by default; only the behandelaar (Drafter) composing the case — the actor /// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a /// real system resolves it from the app overlay independent of role. The reveal itself /// is additionally step-up-gated + audited at the endpoint. (Illustrated on the /// BIG-nummer because no BSN travels the wire — see PRD note.) public static bool CanRevealBigNummer(Principal principal) => principal.Role == PrincipalRole.Drafter; /// 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", // PII reveal is status-independent (§5c) — unlike the action gates above. CanRevealBigNummer: CanRevealBigNummer(principal)); }