Permissions are the part of a business application that everyone assumes will be simple. There is an admin, there are users, and admins can do more. Two weeks before launch someone asks whether a team leader should be able to see another department's invoices, and the entire model falls apart.
We have built role and permission systems into CRMs, workforce platforms, booking back-offices, and multi-tenant SaaS products, and the pattern is consistent: access control is easy to add badly at any point and hard to add properly late. This article is about getting it right early, without over-engineering it into something nobody can reason about.
The most common structural mistake is checking the role in the code.
A conditional that says "if the user is a manager, show the delete button" reads clearly and works fine, right up until the business creates a senior manager role, or decides regional managers should not delete, or adds a new role that needs one specific manager capability. Now every one of those checks has to be found and edited, and inevitably one is missed. The one that is missed is a security bug.
The alternative is to check capabilities, not identities. Roles are bundles of permissions; code asks whether the user can perform an action, never who they are:
Adding a role becomes configuration. Adding a permission to an existing role becomes a data change. Neither requires touching the code that enforces it. In a Laravel codebase this maps naturally onto policies and gates; the framework detail matters less than the discipline of never branching on a role name in business logic.
Knowing that a user can delete invoices is only half the question. The other half is which invoices.
This is the distinction between what a user can do and what they can do it to, and it is where most permission systems become tangled. A team leader can view tasks — but only for their own team. A project manager can edit projects — but only ones they are assigned to. A client can see invoices — but only their own.
Handle these as two separate layers and both stay comprehensible:
Scoping approaches, in increasing order of complexity:
Pick the simplest one that expresses the real business rule. Teams that reach for a full hierarchy on day one usually discover that ownership and assignment would have covered every actual requirement.
A permission that is checked in the user interface only is not a permission, it is a suggestion. Hiding a button prevents an honest mistake; it does not prevent anyone who can open developer tools or call the API directly.
The rule we apply without exception: authorisation is enforced on the server, at the point where the action happens, and the user interface merely reflects those decisions.
That means:
The most common real-world leak we find in audits is not an unprotected endpoint. It is a well-protected endpoint that returns related records nobody thought to filter, or an autocomplete search that quietly returns every user in the system because it was written as a convenience.
If your application serves multiple organisations from one database, tenant isolation is a different class of concern from role permissions. A user seeing something above their pay grade inside their own company is an internal problem. A user seeing another company's data is a breach, a notification obligation, and possibly the end of the product.
The controls that make this safe:
We treated this as a first-class concern when building BookFlow Pro as a multi-tenant platform, and it is the one area where we deliberately accept extra defensive machinery.
The technical model is only as good as the roles the business actually needs. A useful process:
Resist the temptation to build a fully generic permission designer where customers compose arbitrary roles from atomic permissions. It sounds flexible and in practice produces misconfigured roles, support tickets, and a permission matrix nobody can audit. Well-named preset roles with a small number of toggles serve real organisations better.
Real businesses have access rules that a static role model cannot express on its own.
That last point generalises. Any capability that can view data beyond the normal scope should leave a trail that someone can review.
Most audit logs are written once and never read, because they record the wrong thing. A log entry saying that a record was updated at 14:32 answers no question anyone asks.
Log what will be needed in a dispute:
Keep the log append-only and outside the reach of the application's normal delete paths. An audit log that a sufficiently privileged user can edit is not evidence.
Support teams need to see what a user sees. Impersonation is the right feature and a dangerous one if built casually.
The controls that make it safe: restrict it to a specific permission rather than to administrators generally, record both the real and the effective user on every action taken during the session, display a persistent and unmistakable banner so nobody forgets which context they are in, time-limit sessions, and block genuinely destructive actions while impersonating. Being able to see a customer's dashboard is support; being able to delete their account while wearing their identity is a liability.
Permissions are the area where tests pay for themselves fastest, because the failure mode is silent.
The tests worth writing:
When a new role is added, this suite is what tells you whether it accidentally inherited something it should not have.
Most of the permission work we do is not greenfield. It is untangling an application where role checks accumulated in the code over years and nobody can now say with confidence who can do what.
The sequence that works:
The temptation is to redesign the roles and the mechanism at the same time. Doing both at once means that when something breaks, you cannot tell whether it is the new plumbing or an intended change in policy, and in access control that ambiguity is expensive.
Check capabilities, never role names. Separate what a user can do from which records they can do it to. Enforce on the server, scope at the query level, and treat the interface as a convenience layer. Make tenant isolation global and loud. Keep the role set small and named after real jobs. Model delegation and temporary access explicitly rather than by editing roles. Log the things you would need in a dispute, including permission changes themselves. Test every role against every endpoint.
We build business software where this model is the backbone rather than an afterthought, from CRMs to workforce platforms like Binary Time Station. If you are designing a permission model, or you have inherited one that has become unmanageable, our contact page is a good place to start.