The Permission Model That Grew Until Nobody Understood It
The app started with three roles. When I audited it, there were 94 permissions, 17 roles, and a function called checkAccess that was 400 lines of nested conditionals.
The first sign of trouble was a support ticket that nobody could explain. A customer reported that their account manager could see invoices but not download them. This was supposed to be impossible — the "Account Manager" role had full billing access. Or so everyone thought.
I was three weeks into an engagement with a B2B SaaS company. Forty engineers, two hundred enterprise customers, a product that had been in production for five years. They'd brought me in for an architecture review, but I kept tripping over authorization bugs. Not edge cases. Basic things, like a user who should clearly have access to something and didn't, or a user who shouldn't have access to something and did.
So I did what I usually do when something smells wrong: I followed the code.
How three roles became seventeen
The original permission model was clean. I could see it in the git history, committed four years ago. Three roles: admin, manager, member. Each role had a list of capabilities. Every API route checked the user's role against a list of allowed roles. Simple, readable, correct.
Then the enterprise customers arrived.
The first request was reasonable: some customers wanted a read-only role for auditors. So the team added auditor. Then a customer wanted managers who could handle billing but not user management, so they split manager into billing_manager and team_manager. Then came support_agent, which was like member but with access to the admin ticket queue. Then custom_report_viewer, which existed for exactly one customer who needed three of their staff to see reports without seeing anything else.
By the time I looked at it, the Role enum had seventeen entries. But the roles were the easy part.
When roles weren't enough
About two years in, someone realized that roles were too coarse. A customer wanted their managers to have full access except for deleting records. You can't model that with a role alone — you'd need a separate role for every combination of "everything except X." So the team introduced granular permissions.
Each role mapped to a set of permissions like invoices:read, invoices:write, invoices:delete, users:invite, reports:export. There were 94 of them. The mapping was defined in a file called role-permissions.ts that was 600 lines long, mostly arrays of strings.
But that still wasn't enough. Some permissions were conditional. A team_manager could edit users, but only users within their own team. A billing_manager could see invoices, but only for their assigned accounts. These conditions lived in the route handlers themselves, as ad-hoc checks.
if (user.role === 'billing_manager' && invoice.accountId !== user.assignedAccountId) {
if (!user.permissions.includes('invoices:read_all')) {
throw new ForbiddenError();
}
}That invoices:read_all permission was a backdoor — added six months earlier because the CEO of a major customer wanted their CFO to see all invoices across all accounts. Instead of modeling multi-account access properly, someone added a permission that bypassed the account filter. It was assigned to exactly one user in production, via a direct database update that was documented in a Slack thread nobody could find anymore.
The checkAccess function
All of this came together in a function called checkAccess. It was supposed to be the single place where authorization decisions happened. In practice, it was a 400-line function full of nested conditionals, early returns, and comments like // temporary fix for Acme Corp dated eighteen months ago.
The function checked the user's role, then their explicit permissions, then whether certain feature flags were enabled for their organization, then whether the user had been granted any "permission overrides" (a concept added to handle exactly the kind of one-off requests that created the mess in the first place). Each layer could grant or deny access, and the precedence rules were nowhere documented.
I wrote a simple test: create a user with the team_manager role and the invoices:delete permission explicitly revoked. Can they delete an invoice? The answer depended on whether the organization had the legacy_permissions feature flag enabled. With the flag on, explicit permission revocations were ignored and the role's default permissions applied. With the flag off, revocations took precedence.
Twelve of their two hundred organizations still had that flag on. Nobody remembered why.
What actually broke
Back to the original support ticket. The account manager who couldn't download invoices — it turned out their organization had recently been migrated to a new billing tier. The migration script updated the organization's plan, but it also toggled a feature flag called granular_billing_access. With that flag enabled, the invoices:read permission was split into invoices:view and invoices:download. The Account Manager role still had invoices:read, which now granted nothing because the system was looking for the new permission keys.
Nobody had updated the role-permission mapping for the new flag. The migration script's author didn't even know the permission system existed in its current form. They'd been on the team for six months and had only ever worked on the billing service.
Warning
Untangling it
The refactor took about three months of careful, incremental work. Not because the new model was complex, but because migrating 200 organizations with production data and active users requires extreme caution. You don't get to move fast when you're touching who can see what.
The new model had four concepts: roles define a baseline set of permissions, scopes constrain where those permissions apply (which teams, which accounts), explicit grants add individual permissions beyond the role's baseline, and that's it. No feature flags modifying authorization behavior. No permission overrides. No special cases for individual organizations.
The biggest win wasn't the cleaner model, though. It was the authorization audit log. The old system had no logging for access decisions. When someone reported a permission bug, the only way to debug it was to read the code and try to trace the logic manually. The new system logged every access decision — what was checked, what the inputs were, what the result was. The first week it was live, the team found and fixed three permission bugs that customers had been silently working around for months.
The pattern
I've seen this same progression at four different companies now. It always starts the same way: a clean, simple role-based model. Then a customer asks for something the model can't express. Under deadline pressure, someone adds a special case instead of rethinking the model. Then another. Then another. Each individual change is small and defensible. The accumulated weight of forty small changes is an authorization system that nobody trusts.
The hard part isn't building a good permission model. The hard part is recognizing when you've outgrown the current one and stopping to redesign before the special cases calcify into load-bearing complexity. By the time you have a function called checkAccess with nested conditionals for individual customer names, you're already six months late.
If your authorization logic lives in more than one file, or if you have any per-customer permission overrides stored as database rows that someone inserted manually — it might be time to step back and draw the model you actually need, not the one you've been patching.