RBAC Patterns

advanced

Role-based access control with permissions, roles, and middleware.

authrbacpermissionssecurity
Tested on201619TS5.9
$ bunx sinew add auth/rbac
Interactive demo coming soon

1The Problem

Authorization logic becomes scattered and hard to maintain:

  • Permissions checked inconsistently across the app
  • Hard-coded role checks everywhere
  • No central source of truth for permissions
  • Difficult to audit who can do what

2The Solution

Implement a centralized RBAC system with roles, permissions, and reusable authorization utilities.

3Files

lib/rbac/permissions.ts

lib/rbac/permissions.tsTypeScript
export const PERMISSIONS = {
  // Users
  "users:read": "View user profiles",
  "users:write": "Edit user profiles",
  "users:delete": "Delete users",
  "users:manage": "Manage all users",

  // Content
  "content:read": "View content",
  "content:write": "Create and edit content",
  "content:publish": "Publish content",
  "content:delete": "Delete content",

  // Admin
  "admin:access": "Access admin panel",
  "admin:settings": "Manage settings",
  "admin:billing": "Manage billing",
} as const;

export type Permission = keyof typeof PERMISSIONS;

export const ROLES = {
  user: {
    name: "User",
    permissions: ["users:read", "content:read"] as Permission[],
  },
  editor: {
    name: "Editor",
    permissions: ["users:read", "content:read", "content:write"] as Permission[],
  },
  admin: {
    name: "Admin",
    permissions: [
      "users:read",
      "users:write",
      "users:manage",
      "content:read",
      "content:write",
      "content:publish",
      "content:delete",
      "admin:access",
      "admin:settings",
    ] as Permission[],
  },
  owner: {
    name: "Owner",
    permissions: Object.keys(PERMISSIONS) as Permission[],
  },
} as const;

export type Role = keyof typeof ROLES;

lib/rbac/auth.ts

lib/rbac/auth.tsTypeScript
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ROLES, type Permission, type Role } from "./permissions";

interface AuthorizedUser {
  id: string;
  email: string;
  role: Role;
  permissions: Permission[];
}

export async function getAuthorizedUser(): Promise<AuthorizedUser | null> {
  const session = await auth();

  if (!session?.user?.id) {
    return null;
  }

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, email: true, role: true },
  });

  if (!user) {
    return null;
  }

  const role = (user.role as Role) || "user";
  const permissions = ROLES[role]?.permissions || [];

  return {
    id: user.id,
    email: user.email!,
    role,
    permissions,
  };
}

export async function hasPermission(permission: Permission): Promise<boolean> {
  const user = await getAuthorizedUser();
  if (!user) return false;
  return user.permissions.includes(permission);
}

export async function hasRole(role: Role): Promise<boolean> {
  const user = await getAuthorizedUser();
  if (!user) return false;

  const roleHierarchy: Role[] = ["user", "editor", "admin", "owner"];
  const userRoleIndex = roleHierarchy.indexOf(user.role);
  const requiredRoleIndex = roleHierarchy.indexOf(role);

  return userRoleIndex >= requiredRoleIndex;
}

export async function requirePermission(permission: Permission): Promise<AuthorizedUser> {
  const user = await getAuthorizedUser();

  if (!user) {
    throw new Error("Unauthorized");
  }

  if (!user.permissions.includes(permission)) {
    throw new Error("Forbidden");
  }

  return user;
}

lib/rbac/middleware.ts

lib/rbac/middleware.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { getAuthorizedUser } from "./auth";
import type { Permission, Role } from "./permissions";

export function withPermission(permission: Permission) {
  return function <T extends unknown[]>(
    handler: (req: NextRequest, ...args: T) => Promise<NextResponse>
  ) {
    return async (req: NextRequest, ...args: T): Promise<NextResponse> => {
      const user = await getAuthorizedUser();

      if (!user) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
      }

      if (!user.permissions.includes(permission)) {
        return NextResponse.json({ error: "Forbidden" }, { status: 403 });
      }

      return handler(req, ...args);
    };
  };
}

export function withRole(role: Role) {
  return function <T extends unknown[]>(
    handler: (req: NextRequest, ...args: T) => Promise<NextResponse>
  ) {
    return async (req: NextRequest, ...args: T): Promise<NextResponse> => {
      const user = await getAuthorizedUser();

      if (!user) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
      }

      const roleHierarchy: Role[] = ["user", "editor", "admin", "owner"];
      const userRoleIndex = roleHierarchy.indexOf(user.role);
      const requiredRoleIndex = roleHierarchy.indexOf(role);

      if (userRoleIndex < requiredRoleIndex) {
        return NextResponse.json({ error: "Forbidden" }, { status: 403 });
      }

      return handler(req, ...args);
    };
  };
}

app/api/admin/users/route.ts

app/api/admin/users/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/rbac/middleware";
import { prisma } from "@/lib/db";

const handler = async (req: NextRequest) => {
  const users = await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      role: true,
      createdAt: true,
    },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(users);
};

export const GET = withPermission("users:manage")(handler);

components/permission-gate.tsx

components/permission-gate.tsxTypeScript
import { getAuthorizedUser } from "@/lib/rbac/auth";
import type { Permission } from "@/lib/rbac/permissions";

interface PermissionGateProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export async function PermissionGate({
  permission,
  children,
  fallback = null,
}: PermissionGateProps) {
  const user = await getAuthorizedUser();

  if (!user?.permissions.includes(permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

// Usage in a page:
// <PermissionGate permission="admin:access">
//   <AdminPanel />
// </PermissionGate>

components/role-badge.tsx

components/role-badge.tsxTypeScript
import { ROLES, type Role } from "@/lib/rbac/permissions";

const roleColors: Record<Role, string> = {
  user: "bg-gray-500/10 text-gray-400",
  editor: "bg-blue-500/10 text-blue-400",
  admin: "bg-purple-500/10 text-purple-400",
  owner: "bg-amber-500/10 text-amber-400",
};

export function RoleBadge({ role }: { role: Role }) {
  return (
    <span className={`text-xs px-2 py-0.5 rounded-full ${roleColors[role]}`}>
      {ROLES[role].name}
    </span>
  );
}

4Configuration

Add Role to User Model

model User {
  id    String @id @default(cuid())
  email String @unique
  role  String @default("user")
  // ... other fields
}
prisma

Initialize Default Admin

// scripts/create-admin.ts
import { prisma } from "@/lib/db";

async function main() {
  await prisma.user.update({
    where: { email: "admin@example.com" },
    data: { role: "owner" },
  });
}

main();
TypeScript

5Usage

In Server Components

import { PermissionGate } from "@/components/permission-gate";

export default async function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <PermissionGate permission="admin:access">
        <AdminSection />
      </PermissionGate>

      <PermissionGate permission="content:write" fallback={<p>Read-only access</p>}>
        <Editor />
      </PermissionGate>
    </div>
  );
}
TypeScript

In Server Actions

"use server";

import { requirePermission } from "@/lib/rbac/auth";

export async function deleteUser(userId: string) {
  await requirePermission("users:delete");

  // Proceed with deletion
  await prisma.user.delete({ where: { id: userId } });
}
TypeScript

6Troubleshooting

Permission denied unexpectedly

  • Verify user's role in the database
  • Check that role is included in session callback
  • Ensure permission is spelled correctly (case-sensitive)

Role hierarchy not working

  • Check roleHierarchy array order
  • Verify role names match exactly

Related patterns