Admin-only overview of all cases across owners + an admin delete, gated by a new
`cases:manage` capability (Authz role→cap + CanManageCases + CasesAdmin gate;
FE capability + guard + nav + role.interceptor prefix — the org-template/stamdata
recipe). Backend adds ApplicationStore.ListAll()/DeleteAny() and GET /admin/cases +
DELETE /admin/cases/{id}; admin delete removes ANY case incl. submitted. Page lives
in registratie/ui (owns the Aanvraag aggregate; reuses aanvraag-view + parse),
routed /beheer/zaken; delete guarded by a native confirm, optimistic with rollback.
Typed client regenerated (documents the new endpoints + owner field).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
5.1 KiB
C#
96 lines
5.1 KiB
C#
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
|
|
namespace BigRegister.Domain.Authorization;
|
|
|
|
public enum PrincipalRole { Drafter, Approver, Admin }
|
|
|
|
/// <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() 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<string> 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<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. 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));
|
|
}
|