Blog
BackendSaaSArchitecture

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

February 27, 20268 min read20 views
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:

  • AuthenticationWho are you? (Login, JWT tokens, OAuth)
  • AuthorizationWhat 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

#saas#rbac#permissions#multi-tenant#access-control#nodejs#authorization#backend
All Posts