In modern web applications, controlling what users can and cannot do is crucial for security and user experience. Whether you're building a SaaS platform, an internal dashboard, or an e-commerce site, you need a robust way to manage user permissions.
This is where Role-Based Access Control (RBAC) comes in. RBAC ensures that users only have access to the features and data relevant to their role. However, implementing RBAC efficiently can be tricky, especially when dealing with complex permission structures.
There are so many existing packages both paid and free that solves the problem of Role-Based Access Control (RBAC), examples like casbin, CASL, auth0, permit.io among others. Amongst all this, I enjoy using CASL because of it's simplicity and robust architecture.
According to the documentation;
CASL (pronounced /ˈkæsəl/, like castle) is an isomorphic authorization JavaScript library which restricts what resources a given client is allowed to access.
In this blog, we'll dive deep into CASL, covering:
- What CASL is and why it's useful
- How to set up CASL in a Node.js application
- Defining permissions dynamically based on user roles
- Persisting roles to an external database
Let's get started.
What is CASL?
CASL is a powerful and flexible JavaScript library for managing authorization in applications. It allows developers to define and enforce fine-grained permissions based on user roles, actions, and resource conditions.
Unlike traditional Role-Based Access Control (RBAC), where permissions are assigned to roles in a static way, CASL implements Attribute-Based Access Control (ABAC), meaning permissions can depend on dynamic conditions, such as who owns a resource or specific field values.
Why CASL is useful
✅ Role-Based Access Control (RBAC) – Assigning permissions based on user roles (e.g., Admin, Editor, User).
✅ Fine-Grained Permissions – Controlling access at a more detailed level, like restricting updates only to a resource owner.
✅ Frontend & Backend Authorization – CASL works seamlessly in both the client (React, Vue, Angular) and server (Node.js, NestJS, etc.).
✅ Condition-Based Access – Example: A user can only edit their own posts but admins can edit all posts.
✅ Declarative & Composable Permissions – Permissions are defined in a structured way, making them easy to maintain.
How to set up CASL in a Node.js and Express application
First, we would need to set up a simple express app by running the commands below
npm init
npm install express jsonwebtoken mongoose @casl/ability dotenv
Configure Environment Variables (.env)
PORT=8080
MONGO_URI=mongodb://localhost:27017/rbac-demo-database
Create app.js (Express App & Middleware Setup)
const express = require("express");
const mongoose = require("mongoose");
const dotenv = require("dotenv").config();
const app = express();
function connectToDatabase() {
return new Promise((resolve, reject) => {
mongoose
.connect(process.env.MONGO_URI, {
})
.then(async () => {
console.log("Connected to Database!!!");
resolve();
})
.catch((error) => {
console.error("Failed to connect to Database:", error.message);
});
});
}
connectToDatabase();
app.use(express.json());
app.get("/", (req, res) => {
res.send("Server is running!");
});
module.exports = app;
Create server.js (Database Connection & Server Setup)
const http = require('http');
const app = require('./app');
const server = http.createServer(app);
const port = process.env.PORT || '8080';
app.set('port', port);
const errorHandler = error => {
if (error.syscall !== 'listen') {
throw error;
}
const address = server.address();
const bind = typeof address === 'string' ? 'pipe ' + address : 'port: ' + port;
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges.');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use.');
process.exit(1);
break;
default:
throw error;
}
};
server.on('error', errorHandler);
server.on('listening', () => {
const address = server.address();
const bind = typeof address === 'string' ? 'pipe ' + address : 'port ' + port;
console.log('Listening on ' + bind);
});
server.listen(port);
In this demo, we will build a Todo application that implements a Role Based System using CASL. The application will allow users to create, update, delete, and read todos while ensuring that authorization rules are properly enforced.
We need some basic setup such as population of the users in the database, creation of the user and role schema.
Requirements
We will implement the following features:
- A user can create and read todos.
- A user can update, delete todos that belong to them.
- An admin has full access to all todos, with permission to create, read, update, and delete any todo.
Before implementing the core functionality, we need to establish some foundational elements, including:
User and Role Schema – Defining user roles and permissions.
Populating the Database – Seeding the database with sample users.
User Schema
const mongoose = require('mongoose');
const userSchema = mongoose.Schema({
name: {
type: String,
required: true
},
role: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role'
}
})
module.exports = mongoose.model('User', userSchema);
Role Schema
const mongoose = require('mongoose');
const roleSchema = mongoose.Schema({
name: {
type: String,
required: true
},
permissions: {
type: String,
default: ''
},
})
module.exports = mongoose.model('Role', roleSchema);
Then we need 2 functions to populate the database with users and corresponding roles
const User = require("../models/User");
const Role = require("../models/Role");
const permissionsJson = require("./permissions.json");
const populateUsers = async (users) => {
const dbUsers = await User.find({});
if (dbUsers.length > 0) {
return;
}
const userRole = await Role.findOne({ name: "user" });
const adminRole = await Role.findOne({ name: "admin" });
for (let user of users) {
if (user.name.startsWith("Admin")) {
user.role = adminRole._id;
} else {
user.role = userRole._id;
}
await User.create(user);
}
console.log("All users created successfully!!!")
}
const populateRoles = async (roles) => {
const dbRoles = await Role.find({});
if (dbRoles.length > 0) {
return;
}
for (let role of roles) {
role.permissions = JSON.stringify(permissionsJson[role.name]);
await Role.create(role);
}
console.log("All roles created successfully!!!")
}
module.exports = {
populateUsers,
populateRoles
};
Then we call the functions here
function connectToDatabase() {
return new Promise((resolve, reject) => {
mongoose
.connect(process.env.MONGO_URI, {
})
.then(async () => {
console.log("Connected to Database!!!");
// functions called here
await require("./lib/utils").populateRoles([
{ name: "admin" },
{ name: "user" },
]);
await require("./lib/utils").populateUsers([
{ name: "Admin" },
{ name: "User 1" },
{ name: "User 2" },
{ name: "User 3" },
]);
resolve();
})
.catch((error) => {
console.error("Failed to connect to Database:", error.message);
});
});
}
Now we need to define our routes and our controllers
// routes/auth.js
const express = require('express');
const authController = require('../controller/auth');
const router = express.Router();
router.post('/login', authController.login);
module.exports = router;
// routes/todo.js
const express = require('express');
const todoController = require('../controllers/todo');
const router = express.Router();
router.get('/', todoController.getTodos);
router.post('/', todoController.createTodo);
router.patch('/:id', todoController.updateTodo);
router.delete('/:id', todoController.deleteTodo);
router.patch('/share/:id', todoController.shareTodo);
module.exports = router;
// controllers/auth.js
const User = require('../models/User');
exports.login = async (req, res) => {
try {
const name = req.body.name;
if (!name) {
return res.status(400).json({ message: 'Name is required' });
}
const user = await User.findOne({ name: name });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ message: 'User found', user: user });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
Next, we define the permissions for our Todo app, specifying the actions that can be performed on "Todo" items based on the user's role (either user or admin). These permission rules will be used to control what actions each type of user can take, ensuring proper access control and maintaining the security of the application.
Here is the JSON permission structure and a detailed breakdown:
{
"user": [
{
"action": "create",
"subject": "Todo"
},
{
"action": "read",
"subject": "Todo"
},
{
"action": "update",
"subject": "Todo",
"conditions": {"createdBy": "{{_id}}" }
},
{
"action": "delete",
"subject": "Todo",
"conditions": {
"createdBy": "{{_id}}"
}
}
],
"admin": [
{
"action": "manage",
"subject": "all"
}
]
}
Next, we will rewrite the login controller to ensure that when a user logs in, their role and corresponding permissions are properly attached to their user data. The Mustache package will be used to interpolate the permissions templates dynamically
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const Mustache = require('mustache');
exports.login = async (req, res) => {
try {
const name = req.body.name;
if (!name) {
return res.status(400).json({ message: 'Name is required' });
}
const user = await User.findOne({ name: name }).populate("role");
user.role.permissions = Mustache.render(user.role.permissions, user);
const token = jwt.sign({ name: user.name }, "SECRET", { expiresIn: '1h' });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ message: 'User found', user: user, accessToken: token });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
Next we create the authentication middleware (authMiddleware), which extracts a user's permissions from their assigned role and creates an ability instance for them. This ability instance is then attached to the req object, making it available for access control checks throughout the request lifecycle.
// middlewares/auth
const { createMongoAbility } = require('@casl/ability');
const User = require('../models/User');
const Mustache = require('mustache');
const jwt = require('jsonwebtoken');
const authMiddleware = async (req, res, next) => {
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, "SECRET");
const user = await User.findOne({name: decoded.name }).populate("role");
if (!user) {
return res.status(401).json({ message: 'Unauthorized' });
}
user.role.permissions = Mustache.render(user.role.permissions, user);
// Create the user's ability based on the permissions
const ability = createMongoAbility(JSON.parse(user.role.permissions));
req.ability = ability;
req.userId = user._id;
next();
} catch (error) {
return res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = authMiddleware;
Todo routes
const express = require('express');
const todoController = require('../controller/todo');
const auth = require('../middlewares/auth');
const router = express.Router();
router.get('/', todoController.getTodos);
router.post('/', auth, todoController.createTodo);
router.patch('/:id', auth, todoController.updateTodo);
router.delete('/:id', auth, todoController.deleteTodo);
module.exports = router;
Todo schema
const mongoose = require('mongoose');
const todoSchema = mongoose.Schema({
title: {
type: String,
required: [true, 'Title is required']
},
completed: {
type: Boolean,
default: false
},
sharedWith: {
type: [String],
default: []
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
}, { timestamps: true });
module.exports = mongoose.model('Todo', todoSchema);
Todo Controller
const Todo = require('../models/Todo');
const { ForbiddenError, subject } = require('@casl/ability');
exports.getTodos = async (req, res) => {
try {
const todos = await Todo.find({});
res.status(200).json({ message: 'Todos found', todos: todos });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
exports.createTodo = async (req, res) => {
try {
const todo = await Todo.create({...req.body, createdBy: req.userId});
res.status(201).json({ message: 'Todo created', todo: todo });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
exports.updateTodo = async (req, res) => {
try {
const todoToUpdate = await Todo.findById(req.params.id);
const canUpdate = req.ability.can('update', subject('Todo', {createdBy: todoToUpdate.createdBy}))
if (!canUpdate) {
return res.status(403).json({message: "Access Denied"})
}
const todo = await Todo.findByIdAndUpdate(req.params.id, req.body, { new: true });
res.status(200).json({ message: 'Todo updated', todo: todo });
}
catch (error) {
res.status(500).json({ message: error.message });
}
}
exports.deleteTodo = async (req, res) => {
try {
const todoToUpdate = await Todo.findById(req.params.id);
const canDelete = req.ability.can('delete', subject('Todo', {createdBy: todoToUpdate.createdBy}))
if (!canDelete) {
return res.status(403).json({message: "Access Denied"})
}
await Todo.findByIdAndDelete(req.params.id);
res.status(200).json({ message: 'Todo deleted' });
}
catch (error) {
res.status(500).json({ message: error.message });
}
}
The core functionality of this code lies in the following statement:
const canDelete = req.ability.can('delete', subject('Todo', {createdBy: todoToUpdate.createdBy}))
Here, the CASL ability instance (req.ability) is retrieved from the request and used to evaluate whether the current user has permission to delete a specific Todo item. The evaluation is performed based on the user's role and its associated permissions, ensuring that access control rules are consistently enforced across the application.
Wow! That was quite a journey! 🚀 But with everything we've covered, you should now have a solid foundation for building dynamic and flexible role-based access control (RBAC) using CASL.
With this setup, you can easily manage permissions without redeploying your app, ensuring users have just the right access based on their roles. Whether you're handling basic roles like admin and member or creating more complex permission rules, CASL gives you the flexibility to scale as needed.
Now, it's time to take this knowledge and start implementing it in your own projects. Happy coding! 🎉
Top comments (0)