Implementing RBAC (Role-Based Access Control) for Web Applications
A user logs in and sees exactly what they're authorized to see—no more, no less. Sounds trivial until you're counting: 12 user types, 40 interface sections, a permissions matrix on an A3 sheet you need to maintain in code. RBAC is the standard answer: rights are assigned not to users directly but to roles, and users receive roles.
Data Model
Minimal schema for PostgreSQL:
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE, -- 'admin', 'editor', 'viewer'
description TEXT
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
resource VARCHAR(128) NOT NULL, -- 'articles', 'users', 'reports'
action VARCHAR(64) NOT NULL, -- 'create', 'read', 'update', 'delete', 'publish'
UNIQUE (resource, action)
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
This is classic RBAC0 implementation. For hierarchical roles (admin inherits all editor permissions), add role_hierarchy table with parent_role_id / child_role_id and use recursive CTE for checks.
Permission Checking on Backend
Node.js + Express, middleware approach:
// permissions.js—load permissions from DB on startup or cache in Redis
async function loadUserPermissions(userId) {
const rows = await db.query(`
SELECT DISTINCT p.resource, p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1
`, [userId]);
return new Set(rows.map(r => `${r.resource}:${r.action}`));
}
// middleware/can.js
function can(resource, action) {
return async (req, res, next) => {
const perms = await loadUserPermissions(req.user.id);
if (perms.has(`${resource}:${action}`)) {
return next();
}
res.status(403).json({ error: 'Forbidden' });
};
}
// routes
router.delete('/articles/:id', authenticate, can('articles', 'delete'), deleteArticle);
router.post('/articles', authenticate, can('articles', 'create'), createArticle);
For PHP/Laravel the pattern is similar—Gate and Policy:
// AuthServiceProvider
Gate::before(function (User $user, string $ability) {
if ($user->hasRole('superadmin')) {
return true; // superadmin bypasses all checks
}
});
Gate::define('articles.delete', function (User $user) {
return $user->hasPermission('articles', 'delete');
});
// In controller
public function destroy(Article $article)
{
$this->authorize('articles.delete');
$article->delete();
return response()->noContent();
}
The hasPermission method makes one query with JOIN or gets from cache—depends on load.
Caching Permission Matrix
Running a three-table JOIN on every HTTP request is wasteful. User permissions change rarely—this caches well:
// redis cache, TTL 5 minutes
async function getUserPermissions(userId) {
const cacheKey = `user_perms:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return new Set(JSON.parse(cached));
const perms = await loadUserPermissions(userId);
await redis.setex(cacheKey, 300, JSON.stringify([...perms]));
return perms;
}
// Invalidate when user roles change
async function assignRole(userId, roleId) {
await db.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, roleId]
);
await redis.del(`user_perms:${userId}`);
}
Role Management in UI
Administrators need to see and edit the matrix. Minimal API:
GET /api/roles — list roles
POST /api/roles — create role
GET /api/roles/:id/permissions — role permissions
PUT /api/roles/:id/permissions — update role permissions (array of permission_ids)
GET /api/users/:id/roles — user roles
POST /api/users/:id/roles — assign role
DELETE /api/users/:id/roles/:roleId — unassign role
Frontend—table with checkboxes resource × action—standard UI for this.
What Affects Timeline
Basic implementation (3–5 roles, no inheritance, no role management UI)—2–3 days. Schema, middleware, integration with existing auth, tests.
Add role/permission management UI—add 2 days. Hierarchical roles or multi-tenancy (roles isolated per organization)—add 2–3 days for schema and logic complexity.







