Engineering Journal · ENGINEERING · September 9, 2025 · 16 min read
The Permission Was Correct. The Business Rule Was Not.
Most serious holes I have had to think about were not missing middleware. They were accurate roles attached to the wrong idea of who may do what in a workflow.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
I used to think about security as a layer you add: auth middleware, a policy, a token scope, maybe a rate limit. Those things matter. They are also how you can have a system that is "secure" in the checklist sense and still let the wrong person change a life, because the role was allowed to update the table.
The failures that stay with me are not cinematic. They are a staff user who can see another campus because the query was almost right. An admin who can reverse a financial state because we modelled "admin" as "yes." A token that meant "logged in" and was treated as "may download the letter."
This is not an OWASP list. It is about authorisation as a description of work, and about the damage that happens when we describe tables instead.
Context
Operational products have many kinds of people: students, staff, finance, an operator of the platform, sometimes an agent. Multi-tenant products add a sharper line: this data is not yours. APIs add a third: the caller is a programme.
Laravel gives you gates and policies. They are only as good as the question you ask them. can('update', $invoice) is a different question from can('void after settlement', $invoice). The first is convenient. The second is the business.
I also include the machine actors. A job, a webhook, a personal token, a "system" user used for imports — they all perform work. If your mental model of security is only people at browsers, you will write a careful policy and then bypass it in a console command because the command felt internal. Internal is still a door.
The problem
The apparent problem is unauthenticated access. Lock the routes.
The real problem is authenticated access that is too coarse. Once you are "staff," a surprising amount of the building unlocks. Once you are "admin," the building has no interior walls. Attackers like that. So do tired colleagues, which is more common.
A second real problem is object-level confusion. You checked that the user is staff. You did not check that the student belongs to their tenant. The permission was correct. The world was wider than the permission.
A third is time and aftermath. Who may do the thing is not the same as who may undo it, who may see that it was done, and who must be told. Security that stops at the click will miss the export, the PDF, the job that retries the send, and the admin who "just looks." Those are the same work wearing different doors.
Coarse roles also hide the real question during review. "Is this person staff" is easy to tick. "May they void this after settlement, on this campus, after the letter went" is the work. If the code cannot ask the second question, the review cannot either. You will approve a correct permission on the wrong rule and call it done.
The tempting solution
Roles: admin, staff, user. Middleware on the prefix. Policies that repeat if ($user->isAdmin()) return true.
Hide the button in the UI and assume the API is only used by the UI. I have already argued against that. It is a security failure wearing a design failure.
Why that is not enough
Admin as a synonym for trusted is how privilege escalates without a villain. Every exception becomes a reason to widen the role instead of to add a capability.
Secrets in env files are necessary and insufficient. The leak I worry about more, day to day, is an export that was allowed because exports are a staff feature, and the export included a column nobody thought was sensitive.
Audit trails that only log login and not "who changed the thing that matters" will not help you explain a record. Application logs tell you that a 200 happened. Business audit tells you that a status moved.
"Everyone is a bit of an admin" is the cultural version of the same hole. It starts as kindness. It ends as a model where the only role that can finish the morning is the widest one. At that point your ACL screen is fiction. The real ACL is whoever was shown the override last Friday.
Options
Coarse roles forever. Advantage: simple ACL screens. Disadvantage: you will keep adding people to admin. The set admin is not a team. It is a blast radius.
Fine-grained capabilities from day one. Advantage: precise. Disadvantage: you will build an IAM product. A small team will then bypass it.
Capabilities on the few workflows that can harm — money, personal data, cross-tenant, irreversible letters — and coarse roles elsewhere, plus object checks that always include tenant and owner. Advantage: effort where failure is expensive. Disadvantage: two styles. I can live with two styles if the expensive one is sacred.
A single superuser account shared in a password manager. I mention it only to refuse it. Shared gods do not audit.
Permissions designed from the org chart. Advantage: familiar nouns. Disadvantage: the chart is not the work. The person who covers Friday is not on the chart as "approver." If you implement the chart, the Friday person will borrow a login. Borrowed logins are the oldest privilege escalation in operational software.
Trade-offs
You trade some speed of "just let me fix it" for a recorded capability. Staff will feel blocked. That feeling is information. Sometimes the block is wrong and the capability should exist. Sometimes the feeling is the point.
You trade a little performance for object-level checks. I will pay that. A cached "is staff" bit that skips the tenant predicate is how you get a fast leak.
You should not trade away an audit on irreversible actions to keep the table pretty. A pretty table that cannot explain itself is a liability with nice margins.
Decision
I want the permission question to be in the language of the workflow. May this actor perform this transition on this record in this tenant. If we cannot say that sentence, I do not want a policy that says update.
Admin bypass, when it exists, is a capability with a reason, not a blank cheque at the top of every method.
I would rather have an awkward extra click that writes "overrode because the approver is away" than a silent god mode. The click is annoying. The silence is how you lose the only story you had.
Implementation / Thinking process
Every query that returns a person or a payment has a tenant or an owner predicate that does not depend on the developer remembering. Scopes help. Tests with two tenants help more.
Tokens carry the same questions as people. A personal access token that can read everything the user can read is a portable user. Scope it. Expire it. Know how to revoke it.
Exports are features. They get the same authorisation as the screen, and they get a column list that is reviewed. "Select *" is a privilege.
For the dangerous transitions, write an audit row in the same transaction as the change. If the change commits and the audit fails, I want the change to fail. An audit that is best-effort will be missing on the day you need it.
Think about time. A permission that was true in the morning may be false after a role change, a campus move, or a revoked token. Caches of "is staff" that outlive those events are not a performance win. They are a delayed breach. If you cache authorisation, the invalidation sentence has to be as serious as the one I want for any other cache.
Think about files. A letter PDF sitting on a disk with a guessable name is an API whether you called it one or not. Storage paths get the same object checks as the route that created them.
I also write the negative test first on the expensive paths: a second tenant, a narrower role, a token without the scope, a record in a terminal state. If the suite only tests that the happy actor can proceed, you have tested the brochure. The brochure is not where the hole lives.
Failure modes
The IDOR that looks like a guessable integer in a URL. Policies that check role and not object.
The job that runs as the system user and forgets to impersonate a tenant. System is a role. It is not an excuse.
The CORS or cookie change that was "just for local" and learned to love production.
The debug route that dumps the user. I do not need to invent an incident. The design is the incident waiting.
The policy that checks the role and then loads the record without a tenant predicate, because "we already authorised." Authorisation without an object is a mood. The object is the rest of the sentence.
Operational consequences
When permissions match work, onboarding is easier. You can give someone "letters" without giving them "void invoice." When they do not, every new hire is an admin by week two because that is the only way to do their job. That is not a people problem. That is a model problem.
Security review becomes possible. You can ask "who can void" and get a list. If the answer is "admins, and also anyone who found the old route," you do not have a review. You have archaeology.
You will also change how exceptions are requested. If the only path is "make me admin," every exception widens the blast radius. If the path is "grant this capability on this class of record, with a reason," the exception stays a sentence. Sentences can be reviewed. God mode cannot.
Lessons
Authentication answers who you are. Authorisation answers what work you may do. The second is a business document implemented in code. If the document is "admins can update," you have given up on the interior of the product.
Most serious mistakes look like correct permissions on the wrong rule. The badge was fine. The work was wider than the badge, and the record cannot tell the difference until someone asks you to explain a Tuesday.
What I would do differently today
I would have forbidden isAdmin() as an early return in domain policies. It is a trap that feels like pragmatism.
I would have put tenant assertions in the few queries that hurt, with tests, before I wrote a long security guideline. Guidelines do not fail CI.
I would have treated exports and file URLs as routes with the same object checks as the HTML. A CSV is not a report. It is a bag of other people's lives. If the screen was careful and the export was "select *", the carefulness was theatre.
Closing thought
You can lock the door and still leave the rooms connected. Check the work, not only the badge. The rooms are where the week actually happens. The permission can be correct and the business rule can still be wrong, and the record will not know the difference until someone asks you to explain it.