Back to Blog

SaaS Role-Based Access Control in 2026: Permission Matrix, Role Hierarchy, and UI Gates

Build a production SaaS RBAC system: role hierarchy design, permission matrix, TypeScript permission checks, PostgreSQL row-level enforcement, and React UI gates with usePermission.

Viprasol Tech Team
January 23, 2027
14 min read

SaaS Role-Based Access Control in 2026: Permission Matrix, Role Hierarchy, and UI Gates

Authorization is the most commonly underbuilt feature in early-stage SaaS. A hardcoded if (user.role === "admin") check works until you have five different roles, resource-scoped permissions, and enterprise customers who need to create custom roles. Building RBAC correctly from the start saves months of painful refactoring later.

This post covers the full implementation: role hierarchy design, a typed permission matrix, server-side enforcement in API routes, PostgreSQL row-level security integration, React UI gates that hide/show elements based on permissions, and the "permission context" pattern that loads permissions once per request.


Role Design

Start with a role hierarchy where higher roles inherit all permissions from lower ones:

// lib/auth/roles.ts

export type Role =
  | "owner"     // Full control, billing, can delete workspace
  | "admin"     // Manage members, settings, all resources
  | "manager"   // Manage projects and team members within projects
  | "member"    // Create and edit own resources
  | "viewer"    // Read-only access
  | "guest";    // Time-limited, specific resource access only

// Inheritance chain: owner > admin > manager > member > viewer > guest
export const ROLE_HIERARCHY: Record<Role, number> = {
  owner:   100,
  admin:   80,
  manager: 60,
  member:  40,
  viewer:  20,
  guest:   10,
};

export function roleAtLeast(userRole: Role, minimumRole: Role): boolean {
  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[minimumRole];
}

Permission Matrix

Map actions to the minimum role required:

// lib/auth/permissions.ts
import { Role, roleAtLeast } from "./roles";

// Subjects (what is being acted on)
export type Subject =
  | "workspace"
  | "project"
  | "task"
  | "comment"
  | "member"
  | "billing"
  | "api_key"
  | "audit_log"
  | "integration"
  | "report";

// Actions (what can be done)
export type Action =
  | "create"
  | "read"
  | "update"
  | "delete"
  | "invite"
  | "manage"
  | "export";

// Permission matrix: subject โ†’ action โ†’ minimum role
type PermissionMatrix = {
  [S in Subject]?: {
    [A in Action]?: Role;
  };
};

export const PERMISSIONS: PermissionMatrix = {
  workspace: {
    read:    "viewer",
    update:  "admin",
    delete:  "owner",
    manage:  "owner",
  },
  project: {
    create:  "member",
    read:    "viewer",
    update:  "member",     // Can update own; manager for all
    delete:  "manager",
    manage:  "admin",
  },
  task: {
    create:  "member",
    read:    "viewer",
    update:  "member",
    delete:  "member",     // Own tasks; manager for all
  },
  comment: {
    create:  "member",
    read:    "viewer",
    update:  "member",     // Own comments
    delete:  "manager",
  },
  member: {
    read:    "viewer",
    invite:  "admin",
    update:  "admin",
    delete:  "admin",
  },
  billing: {
    read:    "admin",
    manage:  "owner",
  },
  api_key: {
    create:  "admin",
    read:    "admin",
    delete:  "admin",
  },
  audit_log: {
    read:    "admin",
    export:  "admin",
  },
  integration: {
    create:  "admin",
    read:    "member",
    delete:  "admin",
    manage:  "admin",
  },
  report: {
    read:    "member",
    create:  "member",
    export:  "manager",
  },
};

export function can(
  userRole: Role,
  action: Action,
  subject: Subject
): boolean {
  const requiredRole = PERMISSIONS[subject]?.[action];
  if (!requiredRole) return false; // Not defined = not allowed
  return roleAtLeast(userRole, requiredRole);
}

// Type-safe assertion that throws on unauthorized
export function mustCan(
  userRole: Role,
  action: Action,
  subject: Subject,
  message?: string
): void {
  if (!can(userRole, action, subject)) {
    throw new PermissionError(
      message ?? `Permission denied: cannot ${action} ${subject}`
    );
  }
}

export class PermissionError extends Error {
  readonly statusCode = 403;
  constructor(message: string) {
    super(message);
    this.name = "PermissionError";
  }
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Database Schema

-- Roles are per-team (a user can be admin in one team, viewer in another)
CREATE TABLE team_members (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id   UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  user_id   UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role      TEXT NOT NULL DEFAULT 'member',
  
  -- Resource-scoped roles (optional โ€” for project-level permissions)
  -- Can be extended with a team_member_project_roles join table
  
  invited_by UUID REFERENCES users(id),
  joined_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  
  UNIQUE (team_id, user_id),
  CHECK (role IN ('owner', 'admin', 'manager', 'member', 'viewer', 'guest'))
);

-- Resource-level role overrides (e.g., guest access to specific project)
CREATE TABLE resource_access (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  resource_type TEXT NOT NULL,   -- 'project', 'report', etc.
  resource_id   UUID NOT NULL,
  role          TEXT NOT NULL,   -- Role scoped to this resource
  expires_at    TIMESTAMPTZ,     -- Optional expiry for guest access
  granted_by    UUID REFERENCES users(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  
  UNIQUE (user_id, resource_type, resource_id)
);

Permission Context (Load Once Per Request)

// lib/auth/permission-context.ts
import { cache } from "react";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { Role, can, mustCan, Action, Subject } from "./permissions";
import type { PermissionError } from "./permissions";

export interface PermissionContext {
  userId: string;
  teamId: string;
  role: Role;
  can: (action: Action, subject: Subject) => boolean;
  mustCan: (action: Action, subject: Subject, message?: string) => void;
  isOwner: boolean;
  isAdmin: boolean;
}

// cache() deduplicates across the render tree โ€” DB queried once per request
export const getPermissionContext = cache(
  async (): Promise<PermissionContext | null> => {
    const headersList = await headers();
    const userId = headersList.get("x-user-id");
    const teamId = headersList.get("x-team-id");

    if (!userId || !teamId) return null;

    const membership = await db.teamMember.findUnique({
      where: { teamId_userId: { teamId, userId } },
      select: { role: true },
    });

    if (!membership) return null;

    const role = membership.role as Role;

    return {
      userId,
      teamId,
      role,
      can: (action, subject) => can(role, action, subject),
      mustCan: (action, subject, message) => mustCan(role, action, subject, message),
      isOwner: role === "owner",
      isAdmin: role === "admin" || role === "owner",
    };
  }
);

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

API Route Enforcement

// app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getPermissionContext } from "@/lib/auth/permission-context";
import { PermissionError } from "@/lib/auth/permissions";
import { db } from "@/lib/db";

export async function POST(req: NextRequest) {
  const ctx = await getPermissionContext();
  if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  try {
    ctx.mustCan("create", "project"); // Throws PermissionError if not allowed
  } catch (e) {
    if (e instanceof PermissionError) {
      return NextResponse.json({ error: e.message }, { status: 403 });
    }
    throw e;
  }

  const { name, description } = await req.json();
  const project = await db.project.create({
    data: { name, description, teamId: ctx.teamId, createdBy: ctx.userId },
  });

  return NextResponse.json(project, { status: 201 });
}

// app/api/projects/[id]/route.ts
export async function DELETE(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const ctx = await getPermissionContext();
  if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { id } = await params;

  const project = await db.project.findFirst({
    where: { id, teamId: ctx.teamId },
  });

  if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });

  // Managers can delete any project; members can only delete their own
  const canDelete =
    ctx.can("delete", "project") ||
    (project.createdBy === ctx.userId && ctx.can("update", "project"));

  if (!canDelete) {
    return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
  }

  await db.project.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

React Permission Gates

// components/auth/PermissionGate.tsx
"use client";

import { ReactNode } from "react";
import { usePermissions } from "@/hooks/usePermissions";
import type { Action, Subject } from "@/lib/auth/permissions";

interface PermissionGateProps {
  action: Action;
  subject: Subject;
  children: ReactNode;
  fallback?: ReactNode;  // What to show if no permission
}

export function PermissionGate({
  action,
  subject,
  children,
  fallback = null,
}: PermissionGateProps) {
  const { can } = usePermissions();

  if (!can(action, subject)) return <>{fallback}</>;
  return <>{children}</>;
}

// Disabled variant โ€” renders but disables the element
export function PermissionDisabled({
  action,
  subject,
  children,
}: Omit<PermissionGateProps, "fallback">) {
  const { can } = usePermissions();
  const allowed = can(action, subject);

  return (
    <div
      className={!allowed ? "opacity-50 pointer-events-none select-none" : undefined}
      title={!allowed ? "You don't have permission to perform this action" : undefined}
      aria-disabled={!allowed}
    >
      {children}
    </div>
  );
}
// hooks/usePermissions.ts
"use client";

import { useContext, createContext, ReactNode } from "react";
import type { Role, Action, Subject } from "@/lib/auth/permissions";
import { can as checkCan } from "@/lib/auth/permissions";

interface PermissionsContextValue {
  role: Role;
  userId: string;
  teamId: string;
  can: (action: Action, subject: Subject) => boolean;
  isOwner: boolean;
  isAdmin: boolean;
}

const PermissionsContext = createContext<PermissionsContextValue | null>(null);

export function PermissionsProvider({
  role,
  userId,
  teamId,
  children,
}: {
  role: Role;
  userId: string;
  teamId: string;
  children: ReactNode;
}) {
  const value: PermissionsContextValue = {
    role,
    userId,
    teamId,
    can: (action, subject) => checkCan(role, action, subject),
    isOwner: role === "owner",
    isAdmin: role === "admin" || role === "owner",
  };

  return (
    <PermissionsContext.Provider value={value}>
      {children}
    </PermissionsContext.Provider>
  );
}

export function usePermissions(): PermissionsContextValue {
  const ctx = useContext(PermissionsContext);
  if (!ctx) throw new Error("usePermissions must be used inside PermissionsProvider");
  return ctx;
}

Provide from the layout:

// app/(app)/layout.tsx
import { getPermissionContext } from "@/lib/auth/permission-context";
import { PermissionsProvider } from "@/components/auth/PermissionsProvider";
import { redirect } from "next/navigation";

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const ctx = await getPermissionContext();
  if (!ctx) redirect("/login");

  return (
    <PermissionsProvider role={ctx.role} userId={ctx.userId} teamId={ctx.teamId}>
      {children}
    </PermissionsProvider>
  );
}

Usage in components:

// components/ProjectActions.tsx
"use client";

import { PermissionGate, PermissionDisabled } from "@/components/auth/PermissionGate";
import { Trash2, Settings } from "lucide-react";

export function ProjectActions({ projectId }: { projectId: string }) {
  return (
    <div className="flex gap-2">
      {/* Completely hidden if no permission */}
      <PermissionGate action="update" subject="project">
        <button className="btn-secondary">
          <Settings className="h-4 w-4" />
          Settings
        </button>
      </PermissionGate>

      {/* Visible but disabled if no permission */}
      <PermissionDisabled action="delete" subject="project">
        <button className="btn-danger">
          <Trash2 className="h-4 w-4" />
          Delete
        </button>
      </PermissionDisabled>

      {/* Upgrade prompt as fallback */}
      <PermissionGate
        action="export" subject="report"
        fallback={
          <button className="btn-secondary opacity-60" onClick={() => /* show upgrade modal */ {}}>
            Export (Pro)
          </button>
        }
      >
        <button className="btn-secondary">Export</button>
      </PermissionGate>
    </div>
  );
}

Role Management API

// app/api/members/[userId]/role/route.ts
export async function PUT(
  req: NextRequest,
  { params }: { params: Promise<{ userId: string }> }
) {
  const ctx = await getPermissionContext();
  if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Only admins can change roles
  if (!ctx.can("update", "member")) {
    return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
  }

  const { userId } = await params;
  const { role } = await req.json() as { role: Role };

  // Prevent privilege escalation โ€” can't assign a role higher than your own
  if (ROLE_HIERARCHY[role] >= ROLE_HIERARCHY[ctx.role]) {
    return NextResponse.json(
      { error: "Cannot assign a role equal to or higher than your own" },
      { status: 403 }
    );
  }

  // Prevent demoting owners (must transfer ownership separately)
  const target = await db.teamMember.findUnique({
    where: { teamId_userId: { teamId: ctx.teamId, userId } },
  });

  if (target?.role === "owner" && ctx.role !== "owner") {
    return NextResponse.json({ error: "Cannot change the owner's role" }, { status: 403 });
  }

  await db.teamMember.update({
    where: { teamId_userId: { teamId: ctx.teamId, userId } },
    data: { role },
  });

  return NextResponse.json({ success: true });
}

Cost and Timeline

ComponentTimelineCost (USD)
Role design + permission matrix0.5โ€“1 day$400โ€“$800
Permission context + API enforcement1โ€“2 days$800โ€“$1,600
React permission gates + provider1 day$600โ€“$1,000
Role management UI + API1โ€“2 days$800โ€“$1,600
Resource-level permissions2โ€“3 days$1,600โ€“$2,500
Full RBAC system1.5โ€“2 weeks$6,000โ€“$10,000

See Also


Working With Viprasol

We design and implement RBAC systems for SaaS products โ€” from simple three-role setups through complex multi-tenant permission matrices with resource-level overrides. Our team has shipped authorization systems for products with enterprise customers requiring custom roles and granular permissions.

What we deliver:

  • Role hierarchy and permission matrix design
  • TypeScript permission library with compile-time safety
  • API route middleware for server-side enforcement
  • React permission gates for UI-level access control
  • Role management UI for workspace admins

Explore our SaaS development services or contact us to design your authorization system.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.