20 August 2026 · 3 min read
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.
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.