Team accounts and roles that survive the third custom permission request
The first customer asks for a role that can view reports but not export them. The second wants their finance team to approve invoices before they're sent. The third needs a regional manager who can edit pricing for their region only. By the third request, the role table has fourteen rows, nobody remembers what "Manager" was supposed to do, and the frontend has permission checks scattered across forty components with no single source of truth.
We've shipped this pattern enough times to have a default approach. It's not exotic, but it holds up.
Start with actions, not roles
The mistake is designing roles first. Roles are a packaging concern. The actual unit of authorization is an action: `invoices.create`, `reports.export`, `pricing.edit`. Define the full set of actions your application supports, and make that list the thing you check against.
const permissions = {
'invoices.create': true,
'invoices.approve': false,
'reports.view': true,
'reports.export': false,
'pricing.edit': false,
};
function can(action: string): boolean {
return permissions[action] === true;
}Roles become a named bundle of action grants. "Admin" gets everything. "Finance" gets invoice actions plus reports. "Viewer" gets read-only. When a customer asks for a custom role, you create a new named bundle, not a new code path.
Store grants, not role names
At the database level, we store the resolved grants per user, not just a role ID. A user row has a `role_id` pointing to a template, and a `grants` column holding the effective permission set. When an admin tweaks a role, we recompute grants for affected users. This means permission checks never hit the role table — they read a single column.
CREATE TABLE users (
id uuid PRIMARY KEY,
team_id uuid REFERENCES teams(id),
role_id uuid REFERENCES roles(id),
grants jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX users_team_id_idx ON users(team_id);The tradeoff: role changes require a write to every affected user row. For most SaaS applications, teams are small enough that this is a non-issue. If you have teams with thousands of users, you can batch the recomputation or switch to a computed approach with caching.
Handle the regional manager problem
The third customer's request — a regional manager who can edit pricing for their region only — is where pure action-based permissions break down. The action `pricing.edit` is too coarse. You need a condition.
We add a `scope` to grants. A grant becomes `{ action: 'pricing.edit', scope: { region: 'west' } }`. The permission check passes the context, and the grant matches against it.
type Grant = {
action: string;
scope?: Record<string, string>;
};
function can(action: string, context: Record<string, string>): boolean {
return grants.some(
g => g.action === action &&
Object.entries(g.scope ?? {}).every(
([k, v]) => context[k] === v
)
);
}This adds complexity. The permission function now takes context, and you have to think about what context means for each action. We only add scoping when a customer actually needs it. Most don't.
What we don't do
We don't build a visual permission editor where admins drag actions onto roles. It sounds useful, but in practice, the set of actions changes with every feature release, and the editor becomes a maintenance burden that nobody fully understands. A role definition in code or a seed file is easier to review and version.
We also don't implement hierarchical roles (Manager inherits from Editor inherits from Viewer). Inheritance sounds clean until someone needs to remove one permission from a child role. Then you're implementing subtraction semantics, and the mental model collapses. Flat roles with explicit grants are boring and readable.
The test that matters
When the fourth customer asks for a custom permission, the work should be: add the action to the list, create a role bundle, assign it. No new database columns, no new frontend patterns, no branching logic in components. If that's true, the model is working.