π― Complete RBAC Guide for Beginners: From Zero to Production-Ready#
π Table of Contents#
What is RBAC?
Core Concepts Explained
Step-by-Step Implementation
Complete Code Examples
Real-World Flow Diagrams
Common Pitfalls & Solutions
Testing Your RBAC System
π€ What is RBAC?#
RBAC (Role-Based Access Control) is like a digital security guard for your application. Instead of checking everyone's ID, it checks their role to decide what they can do.
Real-Life Analogy π’#
Imagine a building:
CEO (Admin): Has keys to every room
Manager (Consultant): Has keys to their department
Employee (User): Only has keys to their office
Custom Role (Specialist): Has keys to specific rooms they need
In tech terms:
Roles = Job titles (Admin, User, Consultant)
Permissions = What each role can do
Resources = What they can do it to (users, properties, etc.)
πͺ Core Concepts Explained#
1. Roles vs Permissions#
// Role = Job Title
const roles = {
admin: "Boss - can do everything",
user: "Regular employee - limited access",
consultant: "Specialist - specific access"
};
// Permission = What they can do
const permissions = {
canEditUsers: true, // Admin: β, User: β
canViewReports: true, // Admin: β, Consultant: β
canDeleteData: false // Most: β
};
2. The Permission Matrix#
Think of it as an Excel sheet:
Role View Users Edit Users Delete Users Add Property Admin β β β β User β β β β Consultant β β β β
3. How RBAC Works in Your App#
User Logs In β Check Role β Check Permissions β Allow/Deny Action
π¨ Step-by-Step Implementation#
Step 1: Database Models (MongoDB)#
User Model (models/User.tsClick to copy)#
models/User.tsClick to copy)#import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
name: { type: String, required: true },
// BASE ROLE (simple - for quick checks)
role: {
type: String,
enum: ['user', 'admin', 'consultant'],
default: 'user'
},
// CUSTOM ROLE (detailed - for granular control)
customRole: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role', // Links to Role model
required: false
},
// FOR APPROVAL WORKFLOW
pendingRole: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role'
},
// AUDIT TRAIL - track role changes
roleHistory: [{
roleId: { type: mongoose.Schema.Types.ObjectId, ref: 'Role' },
assignedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
assignedAt: { type: Date, default: Date.now },
approved: { type: Boolean, default: false }
}],
emailVerified: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now }
});
export const User = mongoose.model('User', userSchema);
Role Model (models/Role.tsClick to copy)#
models/Role.tsClick to copy)#import mongoose from 'mongoose';
// Define what actions are possible for each resource
const permissionSchema = new mongoose.Schema({
view: { type: Boolean, default: false },
edit: { type: Boolean, default: false },
delete: { type: Boolean, default: false },
approve: { type: Boolean, default: false }
});
const roleSchema = new mongoose.Schema({
name: {
type: String,
required: true,
unique: true
},
description: String,
// PERMISSION MATRIX
permissions: {
properties: permissionSchema,
services: permissionSchema,
blogs: permissionSchema,
users: permissionSchema,
newsletter: permissionSchema,
chatLeads: permissionSchema,
addProperty: permissionSchema,
addService: permissionSchema
},
// System roles cannot be modified
predefined: {
type: Boolean,
default: false
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
createdAt: {
type: Date,
default: Date.now
}
});
export const Role = mongoose.model('Role', roleSchema);
Invitation Model (models/Invitation.tsClick to copy)#
models/Invitation.tsClick to copy)#import mongoose from 'mongoose';
const invitationSchema = new mongoose.Schema({
email: {
type: String,
required: true
},
token: {
type: String,
required: true,
unique: true
},
// Role to assign when user accepts
roleId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role'
},
invitedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
status: {
type: String,
enum: ['pending', 'accepted', 'revoked'],
default: 'pending'
},
expiresAt: {
type: Date,
required: true
},
createdAt: {
type: Date,
default: Date.now
}
});
export const Invitation = mongoose.model('Invitation', invitationSchema);
Step 2: Authentication Setup#
Auth Configuration (lib/auth.tsClick to copy)#
lib/auth.tsClick to copy)#import { betterAuth } from "@better-auth/next";
import { mongodbAdapter } from "@better-auth/mongodb-adapter";
import mongoose from 'mongoose';
// Connect to MongoDB
const db = mongoose.connection;
export const auth = betterAuth({
database: mongodbAdapter(db),
emailAndPassword: {
enabled: true,
autoSignIn: false,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update after 1 day of inactivity
},
callbacks: {
async session({ session, user }) {
// Add user role to session
const dbUser = await User.findById(user.id);
session.user.role = dbUser?.role || 'user';
session.user.customRole = dbUser?.customRole;
return session;
}
}
});
Step 3: Permission Checking Logic#
Permission Helper (lib/permissions.tsClick to copy)#
lib/permissions.tsClick to copy)#import { NextRequest, NextResponse } from 'next/server';
import { auth } from './auth';
import { User } from '@/models/User';
import { Role } from '@/models/Role';
/**
* Check if user has permission for a specific resource and action
* @param permissions - User's permission object
* @param resource - What they want to access (users, properties, etc.)
* @param action - What they want to do (view, edit, delete)
*/
export function hasPermission(
permissions: Record<string, any>,
resource: string,
action: string = 'view'
): boolean {
// Example permissions structure:
// {
// users: { view: true, edit: false, delete: false },
// properties: { view: true, edit: true, delete: false }
// }
if (!permissions) return false;
const resourcePerms = permissions[resource];
if (!resourcePerms) return false;
return resourcePerms[action] === true;
}
/**
* Middleware to check permissions in API routes
*/
export async function requirePermission(
request: NextRequest,
resource: string,
action: string = 'view'
) {
try {
// 1. Get user session
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
return {
error: NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
};
}
// 2. Admins get full access automatically
if (session.user.role === 'admin') {
return {
session,
user: session.user,
isAdmin: true
};
}
// 3. Fetch complete user data with custom role
const user = await User.findById(session.user.id);
if (!user) {
return {
error: NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
};
}
// 4. Check if user has a custom role assigned
if (!user.customRole) {
return {
error: NextResponse.json(
{ error: 'No role assigned' },
{ status: 403 }
)
};
}
// 5. Get role permissions
const role = await Role.findById(user.customRole);
if (!role) {
return {
error: NextResponse.json(
{ error: 'Role not found' },
{ status: 403 }
)
};
}
// 6. Check specific permission
const allowed = hasPermission(role.permissions, resource, action);
if (!allowed) {
return {
error: NextResponse.json(
{ error: 'Permission denied' },
{ status: 403 }
)
};
}
return {
session,
user: user,
permissions: role.permissions,
role: role
};
} catch (error) {
console.error('Permission check error:', error);
return {
error: NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
};
}
}
Step 4: API Routes with RBAC Protection#
Protected User API (app/api/admin/users/route.tsClick to copy)#
app/api/admin/users/route.tsClick to copy)#import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/permissions';
import { User } from '@/models/User';
// GET /api/admin/users - List all users
export async function GET(request: NextRequest) {
// Check if user has permission to VIEW users
const { error, user, isAdmin } = await requirePermission(
request,
'users',
'view'
);
if (error) return error;
try {
// Fetch users from database
const users = await User.find({})
.select('-password') // Don't return passwords
.populate('customRole', 'name');
return NextResponse.json({
success: true,
data: users,
count: users.length,
yourRole: user.role
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
}
// POST /api/admin/users - Create new user (admin only)
export async function POST(request: NextRequest) {
// Check if user has permission to EDIT users
const { error, isAdmin } = await requirePermission(
request,
'users',
'edit'
);
if (error) return error;
try {
const data = await request.json();
// Create user
const user = await User.create({
email: data.email,
name: data.name,
role: data.role || 'user',
customRole: data.customRole
});
return NextResponse.json({
success: true,
message: 'User created successfully',
data: user
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
);
}
}
Role Management API (app/api/admin/roles/route.tsClick to copy)#
app/api/admin/roles/route.tsClick to copy)#import { NextRequest, NextResponse } from 'next/server';
import { Role } from '@/models/Role';
import { requireAdmin } from '@/lib/auth-guard';
// GET /api/admin/roles - List all roles
export async function GET(request: NextRequest) {
// Only admins can manage roles
const { error } = await requireAdmin(request);
if (error) return error;
try {
const roles = await Role.find({});
return NextResponse.json({
success: true,
data: roles
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch roles' },
{ status: 500 }
);
}
}
// POST /api/admin/roles - Create new role
export async function POST(request: NextRequest) {
const { error, session } = await requireAdmin(request);
if (error) return error;
try {
const data = await request.json();
// Create new role
const role = await Role.create({
name: data.name,
description: data.description,
permissions: data.permissions,
createdBy: session.user.id,
predefined: false // Custom roles are not predefined
});
return NextResponse.json({
success: true,
message: 'Role created successfully',
data: role
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create role' },
{ status: 500 }
);
}
}
Step 5: Invitation System#
Send Invitation (app/api/admin/invite/route.tsClick to copy)#
app/api/admin/invite/route.tsClick to copy)#import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { Invitation } from '@/models/Invitation';
import { requireAdmin } from '@/lib/auth-guard';
import { sendEmail } from '@/lib/email';
export async function POST(request: NextRequest) {
// Only admins can send invitations
const { error, session } = await requireAdmin(request);
if (error) return error;
try {
const { email, roleId } = await request.json();
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
// Create invitation (expires in 7 days)
const invitation = await Invitation.create({
email,
token,
roleId,
invitedBy: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
// Send email
const inviteLink = `${process.env.NEXT_PUBLIC_APP_URL}/accept-invite?token=${token}`;
await sendEmail({
to: email,
subject: 'You are invited to join our platform',
html: `
<h2>You've been invited!</h2>
<p>Click the link below to accept your invitation:</p>
<a href="${inviteLink}">Accept Invitation</a>
<p>This link expires in 7 days.</p>
`
});
return NextResponse.json({
success: true,
message: 'Invitation sent successfully'
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to send invitation' },
{ status: 500 }
);
}
}
Accept Invitation (app/api/invite/accept/route.tsClick to copy)#
app/api/invite/accept/route.tsClick to copy)#import { NextRequest, NextResponse } from 'next/server';
import { Invitation } from '@/models/Invitation';
import { User } from '@/models/User';
import { auth } from '@/lib/auth';
// GET - Verify invitation
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Invitation token required' },
{ status: 400 }
);
}
try {
// Find valid invitation
const invitation = await Invitation.findOne({
token,
status: 'pending',
expiresAt: { $gt: new Date() }
});
if (!invitation) {
return NextResponse.json(
{ error: 'Invalid or expired invitation' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
email: invitation.email,
valid: true,
expiresAt: invitation.expiresAt
}
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to verify invitation' },
{ status: 500 }
);
}
}
// POST - Accept invitation and create account
export async function POST(request: NextRequest) {
try {
const { token, name, password } = await request.json();
// Verify invitation again
const invitation = await Invitation.findOne({
token,
status: 'pending',
expiresAt: { $gt: new Date() }
});
if (!invitation) {
return NextResponse.json(
{ error: 'Invalid or expired invitation' },
{ status: 404 }
);
}
// Create user account using Better Auth
const authResponse = await auth.api.signUpEmail({
body: {
email: invitation.email,
name,
password
}
});
if (!authResponse.user) {
throw new Error('Failed to create user');
}
// Update user with role from invitation
await User.findByIdAndUpdate(authResponse.user.id, {
customRole: invitation.roleId
});
// Mark invitation as accepted
invitation.status = 'accepted';
await invitation.save();
return NextResponse.json({
success: true,
message: 'Account created successfully',
data: {
userId: authResponse.user.id,
email: invitation.email
}
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to accept invitation' },
{ status: 500 }
);
}
}
Step 6: Frontend Components#
Role Manager Component (components/admin/RoleManager.tsxClick to copy)#
components/admin/RoleManager.tsxClick to copy)#'use client';
import { useState, useEffect } from 'react';
import { Role } from '@/types';
export default function RoleManager() {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
// Fetch all roles
const fetchRoles = async () => {
try {
const response = await fetch('/api/admin/roles');
const data = await response.json();
if (data.success) {
setRoles(data.data);
}
} catch (error) {
console.error('Failed to fetch roles:', error);
} finally {
setLoading(false);
}
};
// Create new role
const createRole = async (name: string, permissions: any) => {
try {
const response = await fetch('/api/admin/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, permissions })
});
const data = await response.json();
if (data.success) {
fetchRoles(); // Refresh list
return data.data;
}
} catch (error) {
console.error('Failed to create role:', error);
}
};
// Update role
const updateRole = async (id: string, updates: Partial<Role>) => {
try {
const response = await fetch('/api/admin/roles', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...updates })
});
const data = await response.json();
if (data.success) {
fetchRoles(); // Refresh list
}
} catch (error) {
console.error('Failed to update role:', error);
}
};
// Delete role
const deleteRole = async (id: string) => {
if (!confirm('Are you sure you want to delete this role?')) return;
try {
const response = await fetch('/api/admin/roles', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await response.json();
if (data.success) {
fetchRoles(); // Refresh list
}
} catch (error) {
console.error('Failed to delete role:', error);
}
};
useEffect(() => {
fetchRoles();
}, []);
if (loading) return <div>Loading roles...</div>;
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Role Management</h1>
{/* Create Role Form */}
<div className="mb-8 p-4 bg-gray-50 rounded-lg">
<h2 className="text-lg font-semibold mb-4">Create New Role</h2>
{/* Form fields for role creation */}
</div>
{/* Roles List */}
<div className="space-y-4">
{roles.map((role) => (
<div key={role._id} className="p-4 border rounded-lg">
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{role.name}</h3>
{role.description && (
<p className="text-gray-600">{role.description}</p>
)}
</div>
<div className="space-x-2">
<button
onClick={() => {/* Edit modal */}}
className="px-3 py-1 bg-blue-500 text-white rounded"
>
Edit
</button>
{!role.predefined && (
<button
onClick={() => deleteRole(role._id)}
className="px-3 py-1 bg-red-500 text-white rounded"
>
Delete
</button>
)}
</div>
</div>
{/* Permission Summary */}
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(role.permissions).map(([resource, perms]) => (
<div key={resource} className="text-sm">
<span className="font-medium capitalize">{resource}:</span>
{Object.entries(perms).map(([action, allowed]) => (
allowed && (
<span key={action} className="ml-2 px-1 bg-green-100 rounded">
{action}
</span>
)
))}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
Permission Matrix Component (components/admin/PermissionMatrix.tsxClick to copy)#
components/admin/PermissionMatrix.tsxClick to copy)#'use client';
import { useState } from 'react';
const RESOURCES = [
{ key: 'users', label: 'User Management' },
{ key: 'properties', label: 'Properties' },
{ key: 'services', label: 'Services' },
{ key: 'blogs', label: 'Blogs' },
{ key: 'newsletter', label: 'Newsletter' },
{ key: 'chatLeads', label: 'Chat Leads' },
{ key: 'addProperty', label: 'Add Property' },
{ key: 'addService', label: 'Add Service' }
];
const ACTIONS = [
{ key: 'view', label: 'View' },
{ key: 'edit', label: 'Edit' },
{ key: 'delete', label: 'Delete' },
{ key: 'approve', label: 'Approve' }
];
interface PermissionMatrixProps {
permissions: Record<string, any>;
onChange: (permissions: Record<string, any>) => void;
}
export default function PermissionMatrix({ permissions, onChange }: PermissionMatrixProps) {
// Initialize permissions if empty
const [currentPermissions, setCurrentPermissions] = useState(() => {
const defaultPerms: Record<string, any> = {};
RESOURCES.forEach(resource => {
defaultPerms[resource.key] = {};
ACTIONS.forEach(action => {
defaultPerms[resource.key][action.key] =
permissions?.[resource.key]?.[action.key] || false;
});
});
return defaultPerms;
});
const handleToggle = (resource: string, action: string, value: boolean) => {
const newPermissions = {
...currentPermissions,
[resource]: {
...currentPermissions[resource],
[action]: value
}
};
setCurrentPermissions(newPermissions);
onChange(newPermissions);
};
const toggleAll = (resource: string, value: boolean) => {
const newPermissions = {
...currentPermissions,
[resource]: {}
};
ACTIONS.forEach(action => {
newPermissions[resource][action.key] = value;
});
setCurrentPermissions(newPermissions);
onChange(newPermissions);
};
return (
<div className="overflow-x-auto">
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-50">
<th className="py-3 px-4 text-left font-semibold">Resource</th>
<th className="py-3 px-4 text-center font-semibold">All</th>
{ACTIONS.map(action => (
<th key={action.key} className="py-3 px-4 text-center font-semibold">
{action.label}
</th>
))}
</tr>
</thead>
<tbody>
{RESOURCES.map(resource => (
<tr key={resource.key} className="border-t">
<td className="py-3 px-4 font-medium">
{resource.label}
</td>
{/* Toggle All for this resource */}
<td className="py-3 px-4 text-center">
<input
type="checkbox"
checked={ACTIONS.every(action =>
currentPermissions[resource.key]?.[action.key]
)}
onChange={(e) => toggleAll(resource.key, e.target.checked)}
className="h-4 w-4"
/>
</td>
{/* Individual permission checkboxes */}
{ACTIONS.map(action => (
<td key={action.key} className="py-3 px-4 text-center">
<input
type="checkbox"
checked={currentPermissions[resource.key]?.[action.key] || false}
onChange={(e) =>
handleToggle(resource.key, action.key, e.target.checked)
}
className="h-4 w-4"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
π¨ Real-World Flow Diagrams#
1. User Login & Permission Check#
βββββββββββββββ 1. Login Form βββββββββββββββ
β User βββββββββββββββββββββββΊ Frontend β
βββββββββββββββ ββββββββ¬βββββββ
β 2. Submit credentials
βΌ
βββββββββββββββ 3. Verify βββββββββββββββ
β Database ββββββββββββββββββββββ€ Auth API β
ββββββββ¬βββββββ ββββββββ¬βββββββ
β β 4. Create session
β βΌ
β βββββββββββββββββββ
β β Set session β
β β cookie in browserβ
β ββββββββββ¬βββββββββ
β β
β 5. Fetch user data β
ββββββββββββββββββββββββββββββββββββ
β
6. Redirect to dashboard
β
βΌ
βββββββββββββββ βββββββββββββββ
β Dashboard ββββββββββββββββββββββ€ Middleware β
βββββββββββββββ 7. Check role βββββββββββββββ
β
8. Load components based on permissions
2. Invitation Flow#
sequenceDiagram
participant Admin
participant API
participant Database
participant EmailService
participant NewUser
Admin->>API: Send invitation (email + role)
API->>Database: Create invitation record
API->>EmailService: Send email with link
EmailService->>NewUser: Receive email
NewUser->>API: Click invite link
API->>Database: Validate token
API->>NewUser: Show acceptance form
NewUser->>API: Submit registration
API->>Database: Create user account
API->>Database: Assign role from invitation
API->>Database: Mark invitation as accepted
API->>NewUser: Account created successfully
3. Permission Check in Action#
// Scenario: User tries to edit a property
// 1. User clicks "Edit Property" button
const handleEditProperty = async () => {
// 2. Frontend checks if allowed (optional - for better UX)
const canEdit = await checkPermission('properties', 'edit');
if (!canEdit) {
alert('You need permission to edit properties');
return;
}
// 3. API call to server
const response = await fetch('/api/properties/123', {
method: 'PUT',
body: JSON.stringify({ /* updates */ })
});
// 4. Server checks permission again
// (Always verify on server - never trust client!)
if (response.status === 403) {
alert('Permission denied by server');
}
};
// Server-side check in API route
export async function PUT(request: NextRequest, { params }) {
const { error } = await requirePermission(request, 'properties', 'edit');
if (error) return error;
// User has permission - process the request
// ...
}
β οΈ Common Pitfalls & Solutions#
Pitfall 1: Trusting Client-Side Checks Only#
// β WRONG - Client-side only
function deleteUser(userId) {
if (userRole === 'admin') {
// Delete user - but hackers can bypass this!
}
}
// β
CORRECT - Always verify server-side
async function deleteUser(userId) {
// Client-side check for UX
if (userRole !== 'admin') {
alert('Need admin access');
return;
}
// Server-side check for security
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
// Server will reject if not admin
if (response.status === 403) {
alert('Server denied permission');
}
}
Pitfall 2: Hardcoding Permissions#
// β WRONG - Hardcoded
function canEditBlog(user) {
return user.role === 'admin' || user.role === 'editor';
}
// β
CORRECT - Dynamic from database
async function canEditBlog(userId) {
const user = await User.findById(userId).populate('customRole');
const role = user.customRole;
return role?.permissions?.blogs?.edit === true;
}
Pitfall 3: Forgetting Default Deny#
// β WRONG - Might allow access by mistake
function checkAccess(user, resource) {
if (user.role === 'admin') return true;
// What if user has no custom role? Returns undefined!
}
// β
CORRECT - Explicit default deny
function checkAccess(user, resource, action) {
if (user.role === 'admin') return true;
if (!user.customRole) return false; // Explicit deny
const perms = user.customRole.permissions;
if (!perms || !perms[resource]) return false;
return perms[resource][action] === true;
}
π§ͺ Testing Your RBAC System#
Test Cases to Verify#
// Test file: tests/rbac.test.js
describe('RBAC System', () => {
test('Admin has full access', async () => {
const adminUser = await createUser({ role: 'admin' });
expect(await canAccess(adminUser, 'users', 'delete')).toBe(true);
expect(await canAccess(adminUser, 'properties', 'edit')).toBe(true);
// All resources and actions should return true
});
test('User with custom role has specific access', async () => {
const role = await createRole({
name: 'Content Manager',
permissions: {
blogs: { view: true, edit: true, delete: false },
users: { view: false, edit: false }
}
});
const user = await createUser({
role: 'user',
customRole: role._id
});
expect(await canAccess(user, 'blogs', 'edit')).toBe(true);
expect(await canAccess(user, 'blogs', 'delete')).toBe(false);
expect(await canAccess(user, 'users', 'view')).toBe(false);
});
test('User without role has no access', async () => {
const user = await createUser({ role: 'user' });
// No customRole assigned
expect(await canAccess(user, 'blogs', 'view')).toBe(false);
expect(await canAccess(user, 'properties', 'view')).toBe(false);
});
test('Invitation assigns correct role', async () => {
const role = await createRole({
name: 'Invited Role',
permissions: { blogs: { view: true } }
});
const invitation = await sendInvitation({
email: 'test@example.com',
roleId: role._id
});
const newUser = await acceptInvitation(invitation.token, {
name: 'Test User',
password: 'password123'
});
expect(newUser.customRole.toString()).toBe(role._id.toString());
expect(await canAccess(newUser, 'blogs', 'view')).toBe(true);
});
});
Quick Checklist#
[ ] Admin users bypass all permission checks
[ ] Custom role permissions are checked correctly
[ ] Users without roles are denied access
[ ] Permission changes take effect immediately
[ ] Invitation links expire after set time
[ ] Audit logs record all role changes
[ ] Default admin cannot be modified/deleted
[ ] UI hides unauthorized actions
π Deployment Checklist#
Before Going Live:#
Create Default Roles:
123456789101112131415// Run this script once const defaultRoles = [ { name: 'Administrator', predefined: true, permissions: { /* All permissions true */ } }, { name: 'Content Manager', predefined: false, permissions: { blogs: { view: true, edit: true } } } ];Set Default Admin:
1234# Set environment variable DEFAULT_ADMIN_EMAIL=admin@yourdomain.comConfigure Email Service:
123456SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your-email@gmail.com SMTP_PASS=your-passwordSet Session Security:
12345678session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // Refresh daily sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }
Monitoring:#
Log all permission denied events
Track role assignment frequency
Monitor invitation acceptance rate
Alert on multiple failed permission checks
π Summary: Key Takeaways#
What We Built:#
Flexible Role System - Base roles + custom roles
Granular Permissions - Resource Γ Action matrix
Secure Invitations - Token-based with expiration
Approval Workflow - For sensitive role assignments
Complete Audit Trail - Track all changes
Protected Admin - Cannot be modified/deleted
Core Files You Need:#
/models
βββ User.ts # User with role fields
βββ Role.ts # Role with permissions
βββ Invitation.ts # Invitation system
/lib
βββ auth.ts # Authentication setup
βββ permissions.ts # Permission checking logic
βββ auth-guard.ts # Route protection helpers
/app/api
βββ admin/users/ # User management
βββ admin/roles/ # Role management
βββ invite/ # Invitation handling
/components/admin
βββ RoleManager.tsx # Role CRUD interface
βββ PermissionMatrix.tsx # Visual permission editor
Remember:#
β Always verify permissions server-side
β Default to deny access
β Admins bypass all checks
β Log everything for auditing
β Test all permission scenarios
β Keep UI in sync with permissions
π Need Help?#
Common Questions:#
Q: How do I add a new resource?
// 1. Add to Role model
const roleSchema = new mongoose.Schema({
permissions: {
// ... existing resources
newResource: permissionSchema // Add this
}
});
// 2. Update frontend matrix
const RESOURCES = [
// ... existing resources
{ key: 'newResource', label: 'New Resource' }
];
// 3. Use in API routes
const { error } = await requirePermission(request, 'newResource', 'edit');
Q: How to handle hierarchical permissions?
// Example: If you can edit, you can also view
function hasPermission(permissions, resource, action) {
const resourcePerms = permissions[resource];
if (!resourcePerms) return false;
// Hierarchy: edit implies view
if (action === 'view' && resourcePerms.edit === true) {
return true;
}
return resourcePerms[action] === true;
}
Q: How to cache permissions for performance?
// Cache user permissions in session or Redis
const cachedPermissions = await redis.get(`user:${userId}:permissions`);
if (!cachedPermissions) {
const user = await User.findById(userId).populate('customRole');
const permissions = user.customRole?.permissions || {};
await redis.setex(`user:${userId}:permissions`, 3600, JSON.stringify(permissions));
return permissions;
}
return JSON.parse(cachedPermissions);
π Congratulations! You now have a complete, production-ready RBAC system. Remember:
Start Simple - Use base roles first
Add Custom Roles as needed
Test Thoroughly - Security is critical
Monitor Usage - Adjust permissions based on needs
Document Everything - Keep your team informed
Your RBAC system will grow with your application. Start with what you need now, and expand as requirements change
