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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Role design + permission matrix | 0.5โ1 day | $400โ$800 |
| Permission context + API enforcement | 1โ2 days | $800โ$1,600 |
| React permission gates + provider | 1 day | $600โ$1,000 |
| Role management UI + API | 1โ2 days | $800โ$1,600 |
| Resource-level permissions | 2โ3 days | $1,600โ$2,500 |
| Full RBAC system | 1.5โ2 weeks | $6,000โ$10,000 |
See Also
- PostgreSQL Row-Level Security โ Database-enforced row isolation
- SaaS Audit Logging โ Logging permission-gated actions
- PostgreSQL Triggers Audit โ Tracking role changes at DB level
- SaaS Team Invitations โ Inviting members with role assignment
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.