DEV Community

Cover image for Mastering Role-Based Access Control with CASL - Part One
Samuel Oseh
Samuel Oseh

Posted on

Mastering Role-Based Access Control with CASL - Part One

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

Enter fullscreen mode Exit fullscreen mode

Configure Environment Variables (.env)

PORT=8080
MONGO_URI=mongodb://localhost:27017/rbac-demo-database
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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);
        });
    });
}

Enter fullscreen mode Exit fullscreen mode

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 });
    }
}

Enter fullscreen mode Exit fullscreen mode

The core functionality of this code lies in the following statement:

const canDelete = req.ability.can('delete', subject('Todo', {createdBy: todoToUpdate.createdBy}))
Enter fullscreen mode Exit fullscreen mode

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)