Permission-Based SaaS Architecture: Building Scalable Multi-Tenant Access Control

A comprehensive guide to designing and implementing permission-based architecture for SaaS applications. Covers RBAC, multi-tenancy, plan-based feature gating, and production-ready Node.js implementation patterns.
Permission-Based SaaS Architecture: Building Scalable Multi-Tenant Access Control
Introduction
One of the most complex challenges in building a SaaS product is designing a permission system that scales gracefully. Whether you're building a simple two-tier system (Admin/User) or a complex enterprise platform with granular, role-based permissions, the foundational design decisions you make early will impact every feature you ship.
In this comprehensive guide, we'll explore permission-based SaaS architecture from fundamentals to production-ready implementation, covering RBAC, ABAC, multi-tenancy, and real-world patterns used by companies like Stripe, Notion, and Linear.
Core Concepts
1. Authentication vs Authorization
These two are often confused but fundamentally different:
- Authentication – Who are you? (Login, JWT tokens, OAuth)
- Authorization – What can you do? (Permissions, roles, policies)
A permission system deals exclusively with authorization. Your auth is handled by providers like Auth0, Supabase, or custom JWT — your permission layer sits on top.
2. The Three Permission Models
RBAC – Role-Based Access Control
Users are assigned roles, and roles have permissions.
User → Role → Permissions
Admin → [create_post, delete_post, manage_users, view_analytics]
Editor → [create_post, edit_post, view_analytics]
Viewer → [view_post]
ABAC – Attribute-Based Access Control
Permissions are granted based on attributes of users, resources, and environment.
ALLOW access IF:
user.department === resource.department AND
user.clearance_level >= resource.sensitivity AND
time.hour BETWEEN 9 AND 17
ReBAC – Relationship-Based Access Control
Permissions are derived from relationships between users and resources (used by Google Zanzibar, which powers Google Drive, Docs, etc.).
User can edit Document IF:
User is owner of Document OR
User is member of Group that has editor access to Document
Multi-Tenant SaaS Architecture
In a SaaS product, you have multiple organizations (tenants) sharing your infrastructure. Each tenant must be completely isolated.
Database Schema Design
-- Organizations (Tenants)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
plan VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Users belong to organizations via memberships
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
full_name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
-- Roles defined per organization
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
is_system_role BOOLEAN DEFAULT false, -- owner, admin, member
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(organization_id, name)
);
-- Permissions (granular actions)
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(150) UNIQUE NOT NULL, -- e.g. "projects:create", "billing:manage"
resource VARCHAR(100) NOT NULL, -- "projects", "billing", "users"
action VARCHAR(100) NOT NULL, -- "create", "read", "update", "delete"
description TEXT
);
-- Role <-> Permission mapping
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- User membership in an organization with a role
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
invited_by UUID REFERENCES users(id),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, organization_id)
);
Implementing Permissions in Node.js
Permission Naming Convention
Use the pattern resource:action for clear, consistent permissions:
// permissions.js - All permissions as constants
const PERMISSIONS = {
// Projects
PROJECTS_CREATE: "projects:create",
PROJECTS_READ: "projects:read",
PROJECTS_UPDATE: "projects:update",
PROJECTS_DELETE: "projects:delete",
// Billing
BILLING_VIEW: "billing:view",
BILLING_MANAGE: "billing:manage",
// Team Members
MEMBERS_INVITE: "members:invite",
MEMBERS_REMOVE: "members:remove",
MEMBERS_VIEW: "members:view",
// Settings
SETTINGS_VIEW: "settings:view",
SETTINGS_MANAGE: "settings:manage",
};
// Default roles with permissions
const DEFAULT_ROLES = {
OWNER: {
name: "owner",
permissions: Object.values(PERMISSIONS), // All permissions
},
ADMIN: {
name: "admin",
permissions: [
PERMISSIONS.PROJECTS_CREATE,
PERMISSIONS.PROJECTS_READ,
PERMISSIONS.PROJECTS_UPDATE,
PERMISSIONS.PROJECTS_DELETE,
PERMISSIONS.MEMBERS_INVITE,
PERMISSIONS.MEMBERS_REMOVE,
PERMISSIONS.MEMBERS_VIEW,
PERMISSIONS.SETTINGS_VIEW,
PERMISSIONS.SETTINGS_MANAGE,
PERMISSIONS.BILLING_VIEW,
],
},
MEMBER: {
name: "member",
permissions: [
PERMISSIONS.PROJECTS_CREATE,
PERMISSIONS.PROJECTS_READ,
PERMISSIONS.PROJECTS_UPDATE,
PERMISSIONS.MEMBERS_VIEW,
],
},
VIEWER: {
name: "viewer",
permissions: [
PERMISSIONS.PROJECTS_READ,
PERMISSIONS.MEMBERS_VIEW,
],
},
};
module.exports = { PERMISSIONS, DEFAULT_ROLES };
Permission Service
// modules/permissions/permission.service.js
const { supabase } = require("../../config/supabase");
class PermissionService {
// Get all permissions for a user in an organization
async getUserPermissions(userId, organizationId) {
const { data, error } = await supabase
.from("organization_members")
.select(`
role:roles (
id,
name,
role_permissions (
permission:permissions (name)
)
)
`)
.eq("user_id", userId)
.eq("organization_id", organizationId)
.eq("is_active", true)
.single();
if (error || !data) return [];
return data.role.role_permissions.map((rp) => rp.permission.name);
}
// Check if user has a specific permission
async hasPermission(userId, organizationId, permission) {
const permissions = await this.getUserPermissions(userId, organizationId);
return permissions.includes(permission);
}
// Check multiple permissions (AND logic)
async hasAllPermissions(userId, organizationId, requiredPermissions) {
const userPermissions = await this.getUserPermissions(userId, organizationId);
return requiredPermissions.every((p) => userPermissions.includes(p));
}
// Check if user has any of the permissions (OR logic)
async hasAnyPermission(userId, organizationId, requiredPermissions) {
const userPermissions = await this.getUserPermissions(userId, organizationId);
return requiredPermissions.some((p) => userPermissions.includes(p));
}
}
module.exports = new PermissionService();
Permission Middleware
// shared/middleware/permission.middleware.js
const permissionService = require("../../modules/permissions/permission.service");
const requirePermission = (permission) => {
return async (req, res, next) => {
try {
const userId = req.user.id;
const organizationId = req.headers["x-organization-id"] || req.params.orgId;
if (!organizationId) {
return res.status(400).json({
success: false,
message: "Organization context required",
});
}
const hasPermission = await permissionService.hasPermission(
userId,
organizationId,
permission
);
if (!hasPermission) {
return res.status(403).json({
success: false,
message: `Permission denied. Required: ${permission}`,
code: "PERMISSION_DENIED",
});
}
next();
} catch (error) {
res.status(500).json({ success: false, message: "Permission check failed" });
}
};
};
const requireAnyPermission = (permissions) => {
return async (req, res, next) => {
const userId = req.user.id;
const organizationId = req.headers["x-organization-id"];
const hasAny = await permissionService.hasAnyPermission(
userId, organizationId, permissions
);
if (!hasAny) {
return res.status(403).json({ success: false, message: "Permission denied" });
}
next();
};
};
module.exports = { requirePermission, requireAnyPermission };
Using Middleware in Routes
// modules/projects/project.routes.js
const { PERMISSIONS } = require("../../shared/constants/permissions");
const { requirePermission } = require("../../shared/middleware/permission.middleware");
router.get("/", requirePermission(PERMISSIONS.PROJECTS_READ), controller.getProjects);
router.post("/", requirePermission(PERMISSIONS.PROJECTS_CREATE), controller.createProject);
router.put("/:id", requirePermission(PERMISSIONS.PROJECTS_UPDATE), controller.updateProject);
router.delete("/:id", requirePermission(PERMISSIONS.PROJECTS_DELETE), controller.deleteProject);
// Billing routes - higher privilege required
router.get("/billing", requirePermission(PERMISSIONS.BILLING_VIEW), billingController.getBilling);
router.post("/billing/upgrade", requirePermission(PERMISSIONS.BILLING_MANAGE), billingController.upgrade);
Plan-Based Feature Gating
Beyond user permissions, SaaS products also gate features by subscription plan:
// shared/middleware/plan.middleware.js
const PLAN_FEATURES = {
free: {
maxProjects: 3,
maxMembers: 5,
features: ["basic_analytics", "email_support"],
},
pro: {
maxProjects: 50,
maxMembers: 25,
features: ["advanced_analytics", "priority_support", "custom_domains"],
},
enterprise: {
maxProjects: Infinity,
maxMembers: Infinity,
features: ["sso", "audit_logs", "sla", "dedicated_support", "custom_roles"],
},
};
const requireFeature = (featureName) => {
return async (req, res, next) => {
const orgPlan = req.organization.plan;
const planConfig = PLAN_FEATURES[orgPlan];
if (!planConfig.features.includes(featureName)) {
return res.status(402).json({
success: false,
message: `Feature '${featureName}' requires a higher plan`,
upgradeRequired: true,
currentPlan: orgPlan,
code: "FEATURE_GATED",
});
}
next();
};
};
module.exports = { requireFeature, PLAN_FEATURES };
Frontend Permission Handling
Permissions shouldn't just be enforced on the backend — the UI should also reflect what a user can do:
// hooks/usePermissions.js (React)
import { useQuery } from "@tanstack/react-query";
import { useOrganization } from "./useOrganization";
export function usePermissions() {
const { currentOrg } = useOrganization();
const { data: permissions = [] } = useQuery({
queryKey: ["permissions", currentOrg?.id],
queryFn: () => api.get(`/organizations/${currentOrg.id}/my-permissions`),
enabled: !!currentOrg,
});
const can = (permission) => permissions.includes(permission);
const canAny = (perms) => perms.some(can);
const canAll = (perms) => perms.every(can);
return { can, canAny, canAll, permissions };
}
// Usage in React components
function ProjectPage() {
const { can } = usePermissions();
return (
<br>
);
}
Audit Logging
Every permission-sensitive action should be logged for compliance and debugging:
// shared/utils/audit.js
async function auditLog({ userId, organizationId, action, resource, resourceId, metadata }) {
await supabase.from("audit_logs").insert({
user_id: userId,
organization_id: organizationId,
action, // "project.created", "member.invited", "billing.upgraded"
resource, // "project", "member", "billing"
resource_id: resourceId,
metadata, // { projectName: "My App", plan: "pro" }
ip_address: req.ip,
user_agent: req.headers["user-agent"],
created_at: new Date(),
});
}
Architecture Summary
Client Request
↓
[Authentication Middleware] → Verify JWT, set req.user
↓
[Organization Context] → Set req.organization from header
↓
[Permission Middleware] → Check user has required permission
↓
[Plan Guard] (if needed) → Check organization plan allows feature
↓
[Controller] → Business logic
↓
[Service Layer] → Data operations + audit log
↓
[Database] → Isolated per organization (RLS)
Conclusion
Building a robust permission system is an investment that pays dividends as your SaaS scales. Key takeaways:
- Start with RBAC — it covers 90% of SaaS permission needs
- Use consistent naming (resource:action) for all permissions
- Enforce permissions at both the API and UI level
- Combine role-based permissions with plan-based feature gating
- Always audit log permission-sensitive actions
- Design for multi-tenancy from day one — retrofitting is painful
The architecture patterns shown here power some of the world's most successful SaaS products. Start simple, document your conventions, and evolve as your needs grow.
Building a SaaS product? Have questions about permission architecture? Leave a comment below!
Tagged