Bolting permissions on at the edge isn't the shortcut it looks like
Why bolting permissions on at the edge keeps looking like the sensible option, and what it costs by year two.
It's a well-meant mistake. That's exactly why it sticks around. Every team that makes it can explain why, and the explanation holds up right until the system has to survive its second year.
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.
This is where the reconciliation spreadsheet comes from. Somebody built it once to settle an argument. Now north of 600 deliverables a month depends on a file with one author, no tests, and a filename ending in _v4_final.
// 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 bill shows up in the handover notes: nine paragraphs on which of two systems to trust for a given delivery package. When a master was published with the wrong audio mix attached, it took two days to work out what had actually happened, in what order.
WHERE IT GOES WRONG
- 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.
- A permissions model that can't express any rule involving what happened earlier.
Authorisation is a domain rule. Keep it next to the domain, in the domain's words.
THE REPLACEMENT
The fix isn't more discipline. Discipline wears off. The fix is a structure where the wrong thing can't be said, so nobody has to remember the rule at 3am.