DEV Community

Cover image for How to implement dynamic Role-based Access Control (RBAC) in Express JS REST API.
Imo-owo Nabuk
Imo-owo Nabuk

Posted on

How to implement dynamic Role-based Access Control (RBAC) in Express JS REST API.

In this tutorial, I want to share how to implement dynamic role based access control (RBAC) system in express js (node js) API with Postgres, and Sequelize ORM with ES6+.

There are many resources out there on creating a user account with role field in the user table. The limitation with this is that a user can only have one role at a time.

Some software products such as management systems might require a user to share multiple roles and sometimes have direct permissions to perform an action.

Let's explore how to create a user account with multiple roles and direct permissions for specific actions.

For this implementation, we are going to continue from my previous tutorial on how to set up Express JS REST API, Postgres, and Sequelize ORM with ES6+ with a some tweaks.

Clone this repository for the tutorial.

Let's create util functions for hashing password and json responses. Create utils folder in src folder and add two files: hashing.js & sendResponse.js

In hashing.js, add the following codes:

import crypto from 'crypto';

export const hash = (string) => crypto.createHash('sha256').update(string).digest('base64');

export const hash_compare = (first_item, second_item) => Object.is(first_item, second_item);
Enter fullscreen mode Exit fullscreen mode

Add the following to sendResponse.js:

export const sendErrorResponse = (res, code, errorMessage, e = null) => res.status(code).send({
    status: 'error',
    error: errorMessage,
    e: e?.toString(),
});

export const sendSuccessResponse = (res, code, data, message = 'Successful') => res.status(code).send({
    status: 'success',
    data,
    message,
});
Enter fullscreen mode Exit fullscreen mode

Replace the codes in src/controllers/AuthController.js with

import {Op} from 'sequelize';
import model from '../models';
import {sendErrorResponse, sendSuccessResponse} from "../utils/sendResponse";
import {hash} from "../utils/hashing";

const {User} = model;

export default {
    async signUp(req, res) {
        const {email, password, name, phone} = req.body;
        try {
            let user = await User.findOne({where: {[Op.or]: [{phone}, {email}]}});
            if (user) {
                return sendErrorResponse(res, 422, 'User with that email or phone already exists');
            }
            const settings = {
                notification: {
                    push: true,
                    email: true,
                },
            };
            user = await User.create({
                name,
                email,
                password: hash(password),
                phone,
                settings
            });
            return sendSuccessResponse(res, 201, {
                user: {
                    id: user.id,
                    name: user.name,
                    email: user.email,
                }
            }, 'Account created successfully');
        } catch (e) {
            console.error(e);
            return sendErrorResponse(res, 500, 'Could not perform operation at this time, kindly try again later.', e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the routes folder, add a file authRouter.js and add the following code:

import express from 'express';
import AuthController from '../controllers/AuthController';

const router = express.Router();

router.post('/register', AuthController.signUp);

export default router;
Enter fullscreen mode Exit fullscreen mode

Replace the code in src/routes/index.js with:

import authRouter from "./authRouter";
import express from "express";
import { sendErrorResponse } from "../utils/sendResponse";

export default (app) => {
    app.use(express.urlencoded({ extended: true }));
    app.use(express.json());

    app.use('/api/v1/auth', authRouter);

    app.all('*', (req, res) => sendErrorResponse(res, 404, 'Route does not exist'));
};
Enter fullscreen mode Exit fullscreen mode

Let's implement the API login. I prefer to persist the token in the database. The benefit of this is that a user will know the devices with active session and can choose to destroy the session. It's the approach used in telegram messaging app.

Run the sequelize command to create the token model and migration
sequelize model:generate --name PersonalAccessToken --attributes name:string,token:string,last_used_at:string,last_ip_address:string

Update the PersonalAccessToken model with the following code:

import { Model } from 'sequelize';

const PROTECTED_ATTRIBUTES = ['token'];

export default (sequelize, DataTypes) => {
  class PersonalAccessToken extends Model {
    toJSON() {
      // hide protected fields
      const attributes = { ...this.get() };
      // eslint-disable-next-line no-restricted-syntax
      for (const a of PROTECTED_ATTRIBUTES) {
        delete attributes[a];
      }
      return attributes;
    }

    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
      PersonalAccessToken.belongsTo(models.User, {
        foreignKey: 'user_id',
        as: 'owner',
        onDelete: 'CASCADE',
      });
      models.User.hasMany(PersonalAccessToken, {
        foreignKey: 'user_id',
        as: 'tokens',
        onDelete: 'CASCADE',
      });
    }
  }
  PersonalAccessToken.init({
    user_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    name: DataTypes.STRING,
    token: {
      type: DataTypes.STRING,
      unique: true,
      allowNull: false,
    },
    last_used_at: DataTypes.DATE,
    last_ip_address: DataTypes.STRING
  }, {
    sequelize,
    modelName: 'PersonalAccessToken',
  });

  return PersonalAccessToken;
};

Enter fullscreen mode Exit fullscreen mode

We just added the associations for the token and the user model and can then create our login controller.

Add this function to the user model before the return statement:


  /**
   * Create a new personal access token for the user.
   *
   * @return Object
   * @param device_name
   */
  User.prototype.newToken = async function newToken(device_name = 'Web FE') {
    const plainTextToken = Random(40);

    const token = await this.createToken({
      name: device_name,
      token: hash(plainTextToken),
    });

    return {
      accessToken: token,
      plainTextToken: `${token.id}|${plainTextToken}`,
    };
  };
Enter fullscreen mode Exit fullscreen mode

Remember to import the hashing utils in the user model. Add a random token generator and import as well. Create src/utils/Random.js and add the code:

import crypto from 'crypto';

export default (length = 6, type = 'alphanumeric') => {
    if (!(length >= 0 && Number.isFinite(length))) {
        throw new TypeError('Expected a `length` to be a non-negative finite number');
    }

    let characters;
    switch (type) {
        case 'numeric':
            characters = '0123456789'.split('');
            break;
        case 'url-safe':
            characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split('');
            break;
        case 'alphanumeric':
        default:
            characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');
            break;
    }

    // Generating entropy is faster than complex math operations, so we use the simplest way
    const characterCount = characters.length;
    const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division
    const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low
    let string = '';
    let stringLength = 0;

    while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
        const entropy = crypto.randomBytes(entropyLength);
        let entropyPosition = 0;

        while (entropyPosition < entropyLength && stringLength < length) {
            const entropyValue = entropy.readUInt16LE(entropyPosition);
            entropyPosition += 2;
            if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
                // eslint-disable-next-line no-continue
                continue;
            }

            string += characters[entropyValue % characterCount];
            // eslint-disable-next-line no-plusplus
            stringLength++;
        }
    }

    return string;
};
Enter fullscreen mode Exit fullscreen mode

Let's create login method in the src/controllers/AuthController.js file

    async login(req, res) {
        const { login, password, device_name } = req.body;

        try {
            const user = await User.findOne({ where: { email: login } });

            if (!user) return sendErrorResponse(res, 404, 'Incorrect login credentials. Kindly check and try again');
            const checkPassword = hash_compare(hash(password), user.password);
            if (!checkPassword) {
                return sendErrorResponse(res, 400, 'Incorrect login credentials. Kindly check and try again');
            }

            if (user.status !== 'active') {
                return sendErrorResponse(res, 401, 'Your account has been suspended. Contact admin');
            }

            const token = await user.newToken();
            return sendSuccessResponse(res, 200, {
                token: token.plainTextToken,
                user: {
                    name: user.name,
                    id: user.id,
                    email: user.email,
                },
            }, 'Login successfully');
        } catch (e) {
            console.error(e);
            return sendErrorResponse(res, 500, 'Server error, contact admin to resolve issue', e);
        }
    }
Enter fullscreen mode Exit fullscreen mode

Use postman to test login

image

With the sign up and login features completed, let's dive into creating models for Roles and Permission. You can later add an endpoint for the user to login of other device, that's beyond the scope of this tutorial.

We are going to create Role, Permission, UserRole, RolePermission and UserPermission models and migrations.

Check repo for models and relationship.

Next, add this static method to PersonalAccessToken model:

 /***
     * Verify the token and retrieve the authenticated user for the incoming request.
     * @param authorizationToken
     * @returns {Promise<{user}>}
     */
    static async findToken(authorizationToken) {
      if (authorizationToken) {
        let accessToken;
        if (!authorizationToken.includes('|')) {
          accessToken = await this.findOne({ where: { token: hash(authorizationToken) }, include: 'owner' });
        } else {
          const [id, kToken] = authorizationToken.split('|', 2);
          const instance = await this.findByPk(id, { include: 'owner' });
          if (instance) {
            accessToken = hash_compare(instance.token, hash(kToken)) ? instance : null;
          }
        }

        if (!accessToken) return { user: null, currentAccessToken: null };

        accessToken.last_used_at = new Date(Date.now());
        await accessToken.save();
        return { user: accessToken.owner, currentAccessToken: accessToken.token };
      }

      return { user: null, currentAccessToken: null };
    }
Enter fullscreen mode Exit fullscreen mode

Let's create a seeder for our default roles and permissions using the command

sequelize seed:generate --name roles-permissions-admin-user

Add the following to the seeder file located at src/database/seeders:

import { hash } from '../../utils/hashing';
import model from '../../models';
import Constants from '../../utils/constants';

const { User, Role, Permission } = model;

export default {
  // eslint-disable-next-line no-unused-vars
  up: async (queryInterface, Sequelize) => {
    /**
     * Add seed commands here.
     *
     * Example:
     * await queryInterface.bulkInsert('People', [{
     *   name: 'John Doe',
     *   isBetaMember: false
     * }], {});
     */
    await Role.bulkCreate([
      { name: Constants.ROLE_SUPER_ADMIN },
      { name: Constants.ROLE_ADMIN },
      { name: Constants.ROLE_MODERATOR },
      { name: Constants.ROLE_AUTHENTICATED },
    ]);

    await Permission.bulkCreate([
      { name: Constants.PERMISSION_VIEW_ADMIN_DASHBOARD },
      { name: Constants.PERMISSION_VIEW_ALL_USERS },
    ]);

    const superAdminUser = await User.create({
      name: 'John Doe',
      email: 'johndoe@node.com',
      password: hash('Password111'),
      phone: '+2348123456789',
    });

    const superAdminRole = await Role.findOne({ where: { name: Constants.ROLE_SUPER_ADMIN } });
    const superAdminPermissions = await Permission.findAll({
      where: {
        name: [
          Constants.PERMISSION_VIEW_ADMIN_DASHBOARD,
          Constants.PERMISSION_VIEW_ALL_USERS,
        ],
      },
    });
    await superAdminUser.addRole(superAdminRole);
    await superAdminRole.addPermissions(superAdminPermissions);
  },

  // eslint-disable-next-line no-unused-vars
  down: async (queryInterface, Sequelize) => {
    /**
     * Add commands to revert seed here.
     *
     * Example:
     * await queryInterface.bulkDelete('People', null, {});
     */
    await Role.destroy();
    await Permission.destroy();
    await User.destroy();
  },
};
Enter fullscreen mode Exit fullscreen mode

Here, we've created a super admin user, default roles and permissions and sync the models.

Update the user model with the following:


  User.prototype.hasRole = async function hasRole(role) {
    if (!role || role === 'undefined') {
      return false;
    }
    const roles = await this.getRoles();
    return !!roles.map(({ name }) => name)
      .includes(role);
  };

  User.prototype.hasPermission = async function hasPermission(permission) {
    if (!permission || permission === 'undefined') {
      return false;
    }
    const permissions = await this.getPermissions();
    return !!permissions.map(({ name }) => name)
      .includes(permission.name);
  };

  User.prototype.hasPermissionThroughRole = async function hasPermissionThroughRole(permission) {
    if (!permission || permission === 'undefined') {
      return false;
    }
    const roles = await this.getRoles();
    // eslint-disable-next-line no-restricted-syntax
    for await (const item of permission.roles) {
      if (roles.filter(role => role.name === item.name).length > 0) {
        return true;
      }
    }
    return false;
  };

  User.prototype.hasPermissionTo = async function hasPermissionTo(permission) {
    if (!permission || permission === 'undefined') {
      return false;
    }
    return await this.hasPermissionThroughRole(permission) || this.hasPermission(permission);
  };
Enter fullscreen mode Exit fullscreen mode

Next, we create a middleware for the route. We are going to create two middleware files, one for basic authentication and another for the permissions.

In the src folder, create another folder called middleware and add Auth.js and canAccess.js files to it.

Paste the following as the content for Auth.js file:

import { sendErrorResponse } from '../utils/sendResponse';
import model from '../models';

const { PersonalAccessToken } = model;

export default async (req, res, next) => {
  try {
    if (!req.headers.authorization) {
      return sendErrorResponse(res, 401, 'Authentication required');
    }

    const bearerToken = req.headers.authorization.split(' ')[1] || req.headers.authorization;

    const { user, currentAccessToken } = await PersonalAccessToken.findToken(bearerToken);

    if (!user) {
      return sendErrorResponse(res, 401, 'Authentication Failed');
    }
    if (user.status !== 'active') return sendErrorResponse(res, 403, 'Your account is either suspended or inactive. Contact admin to re-activate your account.');

    req.currentAccessToken = currentAccessToken;
    req.userData = user;
    next();
  } catch (e) {
    console.error(e);
    return sendErrorResponse(res, 401, 'Authentication Failed', e);
  }
};
Enter fullscreen mode Exit fullscreen mode

and canAccess.js

import { sendErrorResponse } from '../utils/sendResponse';
import model from '../models';

const { Role, Permission } = model;

export default (permission) => async (req, res, next) => {
  const access = await Permission.findOne({
    where: { name: permission },
    include: [{ attributes: ['id', 'name'], model: Role, as: 'roles', through: { attributes: [] } }],
  });
  if (await req.userData.hasPermissionTo(access)) {
    return next();
  }
  console.error('You do not have the authorization to access this.');
  return sendErrorResponse(res, 403, 'You do not have the authorization to access this');
};
Enter fullscreen mode Exit fullscreen mode

Finally, let's add our middlewares to the route by creating an adminRouter.js file in routes folder and add the following:

import express from 'express';
import Auth from '../middlewares/Auth';
import can from '../middlewares/canAccess';
import Constants from '../utils/constants';
import AdminController from "../controllers/AdminController";
import { sendSuccessResponse } from "../utils/sendResponse";

const router = express.Router();

router.get('/users', Auth, can(Constants.PERMISSION_VIEW_ALL_USERS), AdminController.users);
router.get('/dashboard', Auth, can(Constants.PERMISSION_VIEW_ADMIN_DASHBOARD), (req, res) => {
    return sendSuccessResponse(res, 200, '', 'Admin dashboard access allowed.')
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Note, I created an AdminController file and used it in the route. You can organize your code however you want.

The basic thing is that each route has a permission tag and that permission can be assigned to a role or directly to a user for the middleware to allow or deny.

See complete API server codes on github.

The API documention is available here

If you have issues, questions or contribution, kindly do so in the comment section below.

That's how to implement Dynamic Role based Access Control (RBAC) system in Express js (Node js) API. The code is not production ready, you are free to improve on it and use it.
Add logout controller, validation for data, endpoint for creating and assigning roles and permissions and other features your app will need.

Thank you.

Alt Text

Discussion (0)