Authorisation as domain logic, explained before you need it
Authorisation as domain logic from the start, for a room that contains both engineers and the people who'll live with it.
Most write-ups of this start in the middle. We'll start earlier than strictly necessary, because nine times in ten the confusion comes from a word two people are using differently, not from the mechanism.
Who may do what is a domain rule, not a middleware concern. When authorisation lives at the edge it drifts from the business rule it was meant to express, and the drift is invisible until someone does something they shouldn't have been able to do.
ROLES ARE NOT THE RULE
Real authorisation rules are almost never about roles. They're about relationships: you can approve this because you're the assigned reviewer and you didn't raise it, you can see this because your team owns the case, you can't do this because the cutoff has passed. Roles are a rough approximation people reach for because the framework offers them.
The approximation leaks immediately. The first exception, a supervisor covering leave or a case reassigned mid-flight, gets handled by granting a broader role, and now someone has permissions that have nothing to do with the actual rule. Do that four times and nobody can tell you who can see what.
PUT IT WHERE THE RULE LIVES
If the rule is about the relationship between a person and a record, it belongs next to the record, expressed in the domain's own vocabulary and tested like any other rule. The HTTP layer then asks one question and gets an answer it doesn't have to interpret.
The payoff is that the rule becomes readable by the people who own it. We've had compliance staff read an authorisation module and correct it, which never happens when the same logic is spread across route decorators.
Made concrete: a referral packet passes through several pairs of hands, and each of them believes it owns the record. What's above is how you let all of them be right without letting any of them silently overwrite the others.
// Not a role check. A question about this person and this record.
export function canApprove(user: User, file: CaseFile): Decision {
if (file.state !== "submitted") return deny("not awaiting approval")
if (file.assignedReviewer !== user.id) return deny("not the assigned reviewer")
// Separation of duties: needs the record's history, not the user's role.
if (file.wasSubmittedBy(user.id)) return deny("cannot approve own submission")
if (file.pastCutoff(clock.now())) return deny("past cutoff")
return allow()
}SEPARATION OF DUTIES
The rule that catches everyone out is the one that depends on history: you can't approve something you submitted. It's not expressible in a role at all, because it needs the record's past. Systems that model authorisation as a static grant simply cannot say it, so it gets enforced by convention and then not enforced at all.
Once you're storing who did what anyway, this becomes a query rather than a special case. That's usually the argument that wins the design discussion, because it turns a compliance requirement into a line of code someone can point at.
Picture a multi-site outpatient network at about 9,000 referrals a month. The naive version works perfectly in testing and falls over the first day two care coordinators touch the same referral packet in the same second.
WHERE IT GOES WRONG
- Authorisation spread across route decorators, so no single place answers who can see what.
- Separation-of-duties enforced by convention, which means enforced on the days people remember.
- A permissions model that can't express any rule involving what happened earlier.
- Widening a role to cover one holiday, and never narrowing it again.
Authorisation is a domain rule. Keep it next to the domain, in the domain's words.
THE SHORT VERSION
None of this is advanced. It's ordinary practice that gets skipped when a date is close, and then paid for with interest by whoever's on call.