Skip to main content

Case authorization — the per-command permission system

Who may do what to a case, and — the part that actually bit us — how the write side (enforcement) and the read side (what the UI offers) are kept from drifting apart. This is the third axis of the workflow state machine: the machine decides whether a transition is possible (data + legal guards); this decides whether this caller may fire it.

Two kinds of role

A caller's standing on a case is the union of two things:

  • Company standing — their CompanyRole in the case's company (HrAdmin, CaseManager, InstructorInternal/External, LegalReviewer, Decider), plus the account-level super_admin flag. Resolved by CallerAuthority (IsSuperAdmin, RoleInCompany).
  • Case-team role — whether they are this case's assigned instructor or secretary.

CaseCallerRole.RolesOf(@case, callerId, authority) returns the whole set of CaseRoles the caller holds — team role(s) company standing. Evaluating the set (not a single "most-specific" role) is deliberate: a CaseManager who is also assigned as a case's instructor must keep their management power, which a most-specific resolution would silently strip. An empty set means the caller may not even view the case (the reads 404 rather than leak its existence).

The policy: one command → the roles that may fire it

CaseCommandPolicy is a single table mapping each workflow command to the set of case-roles permitted to fire it:

CanFire(roles, command) => roles.Overlaps(Who(command)) // may this caller fire THIS command?
CanFireAny(roles) => roles may fire at least one command // drives the "advance" permission

It sits beside the state machine, not inside it — the machine stays a pure decider and roles never enter CaseData (compile-enforced: there is no identity field on the decider's data to branch on). This is the "third axis" alongside the machine's .When (data precondition) and .Guard (legal rule).

Seed: coarse and fail-closed ("B-lite")

Every command currently maps to the management set (super_admin / hr_admin / case_manager), so enforcement is identical to the old blanket "can manage cases" gate — nothing loosened. The real per-command legal rules are a product decision and are deliberately not invented in code. Refining them is a one-line edit per command in the policy table; an invariant test requires every command to have an entry, so a new command can't be silently unauthorizable.

Open product questions (seeded coarse, flagged not guessed): may an assigned Instructor fire OpenPreliminaryInquiry and/or IssueCharges? May a Secretary Suspend? Who may Resume? Should HrAdmin and CaseManager differ? Each is a table edit; the tests lock the enforcement/display parity, not the specific legal choices.

One source, so enforcement and display can't drift

This is the whole point. Both sides derive from CaseCommandPolicy:

SideUsesBehaviour
Enforcement (advance endpoint)CanFire(roles, command)unauthorized → 403 NotAuthorizedToTransition, with the case loaded (to resolve the case-role) but never advanced, updated, or audited
Display (case detail read)CanFireAny(roles)the advance permission appears iff the caller may fire some command
Display (available-transitions read)CanFire(roles, command) per targeteach offered target carries a canFire flag; the FE greys out one that is allowed (by the machine) but not canFire (by the policy)

The bug this design closes: before it, the detail read told an Instructor they could "advance" while the write path's coarse gate refused them — a button that 403s. Now the two read off the same table, so they agree by construction.

Authorization is not tenant scoping

A subtlety worth stating: SetCompany(companyId) arms the fail-closed tenant query filter (a case reached via the wrong company's route is simply not found). That is data partitioning, not a caller check. Every case-mutating and case-reading handler must also run the authorization above — the tenant filter answers "does this case belong to this route's company?", the policy answers "may this caller act on it?". (The transitions and available-transitions endpoints originally had only the former; adding the latter was the #152 review follow-up.)

  • Case workflow — the state machine — the axis this sits beside
  • Spec: MyLegalTeam.Application/Features/Cases/BEHAVIOUR.CaseAuthorization.md (module caseauth)
  • Code: CaseCommandPolicy, CaseCallerRole, CallerAuthority in MyLegalTeam.Application/Features/Cases