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
CompanyRolein the case's company (HrAdmin,CaseManager,InstructorInternal/External,LegalReviewer,Decider), plus the account-levelsuper_adminflag. Resolved byCallerAuthority(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:
| Side | Uses | Behaviour |
|---|---|---|
| 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 target | each 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.)
Related
- Case workflow — the state machine — the axis this sits beside
- Spec:
MyLegalTeam.Application/Features/Cases/BEHAVIOUR.CaseAuthorization.md(modulecaseauth) - Code:
CaseCommandPolicy,CaseCallerRole,CallerAuthorityinMyLegalTeam.Application/Features/Cases