Permissions are one of the most overdesigned parts of SaaS. Teams default to fine-grained from day one, end up with 17 permissions across 6 roles, and can’t answer “can this user do X?” without reading 50 lines of code. Here’s a discipline that scales.
Start with RBAC
Three to five roles, each with a fixed permission set. For most SaaS:
- Owner — everything, including billing and destroying the workspace
- Admin — everything except billing
- Member — create, edit, collaborate on own content
- Viewer — read-only
That’s the whole permission model for your first year, possibly longer. Every check looks like:
if (user.role === 'admin' || user.role === 'owner') { ... }
Simple. Readable. Testable (one test case per role).
Know when RBAC breaks
RBAC breaks when the rule depends on a property of the object, not just the role. The classic triggers:
- “Editors can edit posts they authored, but only read others’”
- “Managers can approve expense reports from their direct reports”
- “Members can delete comments they wrote, for a 5-minute window”
Each of these is “role X + object attribute Y → permission Z.” You can’t express it as a fixed role-to-permission map.
The middle ground: scoped permissions
Before you reach for OpenFGA or Oso, write a permissions module. Centralize all the rules:
// lib/permissions.ts
export function canEditPost(user, post, workspace) {
if (user.role === 'owner') return true
if (user.role === 'admin') return true
if (user.role === 'member' && post.authorId === user.id) return true
return false
}
export function canDeleteComment(user, comment) {
if (user.role === 'admin' || user.role === 'owner') return true
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
return comment.authorId === user.id && comment.createdAt > fiveMinutesAgo
}
This module is:
- Readable: you can audit every permission in one file
- Testable: a dedicated test file with one case per rule
- Localized: when a rule changes, exactly one file changes
For 80% of the “I need attribute-based rules” cases, this is enough.
When to go full ABAC
Go to a real policy engine (OpenFGA, Oso, Cedar) when:
- You have 20+ permission rules, and the permissions module is becoming unreadable
- You need end-user-configurable permissions (custom roles, per-workspace policies)
- You need a policy audit trail (“why does this user have access to X?”)
The tradeoff: a policy engine is another service to run and another learning curve. Don’t adopt it for a 5-rule system.
What never works
- Hardcoded permission strings scattered across the codebase.
if (user.permissions.includes('posts.edit'))in 50 files. When the rule changes, you can’t find every check. Pain. - Roles stored as arrays with custom permissions attached. Flexible until it’s unmaintainable.
- Policy logic in SQL. Invisible in code review. Impossible to unit-test in isolation.
- Self-invented policy DSLs. Every one of these dies when the original author leaves.
The rollout
- Month 0: fixed roles, hardcoded checks
- Month 3: centralized permissions module
- Month 12: scoped permissions for specific objects
- Year 2+: real policy engine if the rule count justifies it
Don’t skip steps, don’t retrofit. Permissions that grew linearly are always cleaner than permissions that were refactored under pressure.