Permissions look like an if-statement. By the time you have three roles and one custom rule, it's a system — and most teams discover the mistakes in production. Here's the map.
The previous post was about tables — one of the eight items on the floor of every admin product, and a representative example of what happens when "a component" turns into "a system." Five problems, one shape.
Permissions is the next item on that floor. It has the same five-shaped pattern, with completely different surface details, and it bites teams earlier and harder than tables do. The difference: when a table is wrong, the symptom is a slow page or a confused user. When permissions are wrong, the symptom is a data breach or an audit finding.
This post takes permissions apart the same way. Five problems, in the order they appear.
Problem 1: role strings stop working
It starts as if (user.role === 'admin'). One check, one role, ships. It works for two months.
Then product asks for a second admin who can do most of what the first admin can — but not delete customers. So you add if (user.role === 'admin' || user.role === 'manager') and a few inverse checks for the delete case. Now there are two role strings appearing across the codebase, and they're not always consistent.
Then a customer asks for a "viewer" role for their auditor — read-only, but read-only of which resources? Some tenants want auditors to see findings but not financial details. Now you're inside if (user.role === 'admin' || (user.role === 'auditor' && tenant.financialAccess)).
This is the moment role strings die. The fix isn't to make the strings smarter; it's to stop treating the role as the permission and start treating the role as a grouping of permissions.
That requires three concepts, not one:
-
Abilities define what's possible at all.
assessmenthas the actionsread / create / update / delete.customerhas its own set. These are the atoms of the permission system, edited by an admin and (rarely) by a developer. -
Roles bundle abilities into named groups. A role is a name plus a matrix — "for the
assessmentability, this role can read and create but not update or delete." The "admin" role and the "auditor" role are two such bundles. Adding a new role is a backend change with no frontend impact. - Users have roles — at least one, sometimes several. A user who is both an "auditor" and a "support agent" gets the union of those two matrices. The effective permissions arrive at the frontend as a flattened matrix on the user record, computed at sign-in so the frontend doesn't have to do the join:
user.abilities = {
customer: { read: true, create: false, update: false, delete: false },
assessment: { read: true, create: true, update: true, delete: false },
// ...one entry per resource the user can touch
}
This three-level structure is what makes the system scale. Adding a permission is editing one role. Granting a user a temporary capability is adding a role to their record. Building a "support agent + auditor" hybrid is assigning two existing roles to one user. Adding a new role should be a data or admin-side change, not a frontend change — the frontend should consume the resulting ability matrix without caring whether the role is called admin, auditor, or support_agent.
Coreola ships this exact model. The accounts module has three resources: Abilities (the atomic definitions), Roles (named bundles with matrices), and Users (assigned to roles). The user record arriving from sign-in carries the flattened matrix on user.abilities. A useAbility() hook builds a CASL Ability from it, and feature code calls ability.can('read', 'assessment'). No role strings appear in feature code — only abilities.
Problem 2: permission checks scattered everywhere
A button has an inline check. A route file has a different inline check. A menu item has yet another. The redirect resolver — the thing that decides where to send a user landing on / — has its own logic for "where is this user allowed to go first?"
Four pieces of code, four checks, almost-but-not-quite the same. A bug in one of them is invisible until someone clicks just the right way and lands in a place they shouldn't be.
The shape that survives: one predicate, many call sites.
export function canAnyAbility(ability: AppAbility, abilityCan?: string[]): boolean {
if (!abilityCan?.length) {
return true;
}
return abilityCan.some((abilityKey) => canByAbilityKey(ability, abilityKey));
}
abilityCan is an array of "subject.action" keys — "assessment.read", "customer.delete" — and canByAbilityKey splits the key, normalizes singular/plural subject variants, and asks CASL whether any of them are allowed. The whole helper is one small function consumed by every gating surface in the app.
That function is called from the route filter, the menu builder, and the redirect resolver. Three call sites, one rule. A change to the rule changes all three at once. A user who is forbidden a route never sees it in the sidebar, never gets redirected into it, and can't navigate to it directly.
This is the part that looks obvious in retrospect and almost nobody gets right the first time. The instinct is to write the check next to the consumer ("the sidebar handles its own logic"). The cost shows up six months later when the rules diverge.
Coreola funnels every gating decision through filterRoutesByAbility. The router uses it. The menu tree uses it. The index-redirect resolver uses it. A forbidden route does not appear in any of those surfaces because the same function decides.
Problem 3: hide vs disable
A user lacks permission to delete a customer. What should the Delete button look like?
The intuitive answer is to render it disabled — grey, unclickable, with a tooltip explaining why. It feels honest: "this feature exists; you just can't use it."
It's the wrong answer most of the time, and here's why:
- A disabled control invites questions. "Why can't I delete this?" becomes a support ticket. Multiply that by every gated action in the product.
- It makes the UI heavier than it needs to be. Twelve disabled controls per row of a table is visual noise, not information.
- Screenshots for support and docs are worse, because the disabled state varies per user and the screenshots are taken by admins who don't see the disabled version anyway. The convention that holds up: hide forbidden controls. Render them only when the user can act. If a user with the manager role doesn't see Delete, they don't ask why — they assume the product simply doesn't offer it to them.
The exception is discovery. If a feature is gated as part of a trial or upgrade ("Pro users can export to PDF"), the user should know it exists so they can opt in. For that case, render an empty-state or banner with a "request access" affordance — not a disabled button.
Coreola treats hide-don't-disable as a project-wide convention. Permission checks at the component level early-return null instead of rendering a disabled variant. Discovery cases get their own empty-state patterns, separate from the gating system. This is the kind of decision that has to be made once across the codebase or it never gets made.
Problem 4: frontend permissions as security
A junior engineer hides the Delete button when the user lacks permission. The button doesn't render. They move on.
Three weeks later a curious user opens DevTools, finds the API endpoint, fires fetch('/customers/123', { method: 'DELETE' }), and the customer is gone.
This isn't a hypothetical. Many teams eventually learn this the hard way. The mistake is treating the frontend's role as security when it's actually UX. The frontend hides things to keep the interface coherent; the backend has to refuse things to keep the data safe.
The rule that works is defense in depth: frontend gates routes and components; backend enforces every mutation. Both layers do their own check, independently, against the same permission rules. The frontend hides the button. The backend returns 403 if anyone asks anyway. If either layer is missing, the system is broken — but the frontend missing is a worse user experience, while the backend missing is a vulnerability.
The corollary: never write try/catch around a forbidden mutation to "handle" the 403. If a user can fire a mutation they shouldn't be able to fire, the gate is missing — not the catch handler. Add the gate; don't catch the 403.
Coreola ships the frontend permission layer: route gates, navigation filtering, component-level checks, and mutation-level UI gates. The production backend still has to enforce the same rules independently. The mock backend in development is for prototyping, not security; the real backend you wire up is where the actual enforcement lives. This is one of the few places where the foundation deliberately stops short and forces the consumer to wire it correctly.
Problem 5: feature flags and permissions get conflated
A team adds a feature flag for an export-to-PDF rollout. Then someone says "we also want this to be admin-only." The path of least resistance is to wire both checks into the flag — turn off the flag for non-admins, turn it on for admins.
This breaks within a week. Flags are environment-scoped — they exist to turn things on and off for everyone in a deploy or tenant. Permissions are user-scoped — they exist to grant or deny actions per user. Conflating them creates a flag that's actually a permission, which makes it impossible to roll out the feature to all admins without also turning it on for non-admins.
The two mechanisms are deliberately separate:
| Feature flags | Permissions | |
|---|---|---|
| Scope | Per-environment | Per-user |
| Lifecycle | Short-lived; removed when stable | Permanent; tied to the resource model |
| Audience for changes | Engineering / product | Customer admins / operations |
| Storage | Server-side config | User record (matrix) |
A route or component can gate on both. "Show the export button when the export flag is enabled AND the user can export." Two independent checks, composed at the call site. Either one off, the button is hidden.
The temptation to merge them is strongest when a feature is new and only used by one role. Resist it. The day the feature graduates to general availability, you'll want to flip the flag without touching the permission model — and that only works if they were never the same thing.
Coreola keeps these strictly separate. Routes can declare abilityCan: [...] and featureFlagCan: [...] independently. There are two hooks, useAbility and useFeatureFlag. The admin UI for managing each lives in a different place — abilities under Accounts → Roles, flags under Settings → Feature Flags — because the people who change them are different people with different intentions.
The pattern, again
Re-read those five and the shape from the table post echoes:
- The naive model breaks — role strings; the array-of-rows table.
- Logic ends up in the wrong place — checks duplicated next to consumers; state duplicated in components instead of the URL.
-
Two stores or surfaces collide — flags and abilities mistakenly merged; two tables fighting over
?page. - A reconciliation rule is needed — defense in depth between frontend and backend; URL state vs. user preferences.
- A discipline is needed to keep things separate — hide-don't-disable as a project convention; decoupled column/filter/query definitions. Tables solve it with one shape. Permissions solve it with another. Forms will be a third. Notifications a fourth. The eight items on the floor share this pattern, and the work of building each one well is the work of recognizing which of the five problems you're currently in front of and applying the right fix.
This is what makes the floor expensive when you build it alone — you have to discover the pattern from scratch at every layer. And it's what makes the floor a solvable problem once: encode the pattern, test it on a real workflow, fork it into every new product.
The next post takes one of those real workflows apart — the Assessments module — and shows how the abstract patterns from these three posts hold up against statuses, sub-resources, and a decision flow that runs separately from status. That's the test most templates skip and where most foundations crack.
Coreola is a React admin foundation that has CASL-based permissions wired at the route, navigation, and component level, with feature flags as an independent layer and a working ability matrix tested against a real workflow module. Live demo at demo.coreola.com.
Top comments (0)