How to implement dynamic Role-based Access Control (RBAC) in Express JS REST API.
Tags: node, rbac, express, api
Published: 2021-09-09 22:03:46

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+](https://dev.to/richienabuk/setting-up-express-js-rest-api-postgres-and-sequelize-orm-with-es6-4m08) with a some tweaks.

Clone [this](https://github.com/richienabuk/express-js-postgres-sequelize) 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:

```javascript
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);
```

Add the following to sendResponse.js:
```javascript
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,
});
```

Replace the codes in src/controllers/AuthController.js with
```javascript
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)
        }
    }
}
```
In the routes folder, add a file authRouter.js and add the following code:

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

const router = express.Router();

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

export default router;
```

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

```javascript
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'));
};
```
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:

```javascript
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;
};

```

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:

```javascript

  /**
   * 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}`,
    };
  };
```

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: 

```javascript
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;
};
```

Let's create login method in the src/controllers/AuthController.js file
```javascript
    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);
        }
    }
```
Use postman to test login

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yrp5q879t2vdxkka0l1h.png)
 

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](https://github.com/richienabuk/express-js-api-rbac) for models and relationship.

Next, add this static method to PersonalAccessToken model:
```javascript
 /***
     * 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 };
    }
```

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:

```javascript
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();
  },
};
```

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

Update the user model with the following:

```javascript

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

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:

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

and canAccess.js

```javascript
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');
};
```

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

```javascript
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;
```

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](https://github.com/richienabuk/express-js-api-rbac).

The API documention is available [here](https://www.getpostman.com/collections/faa987829f1e99090933)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k4uw59lcpubry8r8vyan.png)