RBAC

Read
RBAC
πŸ“Έ

🎯 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.ts)#

41 lines
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.ts)#

49 lines
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.ts)#

44 lines
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.ts)#

32 lines
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.ts)#

124 lines
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.ts)#

73 lines
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.ts)#

58 lines
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.ts)#

54 lines
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.ts)#

107 lines
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.tsx)#

155 lines
'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.tsx)#

126 lines
'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#

28 lines
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   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#

23 lines
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#

36 lines
// 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#

27 lines
// ❌ 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#

19 lines
// ❌ 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#

61 lines
// 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:#

  1. Create Default Roles:

    // 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 } }
      }
    ];
    
    
  2. Set Default Admin:

    # Set environment variable
    DEFAULT_ADMIN_EMAIL=admin@yourdomain.com
    
    
  3. Configure Email Service:

    SMTP_HOST=smtp.gmail.com
    SMTP_PORT=587
    SMTP_USER=your-email@gmail.com
    SMTP_PASS=your-password
    
    
  4. Set Session Security:

    session: {
      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:#

  1. Flexible Role System - Base roles + custom roles

  2. Granular Permissions - Resource Γ— Action matrix

  3. Secure Invitations - Token-based with expiration

  4. Approval Workflow - For sensitive role assignments

  5. Complete Audit Trail - Track all changes

  6. Protected Admin - Cannot be modified/deleted

Core Files You Need:#

20 lines
/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?

18 lines
// 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:

  1. Start Simple - Use base roles first

  2. Add Custom Roles as needed

  3. Test Thoroughly - Security is critical

  4. Monitor Usage - Adjust permissions based on needs

  5. 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

Back to Articles

Rahul Verma

Full Stack Developer passionate about building modern web applications. Sharing insights on React, Next.js, and web development.

Learn more about me→

Enjoyed this article?

Check out more articles on similar topics in the blog section.

Explore More Articles→