A rule of thumb for authorisation as domain logic
The heuristic we use for authorisation as domain logic, and the point where it stops being true.
Somewhere between a principle and a habit sits the useful kind of rule: right most of the time, and wrong in ways you'll notice straight away. This is one of those.
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.
Checked against marketplace operations: at close to 80,000 listings a month it holds comfortably. It'd start to wobble an order of magnitude higher, where the fixed costs it ignores stop being small.
// 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.
The rule would have caught a suspended seller kept transacting through a second account and didn't, because nobody applied it to the integration written under deadline. That's the usual story. The heuristic is fine, the coverage isn't.
WHERE IT GOES WRONG
- 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 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.
Authorisation is a domain rule. Keep it next to the domain, in the domain's words.
WHEN THE RULE FAILS
Treat it as a prompt to think, not a substitute for thinking. Its job is to stop the same conversation happening a fourth time, not to end it.