How to design roles & permissions properly

RBAC until it breaks. ABAC when it breaks. Never invent your own policy language. The full rollout path.

· LoginWith team

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.

Want auth that just works?

Get started with LoginWith