Skip to content
sg·
← cd ../notes

RBAC that survives the org chart

July 2026 · 5 min read

Every RBAC system starts the same way: three roles — admin, manager, viewer — and an if statement in each handler. And every RBAC system that starts that way ends the same way too: forty roles with names like regional-ops-admin-readonly-v2, and nobody can answer the only question that matters: who can do what?

When I built a role-scoped admin panel for internal operations, the design that held up came down to four decisions. None of them are exotic; all of them fight the same enemy — the org chart, which will reorganize itself long before your code does.

1. Permissions are resource + action. Roles are just bundles.

The unit of access is never 'manager'. It's refunds:approve, cards:issue, users:read. Roles exist purely as named bundles of those scopes — convenient labels for humans, meaningless to the enforcement code.

// Roles are data, not code.
const roles = {
  "ops-admin":  ["cards:issue", "cards:revoke", "refunds:approve", "users:read"],
  "support":    ["users:read", "refunds:request"],
  "viewer":     ["users:read"],
};

// Enforcement only ever sees scopes:
function can(operator, scope) {
  return resolveScopes(operator.roles).includes(scope);
}

When the org chart changes — and it will — you edit bundles. The business code, and its tests, never move. We learned this the expensive way: we started with coarse roles baked into handlers, and paid for a migration once the role matrix grew.

2. Resolution happens in one layer, not in every handler

Scattered role checks are how permission bugs are born: someone adds an endpoint, forgets the check, and the gap ships. Centralize resolution — middleware that turns 'who is this' into 'what may they do' — and make handlers declare what they require.

// The handler declares; the layer enforces.
router.post(
  "/cards/:id/revoke",
  requireScope("cards:revoke"),   // ← the only access control line
  revokeCard,
);

One layer means one place to audit, one place to test, and one place a security review has to read. It also enables the property that saved us repeatedly: an endpoint with no declared scope fails closed.

3. Deny by default — access is an allowlist, always

Allow-by-default with exclusions sounds friendlier and is a standing invitation to incident reports. Deny-by-default flips the failure mode: forgetting something means a support ticket ('I can't see X') instead of a breach ('who could see X?'). One of those is annoying; the other is a disclosure.

It also changes the review culture. Granting access becomes a visible diff to a role bundle that someone approves — not the absence of an exclusion nobody remembered to add.

4. Audit the denials, not just the actions

Everyone logs successful actions — who did what, when. The underrated half is logging denials. A viewer probing refunds:approve five times in a minute is either a confused human, a broken client, or a compromised account. All three are things you want to know about, and the allowed-actions log shows you none of them.

  • Allowed action → audit log (who, what, when, on which resource)
  • Denied attempt → same log, flagged — denials are the security signal
  • The UI only renders actions the operator's scopes permit, so honest users rarely see a denial at all

The result we measured: routine admin tasks got ~30% faster, because operators finally had exactly the tools their role needed — no more, no less, and no waiting on an engineer with production access.

If you remember one thing: build for the reorg. Roles are what the org chart looks like today; scopes are what your system actually does. Only one of those is stable enough to build on.

Questions or war stories of your own? Email me — I read everything.