How to Build Role-Based Permissions for SaaS Beyond Simple Admin and User Roles

Every SaaS starts with two roles: admin and user. The admin can do everything. The user can do some things. It works for a minimum viable product. It works through your first ten customers. Then an enterprise prospect joins a demo call and asks three questions you cannot answer with a boolean isAdmin column: "Can I make someone an admin in one workspace but not another? Can I grant document-level edit permissions without making them a workspace admin? And can I create a custom auditor role that can see everything but change nothing?"
That is the moment your SaaS role based permission system implementation stops being optional and starts being the thing that closes enterprise deals. This post walks through the schema, guards, caching, and audit patterns we use to handle these requirements in production NestJS applications.

RBAC vs ABAC vs ReBAC — Which Model Does Your SaaS Need?
Before writing any code, you need to choose an access control model. The three most common ones serve different needs, and picking the wrong one early creates painful migrations later.
RBAC (Role-Based Access Control) assigns permissions to roles, and roles to users. When a user performs an action, you check whether their role includes the required permission. RBAC is the default choice for most SaaS platforms because it is simple to implement, easy to audit, and maps naturally to how organizations think about job functions.
ABAC (Attribute-Based Access Control) evaluates policies against attributes of the user, resource, and environment at access time. Instead of checking "is the user an admin?", you evaluate a policy like "allow access if user.department equals resource.department AND current_time is within business_hours." ABAC is more expressive but significantly harder to maintain — policies become complex, auditing requires simulating attribute combinations, and debugging a denied access often takes a spreadsheet.
ReBAC (Relationship-Based Access Control) grants access based on relationships between entities. Google's Zanzibar paper formalized this: "Alice can read document D because Alice is a member of Team T, and Team T owns Document D." ReBAC handles complex organizational structures well but requires dedicated infrastructure (relationship graphs, reverse index queries) that most SaaS teams should not build themselves.
The OWASP Authorization Cheat Sheet is the reference worth reading before you commit to any of these models. For most B2B SaaS platforms, RBAC with workspace scoping is the right foundation. Add ABAC rules selectively for sensitive resources (e.g., "only the document owner can delete it"), and defer ReBAC until you have a dedicated infrastructure team. The schema and patterns below follow this hybrid approach: RBAC as the core, ABAC-style resource checks layered on top.

The Real Permission Model Most SaaS Platforms Need
A production SaaS role based permission system has four layers:
- Authentication — who is making the request? (JWT, session, API key)
- Workspace context — which workspace are they acting in?
- Role assignment — what role do they hold in that workspace?
- Resource-level check — are they allowed to act on this specific resource?
Steps 1 and 2 happen in middleware. Steps 3 and 4 happen in a permission guard. The key insight is that a user does not have a single global role — they have different roles in different workspaces, and within a workspace they may have additional permissions granted directly on specific resources.
Database Schema for a SaaS Role Based Permission System
The schema needs to support workspace-level roles (user is admin in workspace A, viewer in workspace B), resource-level permissions (can edit document X but not document Y), and a role hierarchy (admin inherits everything editor can do).
1-- Core tables
2CREATE TABLE workspaces (
3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 name TEXT NOT NULL,
5 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
6);
7
8CREATE TABLE roles (
9 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
10 name TEXT NOT NULL,
11 description TEXT,
12 is_system BOOLEAN NOT NULL DEFAULT false, -- prevents deletion of built-in roles
13 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
14 UNIQUE(name)
15);
16
17CREATE TABLE permissions (
18 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
19 name TEXT NOT NULL, -- 'document.create', 'document.delete', 'workspace.settings'
20 description TEXT,
21 resource_type TEXT NOT NULL, -- 'document', 'workspace', 'team', 'billing'
22 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23 UNIQUE(name)
24);
25
26-- Junction tables
27CREATE TABLE workspace_memberships (
28 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
29 user_id UUID NOT NULL REFERENCES users(id),
30 workspace_id UUID NOT NULL REFERENCES workspaces(id),
31 role_id UUID NOT NULL REFERENCES roles(id),
32 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
33 UNIQUE(user_id, workspace_id)
34);
35
36CREATE TABLE role_permissions (
37 role_id UUID NOT NULL REFERENCES roles(id),
38 permission_id UUID NOT NULL REFERENCES permissions(id),
39 PRIMARY KEY (role_id, permission_id)
40);
41
42-- Role hierarchy (e.g., admin > editor > viewer)
43CREATE TABLE role_hierarchy (
44 role_id UUID NOT NULL REFERENCES roles(id),
45 parent_role_id UUID NOT NULL REFERENCES roles(id),
46 PRIMARY KEY (role_id, parent_role_id)
47);
48
49-- Resource-level overrides
50CREATE TABLE resource_permissions (
51 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
52 user_id UUID NOT NULL REFERENCES users(id),
53 resource_type TEXT NOT NULL, -- 'document'
54 resource_id UUID NOT NULL,
55 permission_id UUID NOT NULL REFERENCES permissions(id),
56 workspace_id UUID NOT NULL REFERENCES workspaces(id),
57 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
58);The workspace_memberships table is the linchpin. It ties a user to a role within a workspace. Without this table, you end up with global roles — and global roles break the moment a user needs admin access in one workspace and viewer access in another.
The role_hierarchy table enables permission inheritance. When you check whether a user has document.delete, you check their role's permissions and all permissions inherited through parent roles. This means you define Viewer permissions once, and Editor inherits them automatically.
Workspace-Level Roles: Different Permissions in Different Contexts
A production SaaS platform rarely gives a user the same access across all workspaces. The same person who is an admin in their own company's workspace should be a viewer in a client's workspace and a member in a partner's workspace.
1// src/permissions/permission.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4@Injectable()
5export class PermissionService {
6 constructor(
7 @InjectRepository(WorkspaceMembership)
8 private readonly membershipRepo: Repository<WorkspaceMembership>,
9 @InjectRepository(RolePermission)
10 private readonly rolePermissionRepo: Repository<RolePermission>,
11 @InjectRepository(ResourcePermission)
12 private readonly resourcePermissionRepo: Repository<ResourcePermission>,
13 @InjectRepository(RoleHierarchy)
14 private readonly roleHierarchyRepo: Repository<RoleHierarchy>,
15 ) {}
16
17 async getUserRoleInWorkspace(
18 userId: string,
19 workspaceId: string,
20 ): Promise<Role | null> {
21 const membership = await this.membershipRepo.findOne({
22 where: { userId, workspaceId },
23 relations: ['role'],
24 });
25 return membership?.role ?? null;
26 }
27
28 async userHasPermission(
29 userId: string,
30 workspaceId: string,
31 permissionName: string,
32 ): Promise<boolean> {
33 // Check workspace-level role permissions (with inheritance)
34 const membership = await this.membershipRepo.findOne({
35 where: { userId, workspaceId },
36 relations: ['role'],
37 });
38 if (!membership) return false;
39
40 const hasRolePermission = await this.roleHasPermission(
41 membership.role.id,
42 permissionName,
43 );
44 if (hasRolePermission) return true;
45
46 // Check resource-level direct permissions
47 const directPermission = await this.resourcePermissionRepo.findOne({
48 where: {
49 userId,
50 workspaceId,
51 permission: { name: permissionName },
52 },
53 });
54
55 return !!directPermission;
56 }
57
58 private async roleHasPermission(
59 roleId: string,
60 permissionName: string,
61 ): Promise<boolean> {
62 // Check direct permissions
63 const direct = await this.rolePermissionRepo.findOne({
64 where: {
65 roleId,
66 permission: { name: permissionName },
67 },
68 });
69 if (direct) return true;
70
71 // Check inherited permissions through role hierarchy
72 const hierarchy = await this.roleHierarchyRepo.find({
73 where: { roleId },
74 });
75 for (const entry of hierarchy) {
76 const inherited = await this.roleHasPermission(
77 entry.parentRoleId,
78 permissionName,
79 );
80 if (inherited) return true;
81 }
82
83 return false;
84 }
85}This function is the core of your SaaS role based permission system implementation. Every permission check in your application flows through it, which means you can cache its results, log its decisions, and audit its usage from a single code path.

Resource-Level Permissions
Role-based checks answer "can this role perform this action?" but enterprise SaaS often needs a finer question: "can this specific user perform this action on this specific resource?"
A common example: a workspace admin assigns document-view permissions to an external auditor. The auditor should see the document but not be able to edit it. The auditor does not have a workspace role — they have a resource-level permission that grants read access to a specific document.
1// Check resource-level permission before any resource mutation
2async function checkResourcePermission(
3 userId: string,
4 resourceType: string,
5 resourceId: string,
6 permissionName: string,
7 workspaceId: string,
8): Promise<boolean> {
9 // First check workspace-level role
10 const hasWorkspacePermission = await permissionService.userHasPermission(
11 userId,
12 workspaceId,
13 permissionName,
14 );
15 if (hasWorkspacePermission) return true;
16
17 // Then check resource-level override
18 const resourcePermission = await resourcePermissionRepo.findOne({
19 where: {
20 userId,
21 resourceType,
22 resourceId,
23 permission: { name: permissionName },
24 },
25 });
26
27 return !!resourcePermission;
28}Resource-level permissions should be exceptions, not the rule. If more than 10% of your access decisions come from resource-level overrides, your role definitions are probably wrong — you need more granular roles rather than more exceptions.
NestJS Guard with Permission Decorator
Once the schema and service are in place, the next step is wiring permission checks into your NestJS route handlers. The pattern uses a custom decorator to declare required permissions and a NestJS guard that evaluates them.
1// src/permissions/decorators/require-permissions.decorator.ts
2export const PERMISSIONS_KEY = 'required_permissions';
3export const RequirePermissions = (...permissions: string[]) =>
4 SetMetadata(PERMISSIONS_KEY, permissions);1// src/permissions/guards/permissions.guard.ts
2import { Injectable } from '@nestjs/common';
3@Injectable()
4export class PermissionsGuard implements CanActivate {
5 constructor(
6 private readonly reflector: Reflector,
7 private readonly permissionService: PermissionService,
8 ) {}
9
10 async canActivate(context: ExecutionContext): Promise<boolean> {
11 const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
12 PERMISSIONS_KEY,
13 [context.getHandler(), context.getClass()],
14 );
15 if (!requiredPermissions?.length) return true;
16
17 const request = context.switchToHttp().getRequest();
18 const user = request.user;
19 const workspaceId = request.params.workspaceId || request.headers['x-workspace-id'];
20
21 if (!user || !workspaceId) {
22 throw new ForbiddenException('Missing user or workspace context');
23 }
24
25 for (const permission of requiredPermissions) {
26 const hasPermission = await this.permissionService.userHasPermission(
27 user.id,
28 workspaceId,
29 permission,
30 );
31 if (!hasPermission) {
32 throw new ForbiddenException(
33 `Missing required permission: ${permission}`,
34 );
35 }
36 }
37
38 return true;
39 }
40}Apply the guard to your controllers with the authentication guard running first:
1@Controller('documents')
2@UseGuards(JwtAuthGuard, PermissionsGuard)
3export class DocumentsController {
4 @Delete(':id')
5 @RequirePermissions('document.delete')
6 async remove(@Param('id') id: string, @Request() req) {
7 return this.documentsService.remove(id, req.user.id);
8 }
9
10 @Post()
11 @RequirePermissions('document.create')
12 async create(@Body() dto: CreateDocumentDto, @Request() req) {
13 return this.documentsService.create(dto, req.user.id);
14 }
15}The guard order matters. JwtAuthGuard must run before PermissionsGuard because the permission guard needs req.user to be populated. If you reverse them, the permission check throws a 403 before NestJS can return a proper 401 for unauthenticated requests.

Caching Permission Checks in Redis
The userHasPermission function shown above makes multiple database queries on every invocation. For a hot endpoint handling hundreds of requests per second, this database load adds up quickly. Cache resolved permissions with a short TTL to reduce the hit rate.
1// src/permissions/permission-cache.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRedis } from '@nestjs-modules/ioredis';
4@Injectable()
5export class PermissionCacheService {
6 constructor(
7 @InjectRedis() private readonly redis: Redis,
8 private readonly permissionService: PermissionService,
9 ) {}
10
11 private cacheKey(userId: string, workspaceId: string, permission: string): string {
12 return `perm:${userId}:${workspaceId}:${permission}`;
13 }
14
15 async checkPermission(
16 userId: string,
17 workspaceId: string,
18 permission: string,
19 ): Promise<boolean> {
20 const key = this.cacheKey(userId, workspaceId, permission);
21
22 // Check cache
23 const cached = await this.redis.get(key);
24 if (cached === '1') return true;
25 if (cached === '0') return false;
26
27 // Resolve and cache
28 const result = await this.permissionService.userHasPermission(
29 userId,
30 workspaceId,
31 permission,
32 );
33 await this.redis.set(key, result ? '1' : '0', 'EX', 300); // 5 minute TTL
34
35 return result;
36 }
37
38 async invalidateUserPermissions(userId: string, workspaceId: string): Promise<void> {
39 const pattern = `perm:${userId}:${workspaceId}:*`;
40 const keys = await this.redis.keys(pattern);
41 if (keys.length > 0) {
42 await this.redis.del(...keys);
43 }
44 }
45}The cache TTL is set to five minutes by default. This means a permission change takes up to five minutes to propagate, which is acceptable for most SaaS products. When a user's role changes or a permission override is added, call invalidateUserPermissions to clear the relevant cache entries immediately — the next check will fall through to the database and re-cache the result.
For hot paths, consider pre-warming the cache at login time. After authentication, resolve and cache all permissions the user needs for their current workspace. This shifts the database load from individual requests to the login event.
Use the Redis SET command with the EX option (as shown above) to set an expiration on each cache entry. This ensures stale permissions are automatically evicted even if you forget to call the invalidation function.

Permission Inheritance and Role Hierarchy
Role hierarchy lets you define permissions once and compose roles hierarchically. Instead of listing document.read, document.create, document.edit, document.delete separately for Admin, Editor, and Viewer roles, you define Viewer with only document.read, Editor inherits from Viewer and adds document.create and document.edit, and Admin inherits from Editor and adds document.delete and workspace.settings.
1// Seed script for role hierarchy
2async function seedRoles() {
3 const viewerRole = await roleRepo.save({
4 name: 'viewer',
5 description: 'Can view documents and comments',
6 isSystem: true,
7 });
8
9 const editorRole = await roleRepo.save({
10 name: 'editor',
11 description: 'Can create and edit documents',
12 isSystem: true,
13 });
14
15 const adminRole = await roleRepo.save({
16 name: 'admin',
17 description: 'Full workspace access',
18 isSystem: true,
19 });
20
21 // Build hierarchy: admin > editor > viewer
22 await roleHierarchyRepo.save([
23 { roleId: editorRole.id, parentRoleId: viewerRole.id },
24 { roleId: adminRole.id, parentRoleId: editorRole.id },
25 ]);
26
27 // Assign permissions to roles
28 const docRead = await permissionRepo.findOneBy({ name: 'document.read' });
29 const docCreate = await permissionRepo.findOneBy({ name: 'document.create' });
30 const docDelete = await permissionRepo.findOneBy({ name: 'document.delete' });
31
32 await rolePermissionRepo.save([
33 { roleId: viewerRole.id, permissionId: docRead.id },
34 { roleId: editorRole.id, permissionId: docCreate.id },
35 { roleId: adminRole.id, permissionId: docDelete.id },
36 ]);
37}When userHasPermission checks the editor role for document.read, it does not find the permission directly on the editor role — but it traverses the hierarchy and discovers that editor inherits from viewer, which has document.read. This is implemented in the roleHasPermission method shown earlier, which recursively walks the role_hierarchy table.
The hierarchy should be limited to two or three levels in practice. Deep hierarchies (five levels or more) are hard to reason about and introduce performance overhead in the recursive permission resolution. If your domain requires deep nesting, flatten the hierarchy at write time instead of traversing it at read time — compute the effective permission set when a role is created and store it in a materialized column.
Audit Logging Permission Changes
Every permission change — role assignment, role creation, resource permission grant — should be logged. This is not optional for enterprise customers who need SOC 2 compliance or internal security reviews.
1// src/permissions/permission-audit.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4@Injectable()
5export class PermissionAuditService {
6 constructor(
7 @InjectRepository(AuditLog)
8 private readonly auditRepo: Repository<AuditLog>,
9 ) {}
10
11 async logPermissionChange(params: {
12 actorId: string;
13 targetUserId: string;
14 workspaceId: string;
15 action: 'role_assigned' | 'role_removed' | 'permission_granted' | 'permission_revoked';
16 details: Record<string, unknown>;
17 }) {
18 await this.auditRepo.save({
19 actorId: params.actorId,
20 targetUserId: params.targetUserId,
21 workspaceId: params.workspaceId,
22 eventType: `permission.${params.action}`,
23 details: params.details,
24 ipAddress: null, // Set from request context
25 userAgent: null, // Set from request context
26 createdAt: new Date(),
27 });
28 }
29}Log every role assignment and permission change. Include the actor (who made the change), the target (who it affects), the workspace, and the before-and-after state. For more on the complete audit log schema and query patterns, see our audit log implementation guide.
Store audit logs in a separate table (or a separate database) from your operational data. Permission audit logs are append-only and rarely queried by ID — they are queried by time range and actor. Use a time-series partitioning strategy to keep query performance predictable as the table grows.
Putting It All Together: The Service Layer
The complete permission service integrates workspace scoping, role hierarchy, resource-level overrides, caching, and audit logging into a single interface:
1@Injectable()
2export class PermissionFacade {
3 constructor(
4 private readonly cache: PermissionCacheService,
5 private readonly audit: PermissionAuditService,
6 private readonly permissionService: PermissionService,
7 ) {}
8
9 async check(userId: string, workspaceId: string, permission: string): Promise<boolean> {
10 return this.cache.checkPermission(userId, workspaceId, permission);
11 }
12
13 async assignRole(params: {
14 actorId: string;
15 targetUserId: string;
16 workspaceId: string;
17 roleId: string;
18 }) {
19 await this.permissionService.assignRole(params);
20 await this.cache.invalidateUserPermissions(params.targetUserId, params.workspaceId);
21 await this.audit.logPermissionChange({
22 ...params,
23 action: 'role_assigned',
24 details: { roleId: params.roleId },
25 });
26 }
27
28 async grantResourcePermission(params: {
29 actorId: string;
30 targetUserId: string;
31 resourceType: string;
32 resourceId: string;
33 permissionId: string;
34 workspaceId: string;
35 }) {
36 await this.permissionService.grantResourcePermission(params);
37 await this.cache.invalidateUserPermissions(params.targetUserId, params.workspaceId);
38 await this.audit.logPermissionChange({
39 ...params,
40 action: 'permission_granted',
41 details: {
42 resourceType: params.resourceType,
43 resourceId: params.resourceId,
44 permissionId: params.permissionId,
45 },
46 });
47 }
48}This facade pattern gives you a single entry point for all permission operations. Every check goes through the cache, every mutation invalidates the cache and writes an audit log. You never accidentally skip a step because the code path for a permission change only exists in one place.
Common Mistakes That Break SaaS Permission Systems
Storing roles on the user table. A role column on the users table makes workspace-level roles impossible. You need a junction table that ties a user to a role within a workspace context.
Checking permissions in application code instead of a guard. When permission checks are scattered across controllers and services as if (user.role === 'admin'), you cannot audit them, cache them, or change the permission model without touching every file. Centralize all checks in the PermissionsGuard.
Using permission names as TypeScript enums. Permission names like "document.delete" are application data, not code constants. When you add a new feature, you should insert a row into the permissions table — not ship a TypeScript release and an enum update. Keep permissions in the database and sync them at application boot.
Forgetting to invalidate the permission cache. A Redis cache with a five-minute TTL means permission changes take up to five minutes to propagate. Always call the invalidation function in the same transaction that updates the permission. If the cache invalidation fails, the change still applies — the TTL handles the eventual propagation.
Building an RBAC system before you need it. If your SaaS has fewer than 50 users across three workspaces, a role column on the user table with two or three hardcoded roles is fine. Build the workspace-scoped RBAC system when you add your first enterprise customer who asks for it. Premature abstraction creates complexity without value.
Conclusion
The gap between an isAdmin column and an enterprise-grade RBAC system is not that large. It is a schema with workspace memberships, a role hierarchy table, resource-level override support, a permission guard, a Redis cache layer, and an audit trail. That sounds like a lot of pieces, but each one is small and the integration surface between them is minimal — a few decorators, one guard, one facade service.
The schema scales from three roles (viewer, editor, admin) to dozens of custom enterprise roles without a migration. The guard works with any authentication strategy — JWT, session, API key — as long as the request has a user object with an id. The cache is optional but trivial to add (one service, one Redis decorator call). The audit log writes itself once you wire up the facade.
If you are currently building a SaaS product and wondering whether your permission model will handle enterprise requirements — start with the schema. Everything else is code. The schema is the commitment.
If you are refactoring an existing codebase from if (user.role === 'admin') checks scattered across controllers, start with the guard. Move one permission check at a time into the guard, validate the behavior, and remove the inline check. It is not an all-or-nothing migration — the two approaches can coexist during the transition.
For a complete reference implementation of authentication to pair with this permission system, see our JWT authentication with refresh tokens guide. For the project structure where this permission module lives, see our NestJS project structure.
Frequently Asked Questions
A role-based permission system (RBAC) assigns permissions to roles rather than individual users. Users gain permissions by being assigned roles within a workspace. This scales from small teams (admin, member, viewer) to enterprise hierarchies with dozens of roles and hundreds of granular permissions per workspace.
RBAC grants access based on the user's role within a workspace. ABAC (Attribute-Based Access Control) evaluates attributes of the user, resource, and environment at access time — such as department, document sensitivity, time of day, or location. ABAC is more flexible but harder to audit and maintain. Many SaaS platforms use RBAC as the foundation and add ABAC rules for specific sensitive resources.
Implement RBAC in NestJS with a five-table database schema (users, workspaces, roles, permissions, resources), a dedicated PermissionService that checks workspace-scoped and resource-scoped permissions, custom decorators like @RequirePermissions, and a guard that evaluates permissions before route handlers execute. Cache resolved permissions in Redis to avoid database load on every request.
Workspace-level permissions let a user hold different roles in different workspaces. A user can be admin in their own workspace, member in a client's workspace, and viewer in a partner's workspace. Each role-workspace-user combination is tracked in a junction table, and permission checks always include the current workspace context from the request.
Permission inheritance means higher-level roles automatically include the permissions of lower-level roles. For example, an Admin inherits all Editor permissions plus administrative actions, and Editor inherits all Viewer permissions plus edit capabilities. This is implemented with a role hierarchy table (role_id, parent_role_id) and recursive resolution in the permission check function.
