Back to writing

9 October 2025

Stop checking roles. Check capabilities.

We inherited a payroll app with three roles and one pattern: if (role === 'manager'), copy-pasted into 14 files. It shipped fine. It worked. Nobody thought about it until the fourth role showed up.

A regional lead needed one screen the manager didn't have and one report the admin did. We added || role === 'regional_lead' in six places, missed the seventh, and shipped a build where regional leads couldn't see the approvals queue. QA caught it. The next miss wouldn't have been a lockout, it would have been a leak.

The code never cared about the role

We went through those 14 checks and every one of them was standing in for a specific action. Approve a refund. See the salary column. Delete another user's record. Not one of them actually needed to know the person was a manager.

A role is a bundle of permissions. Checking the role checks the bundle name instead of the thing inside it. So the moment two roles share one permission, the names stop mapping cleanly and you patch it with ||. That's the whole rot.

We counted the || chains as we went. The refund-approval check was already role === 'admin' || role === 'manager'. The salary-column check had grown to three names. One report guard was up to four. Every one of them was a permission that two or three roles happened to share, expressed as a list of role names that had to be kept in sync by hand across the codebase. Add a role and you had to find every list that role belonged in.

What we moved to

We named every action the system performs as module.entity.action. There were 38 of them in the end:

orders.refund.approve
payroll.salary.view
users.record.delete

A capability is a string. A role is a set of them. A user gets exactly one role. Then we replaced the role checks:

// before — 14 files like this
if (user.role === 'admin' || user.role === 'manager') { ... }

// after
if (user.can('orders.refund.approve')) { ... }

Adding a role became a data change. We define a set of capabilities and assign it. No file gets edited, no condition grows a fourth ||. The regional lead that broke the build earlier was now just a role with approvals.queue.view in its set. The screen checks the capability and doesn't know which role granted it.

The roles live in one table now, roles, with a join to role_capabilities. Defining regional-lead was one insert plus 11 rows in the join table:

insert into roles (name) values ('regional-lead');
insert into role_capabilities (role, capability)
select 'regional-lead', c from (values
  ('approvals.queue.view'),
  ('orders.refund.approve'),
  ('payroll.salary.view')
  -- ...8 more
) as t(c);

No deploy, no code review of a || chain, no chance of missing the seventh file. The set is data, and data is easy to get exactly right.

The can() check itself is a set membership test. On login we load the user's role, join to role_capabilities, and put the resulting set of 11 to 38 strings on the request context:

def load_capabilities(user):
    rows = db.query(RoleCapability).filter(
        RoleCapability.role == user.role,
    )
    return frozenset(r.capability for r in rows)

# then can() is a lookup, not a chain of ORs
def can(ctx, capability: str) -> bool:
    return capability in ctx.capabilities

One query at login, cached on the session for the request lifetime, and every check after that is an O(1) set lookup. The 14 files that each did their own role math now call the same function against the same set. There is exactly one place that knows how a role maps to permissions, and it's a join table, not 14 hand-maintained || lists.

One role per user — we tried the other way

We did briefly let a user hold two roles and union the capabilities. It looked cheaper. Within a week every capability check was walking a list of roles at runtime, and we were back to computing bundles on every request. We'd rebuilt the exact thing we were escaping.

So: one role per user, and a combination is a new named role. When someone needs manager work plus audit access, that's regional-lead with its own explicit set. Naming it forced the question "who actually gets this?" instead of quietly OR-ing permissions and hoping the blast radius stayed small.

The multi-role version had a second problem we didn't see until we tried to answer a compliance question. Two users both had "manager plus auditor" but one was assigned the roles in a different order, and a bug in how we merged the two sets meant one of them silently lost payroll.salary.view where the sets overlapped. Nobody reported it because the missing capability just hid a column. When every combination is a named role with one explicit set, there's no merge step to get wrong. regional-lead has its 11 rows and every user with that role gets exactly those 11.

We also considered a role hierarchy — manager inherits everything viewer has, admin inherits everything manager has. It reads cleanly on a whiteboard. It falls apart the first time a role needs most of its parent's capabilities but not one of them, and now you're subtracting from an inherited set, which is harder to reason about than a flat list. We kept roles flat: each role names its capabilities directly, no inheritance, no subtraction. A flat set of 11 strings is something a non-engineer can read and audit. An inheritance tree with exceptions is not.

The frontend gate is not the security

Hiding a button when the user lacks a capability is good UX. We shipped a <Can> wrapper for it:

<Can do="users.record.delete">
  <DeleteButton />
</Can>

That stops the button rendering. It does nothing to stop a curl at the endpoint. We tested this on our own staging: opened dev tools as a viewer, read the DELETE call the button would have made, fired it directly, and it went through. The button was gone, the door was open.

So we check the same capability again on the server:

@requires("users.record.delete")
def delete_user(request, user_id):
    ...

Two checks, same string, different jobs. The frontend one decides what you see. The backend one decides what you can do. We'd skipped the second on the delete endpoint, and the first was theater until we added it.

The failure mode that pushed us to enforce this everywhere was subtler than a missing decorator. One endpoint checked the capability but checked the wrong one — the edit endpoint guarded on records.item.view instead of records.item.edit, because someone copy-pasted the decorator and didn't change the string. A viewer could edit. The <Can> gate on the button was correct, so it never showed in the UI, and we only found the hole when we wrote a test that hit every write endpoint as a viewer and asserted a 403. Seven endpoints passed. One returned 200. We now have that test as a gate: for every capability-gated endpoint, a viewer-role request must be rejected, and it runs on every build.

The decorator reads the same context the login step populated, so backend and frontend check byte-for-byte the same string:

def requires(capability: str):
    def wrap(fn):
        def inner(request, *a, **kw):
            if capability not in request.ctx.capabilities:
                raise Forbidden(capability)
            return fn(request, *a, **kw)
        return inner
    return wrap

There's no separate backend permission model to drift from the frontend one. Both layers ask the same question against the same set.

What it bought

A new role is a row in a table now. The permission audit that used to be "grep for role strings across 40 files" became "list the capabilities in this set." When compliance asked "who can approve refunds?" we ran a query instead of an investigation, and had the answer in under a minute.

The cost was real: naming 38 actions and threading a check through both layers took most of two days up front. But it's the same two days whether you have 3 roles or 30, which is the one thing the role-string version could never promise.