How I structure role-based access in business applications
Hiding a button is not security. A permission model that survives contact with a real business needs one catalogue, enforcement in the service layer, and a short list of actions that only two people may ever perform.
20 August 2026/3 min read
Most business applications get permissions wrong in the same order. First everyone is an admin. Then someone does something expensive by accident, so a role column appears. Then the interface starts hiding buttons, and for a while it looks like the problem is solved — until someone finds the URL the button pointed at.
Here is the structure I keep coming back to, and why each part of it exists.
One catalogue, defined in code
A permission model spread across middleware, components and database checks cannot be audited, because nobody can enumerate it. I keep a single static matrix: every module, every action, and what each role may do.
// roles × modules × actions, in one place
export const permissions = {
owner: { invoices: ['read', 'create', 'issue', 'void'], projects: ['read', 'create', 'edit'] },
finance: { invoices: ['read', 'create', 'issue', 'void'], projects: ['read'] },
sales: { invoices: ['read'], projects: ['read', 'create', 'edit'] },
} as const;
Two things follow from this being data rather than scattered conditionals. It can be printed — you can put the whole matrix in front of the business owner and have a real conversation about it. And it can be tested: a single test asserts the count of permissions, so nobody adds a capability to a role without that showing up in a diff.
There is no permission-editing interface. A CRM for a small business does not need one, and every admin screen that edits its own authorisation model is a way to lock yourself out or quietly promote someone.
Enforce in the service layer, not in the component
The rule I would put on a wall: hiding a button is not security.
export async function voidInvoice(ctx: Context, id: number) {
assertCan(ctx, 'invoices', 'void'); // the boundary is here
// …
}
The interface should still hide the button — showing people controls they cannot use is bad design. But the check that matters sits in the function that does the work, where every caller has to go through it: the form, the API route, the background job, the script someone runs at 11pm.
If your only check is in the component, your permission model is a suggestion.
Some actions are not about roles at all
Every system I have built has a handful of operations that destroy or rewrite financial history: voiding an issued invoice, creating a credit note, reversing a payment, deleting an expense, editing an exchange rate.
Those are not "admin" actions. They are owner-and-finance actions, and I deliberately exclude the general administrator role from them. It is the one place where the matrix is not a neat hierarchy, and that is the point — the hierarchy is a convenience, not a truth about the business.
Record scope is a separate question from action scope
"Can this user edit deals?" and "can this user edit that deal?" are different questions, and conflating them produces either a system where salespeople edit each other's pipeline or one where nobody can cover for a colleague on holiday.
I model them separately: an action permission, plus a record scope of all, own, or none. The query layer applies the scope; the service layer applies the action.
Push it into the database when the data justifies it
In one CRM the data was sensitive enough that application-level checks alone were not good enough — a mistake in one query would have exposed rows across the business. There, authorisation lives in row-level security policies: the database decides what the signed-in user can read, and the application cannot accidentally ask for more.
That has a real cost. Policies are harder to read than a function, and every query gets slower. I would not reach for it in a typical internal tool. I would reach for it again the moment the data is regulated, or the blast radius of one bad where clause is somebody's personal information.
What I would check in an existing system
- Can you print the permission model? If not, nobody knows what it is.
- Where is it enforced? If the answer is "in the UI", it is not enforced.
- Which actions destroy history, and who can perform them?
- Does "can edit" distinguish between all records and the user's own?
- What happens to a signed-in session when someone's access is revoked? If the answer involves waiting for a token to expire, the revocation is a suggestion too.