Engineering
Why Every Permission Check in Our System Lives in One File
By Clasify Team · · 5 min read
Most internal software gets its permissions the same way: a route needs to keep teachers out of the admin report, so somebody writes a check for the role right there in the handler. It works. Then it happens again in the next handler, and the one after that, and within a year the real answer to "who can see this?" is spread across eighty files and nobody can state it.
The failure that follows is not dramatic. It is one handler that never got the check — usually a newer one, often written in a hurry — and the bug is invisible because the code that is missing looks like nothing at all.
A missing call is visible; a missing comparison is not
That observation is what changed how we do this. If a route decides permissions by comparing a role inline, then forgetting to decide produces a handler with no check in it, and there is nothing on the screen to notice. But if every route must call a named guard, forgetting produces a handler that is missing a *call* — and a missing call is something a reviewer, a linter, or a test can actually see.
So the rule in our codebase is deliberately absolute:
If authorization logic is not in the authorization module, it does not exist.
Routes never compare a role directly. Every decision goes through one module with a small, named public surface: authenticate the caller, require a role, require a permission, assert ownership of a record, assert a teacher owns the thing they are touching, assert admin, assert super-admin, assert the student is enrolled. Everything else in that file — the permission matrix, the role normaliser, the request adapter, the list-scoping helpers — is infrastructure those guards are built on, not a second way in.
The value is not that the checks are better in isolation. Any of them could be written inline. The value is that there is exactly one place to read, one place to change, and one place to audit.
Roles are messier than the diagram
Every real system accumulates a gap between the role names in the spec and the role strings in the database. Ours stores `COMPANY_ADMIN` where the original design said `ADMIN`.
The tempting fix is a migration to rename it. The cheaper and safer one is a normaliser: a single function that collapses stored and aliased strings into a canonical tier, returning null for anything it does not recognise. Unknown role means no role, which fails closed.
That function is worth more than it looks. It is the only place in the system that has an opinion about what a role string means, so a future rename is one line and not a data migration. And because it returns null rather than throwing or guessing, a corrupted or unexpected value denies access instead of accidentally matching a tier.
The tiers themselves stay deliberately few: a cross-tenant super-admin, an admin scoped to its own tenant, a teacher limited to its own classes and students, and a student limited to itself and what it is enrolled in. Most requests for a new role are really requests for a new permission, and the matrix absorbs those without adding a tier that then has to be reasoned about everywhere.
One detail worth copying: cross-tenant access for the super-admin is not implicit in the tier. It requires an explicit flag at the call site. Being the most powerful role is not the same as silently reading everyone's data by default, and making it explicit means every cross-tenant read is greppable.
Log the denials, not just the grants
The part teams skip is the audit trail, usually because the obvious version — log every successful action — produces a firehose nobody reads.
Invert it. We write an authorization audit record when a decision *denies*, and it carries the caller, their role at decision time, their tenant, the action evaluated, the resource they reached for, the endpoint, the IP, and a human-readable reason.
That table earns its keep in three different ways, and only one of them is security:
- A support question — "why can't this teacher open the report?" — is answered by a row that literally contains the reason, instead of by reproducing the user's session.
- A bad deployment shows up as a spike in denials for an action that used to succeed, which is a much earlier signal than a support ticket.
- An actual probing attempt looks like one caller generating denials across many resources, which is a shape you can alert on.
Recording the reason as text, not a code, is what makes the first two work. The reason is written once, where the decision is made and the context is known, and it is still legible months later to someone who has never read that function.
What this buys, and what it costs
The cost is real: one indirection between a route and its decision, and a rule the team has to keep. New engineers ask why they cannot just check the role. The answer is that the rule is not protecting the check — it is protecting the ability to find every check later.
What it buys is the ability to answer questions that are otherwise unanswerable in an internal tool: who can reach this record, what changed when we shipped that, and why was this specific person refused at this specific moment.
If you are building the system that replaces the spreadsheet, that is the part to get right early. Permissions are cheap to centralise on day one and expensive to retrofit once eighty handlers each have an opinion.
We build systems like this for other teams as custom software development; how we built Clasify covers the surrounding architecture.
All articles