Warming up the neural circuits...
By the end of this chapter you will:
"Is this user allowed to do this?" That question gets asked on every authenticated request. If your answer is
if (user.role === 'admin')scattered across 200 files, you've built a maintenance nightmare. RBAC (Role-Based Access Control) gives you a centralized, auditable, extensible answer.
An employee gets the "Employee" keycard. It opens the main office and break room, but not the server room. A manager's keycard opens everything the employee's does, plus the executive suite. Security's keycard opens everything.
When someone gets promoted from Employee to Manager, you swap their keycard — you don't re-program every door. That's RBAC: change the role, and 50 permissions update automatically.
A clean RBAC schema has five tables (or four if roles have fixed permissions):
-- Users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
-- Roles (can be dynamic: "admin", "editor", "viewer")
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- 'admin', 'editor', 'viewer'
Why separate roles and permissions? Because "admin" today might mean "can delete users + can view analytics + can manage billing." Tomorrow, you might split "admin" into "super_admin" and "billing_admin." With separate permission entities, you change role-permission mappings without touching application code.
// permissions.ts — centralized, one source of truth
type Action = 'create' | 'read' | 'update' | 'delete';
type Resource = 'posts' | 'users' | 'comments' | 'analytics';
Loading permissions from the database on every request is wasteful. Permissions change rarely. them:
const redis = createClient({ url: process.env.REDIS_URL });
async function getUserPermissions(userId: number): Promise<Permission[]> {
const cacheKey = `permissions:${
Not all permissions are global. "Can edit posts" might mean "can edit ANY post" (admin) or "can edit OWN posts" (author). This requires resource-level checks:
async function canEditPost(userId: number, postId: number): Promise<boolean> {
// Global permission: admin can edit all posts
if (await hasPermission(userId, 'posts', 'update')) return
// ❌ God-admin: one role that bypasses all permission checks
if (user.role === 'admin') return next(); // 🚪 Open sesame
// Every new developer sees this and copies it.
// By year 2, admin bypass is scattered across 50 files.
// You can't audit what admins can actually do.Solution: Even admins should go through the permission system. Grant admins every permission explicitly:
INSERT INTO permissions (name, resource, action) VALUES
('posts:create', 'posts', 'create'),
('posts:read', 'posts', 'read'),
('posts:update', 'posts'
Now "what can admins do?" is answerable with one query. Adding a new resource (invoices) means adding permissions and optionally granting them to admin. Nothing is implicit.
Vercel's platform uses a clean RBAC model for team management:
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Hardcoded role strings | if (user.role === 'admin') — 200 copies across the codebase | Centralize: can(user, 'posts', 'delete'). One function, tested once. |
| Role checks without resource ownership | Editor can edit ANY post, not just their own | Add ownership checks: canEditPost(userId, postId) |
| God-admin anti-pattern | Admin bypasses all checks → unauditable, impossible to add granular admin roles | Admins go through the same permission system. Grant all permissions explicitly. |
| Permission check only on the frontend | Hiding a doesn't prevent a curl request | Backend authorization on every endpoint. Frontend hiding is UX, not security. |
| No permission cache | DB query on every request for permissions → 5ms overhead per request | Cache in Redis (5 min TTL). Invalidate on role change. |
| Hard-deleting roles | Deleting a role orphans user_role records or cascades and removes permissions from users unintentionally | Soft-delete roles (is_active = false). Archive, don't destroy. |
false. Never default to true.admin, editor, viewer. Create permissions: create:posts, read:posts, update:posts, delete:posts, manage:users. Assign permissions to roles. Write a query that returns all permissions for a given user.requirePermission(resource, action) that checks if the current user has the required permission.editor can update/delete their own posts but not others'. An admin can update/delete any post. Write the canEditPost function.admin for 24 hours, after which it automatically expires. Use a expires_at column on user_roles.What is RBAC? Role-Based Access Control. Users are assigned roles, roles are assigned permissions. This centralizes access control — changing a role's permissions instantly affects all users with that role.
Why shouldn't you hardcode role checks? If if (user.role === 'admin') is scattered across 200 files, changing what "admin" means requires touching all 200. Centralize into can(user, resource, action) — one function, one source of truth.
What's the difference between a role and a permission? A role is a named collection of permissions ("Editor"). A permission is a specific action on a specific resource ("posts:create", "users:delete"). Users get roles; roles get permissions.
How do you model "user can edit their own posts but not others" in RBAC? RBAC alone isn't enough — this requires resource-level authorization. The editor role grants the posts:update permission. The authorization checker then adds a second check: if the user has the global permission, allow; otherwise, check if the user owns the post. This is sometimes called "RBAC + ownership" or "relationship-based access control."
What is the God-admin anti-pattern and how do you avoid it? A special check like if (user.role === 'admin') return true that bypasses the permission system entirely. Avoid by: granting admin all permissions explicitly, routing admin through the same permission checker as everyone else, and auditing what permissions admin actually has.
How would you handle a user who needs temporary elevated access? Add an expires_at column to user_roles. The permission checker ignores expired role assignments. A background job or scheduled task can clean up expired assignments. Log all temporary grants with justification and approver for audit.
RBAC means users → roles → permissions. Centralize permission logic in one module (can(user, resource, action)). Cache permissions in Redis (5 min TTL). Add resource ownership checks alongside global permissions. Never hardcode role strings. Never use God-admin bypasses. Audit every permission change. Deny by default. The database schema (users → user_roles → roles → role_permissions → permissions) is simple, proven, and scales to millions of users.
can(userId, resource, action) — centralized, cached.resource.ownerId === userId.if (user.role === 'admin') return true — bypasses the permission system entirely, making permissions unauditable.posts:update), then ownership check (post.authorId === userId) if no global permission.is_active = false).SELECT u.email, r.name as role, p.name as permission FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON r.id = ur.role_id JOIN role_permissions rp ON rp.role_id = r.id JOIN permissions p ON p.id = rp.permission_id ORDER BY u.email;