Why 'Just Add a Role' Stops Working for Permissions

A simple admin/user permission check works until someone needs access to one client's data but not another's. Here's how permissions actually get complicated, and how to fix it without building a system you didn't need.

Why 'Just Add a Role' Stops Working for Permissions

Somewhere between "just check if the user is an admin" and hiring someone to explain Google's Zanzibar paper to you, there's a permissions system that actually fits your business. Most companies never get there. They start with two roles, add a third when someone complains, and by year three there's a column called is_super that nobody remembers writing.

I've inherited a few systems like this. The pattern is always the same: permissions started as a shortcut, and the shortcut got load-bearing.

How it usually starts

Most apps begin with something like a role column on the user table: admin, staff, customer. It's fine. It maps cleanly to what the business needs on day one, and it's fast to check in code. An if-statement, basically.

Then the business changes shape a bit. A staff member needs to see reports but not edit invoices. A client wants a "manager" who can approve jobs but not see pricing. Someone needs temporary access to one account while covering for a colleague on leave. None of that fits neatly into three roles, so each one gets bolted on as a special case: another boolean, another if statement checking user->id == 14 in a controller somewhere. I've seen that literally in production code. It works, right up until user 14 leaves the company.

The real problem isn't roles, it's granularity

A role answers "what kind of user is this". It doesn't answer "can this specific user do this specific thing to this specific record right now". Those are different questions, and businesses tend to need the second one far sooner than they expect.

Common places where a simple role check falls over:

  • Ownership. A user can edit their own bookings but not someone else's, even though they share a role.
  • Scope. An agency user should only see the clients assigned to them, not the whole client list.
  • Delegation. Someone needs to act on another user's behalf for a limited time, without becoming that user.
  • Per-resource exceptions. A contractor can see one project's files but nothing else in the account.
  • Approval chains. A discount over a certain value needs sign-off from someone else, but only for that one action.

None of these are solved by adding a fourth role. They're solved by separating who someone is from what they're allowed to do to what.

You probably don't need Zanzibar

Google's Zanzibar system, and the open-source projects that copy it, solve permissions at a scale most businesses will never reach: billions of objects, relationships several hops deep, real-time consistency across services. It's an impressive piece of engineering, and it's massively overkill for a system with a few thousand users and a dozen resource types.

What actually helps most businesses is much more boring: separate the concept of a role (a bundle of default permissions) from a grant (a specific permission on a specific resource for a specific user, optionally with an expiry). Store grants as data in a table, not as logic scattered through the codebase. Then your permission check becomes one consistent question, asked the same way everywhere: does this user have this grant, either directly or through a role.

That single change tends to fix most of the mess, because it stops permissions logic leaking into every controller and view. You get one place to look when something's wrong, and one place to add a new rule instead of five.

What this looks like in practice

A structure I use often:

  • A roles table with a name and a set of default permissions.
  • A permissions table listing the actions the system knows about (view_invoice, edit_booking, approve_discount).
  • A join table linking users to roles, and a separate join table for direct, resource-specific grants, with an optional expiry date.
  • One function that checks all of this consistently, wherever a permission needs checking.

It sounds like more work up front than an enum column, and it is, by a couple of hours. But it means when someone asks for "can this one person see this one client's invoices for the next month while they cover a colleague", the answer is a new row in a table, not a new deploy.

Where to draw the line

The mistake I see going the other way is businesses building an elaborate permissions engine for a system with four users and one role that ever mattered. If nobody's ever asked for an exception, don't build for exceptions. Start with roles. Add the grants table when the first "well, except for this one person" request comes in, because it will.

The other thing worth building alongside this, once permissions get any real complexity, is a record of who changed what access and when. Permissions without a history are just as hard to trust as a balance field without a ledger behind it. If you're already thinking about that, it's worth reading how I approach building an audit trail you can actually trust, since the two problems usually get solved together.

If you've inherited a system where permissions are scattered through if-statements and nobody's sure who can see what, that's a fixable problem, not a rewrite. I've untangled a few of these, and it's usually a matter of days, not months, once the actual rules are written down properly.