Authentication tells you who someone is. Authorization decides what they're allowed to do once they're in — and that gets messy fast if adding a new role means shipping a code change. Here's an RBAC setup that avoids that.
The Permission Model
At the foundation of RBAC are three entities: Users, Roles, and Permissions. The relationships are:
- A User is assigned one or more Roles.
- A Role contains a set of Permissions.
- A Permission represents a granular action (e.g.,
users:read,orders:delete).
Schema Design (MongoDB)
// Permission embedded in Role for fast lookups
const RoleSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true },
description: { type: String },
permissions: [{ type: String }], // e.g., ['users:read', 'users:write', 'orders:read']
});
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
passwordHash: { type: String, required: true },
roles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Role' }],
});
Embedding permissions as strings in the Role document means a single populate() call gives you everything needed to authorize a request — no joins, no extra queries.
Bitmask Approach for High Performance
For systems with a fixed, manageable set of permissions, bitmask encoding delivers the fastest possible permission checks:
const Permissions = {
USERS_READ: 1 << 0, // 1
USERS_WRITE: 1 << 1, // 2
USERS_DELETE: 1 << 2, // 4
ORDERS_READ: 1 << 3, // 8
ORDERS_WRITE: 1 << 4, // 16
ORDERS_DELETE: 1 << 5, // 32
ADMIN_PANEL: 1 << 6, // 64
} as const;
// Define roles as bitmask sums
const ROLES = {
viewer: Permissions.USERS_READ | Permissions.ORDERS_READ, // 9
editor: Permissions.USERS_READ | Permissions.USERS_WRITE |
Permissions.ORDERS_READ | Permissions.ORDERS_WRITE, // 27
admin: Object.values(Permissions).reduce((a, b) => a | b, 0), // 127
};
Permission check is a single bitwise AND — executed in nanoseconds:
function hasPermission(roleMask: number, permission: number): boolean {
return (roleMask & permission) === permission;
}
hasPermission(ROLES.editor, Permissions.ORDERS_WRITE); // true
hasPermission(ROLES.viewer, Permissions.USERS_DELETE); // false
Authorization Middleware
Create a reusable middleware factory that accepts required permissions:
import { Request, Response, NextFunction } from 'express';
export function authorize(...requiredPermissions: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) return res.status(401).json({ error: 'Authentication required' });
// Gather all permissions from user's roles
const userPermissions = new Set(
user.roles.flatMap((role: any) => role.permissions)
);
const hasAll = requiredPermissions.every((p) => userPermissions.has(p));
if (!hasAll) {
return res.status(403).json({
error: 'Forbidden',
required: requiredPermissions,
});
}
next();
};
}
Usage on routes:
router.get('/users', authorize('users:read'), usersController.list);
router.delete('/users/:id', authorize('users:delete'), usersController.remove);
router.post('/orders', authorize('orders:write'), ordersController.create);
Hierarchical Roles
In many organizations, a Manager should inherit all permissions of a Viewer, and an Admin should inherit all permissions of a Manager. Model this with a parent reference:
const RoleSchema = new mongoose.Schema({
name: { type: String, required: true },
permissions: [{ type: String }],
parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Role', default: null },
});
async function getEffectivePermissions(roleId: string): Promise<string[]> {
const role = await Role.findById(roleId).populate('parent');
let permissions = [...role.permissions];
if (role.parent) {
const parentPerms = await getEffectivePermissions(role.parent._id);
permissions = [...new Set([...permissions, ...parentPerms])];
}
return permissions;
}
Cache the resolved permission set in Redis so hierarchical lookups do not hit the database on every request.
Caching Role Data
Roles change infrequently, making them ideal cache candidates:
async function getUserPermissions(userId: string): Promise<string[]> {
const cacheKey = `perms:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await User.findById(userId).populate('roles');
const permissions = user.roles.flatMap((r: any) => r.permissions);
await redis.set(cacheKey, JSON.stringify(permissions), 'EX', 3600);
return permissions;
}
Invalidate the cache whenever a role is updated or a user's role assignment changes.
Wrapping Up
String-based permissions if you want flexibility, bitmasks if you want speed — most apps never need both. Either way, cache the resolved permission set; roles change rarely but get checked on nearly every request.
