Role Based Access Control In Adonis Js With Npm Role-cl

Role Based Access Control In Adonis Js With Npm Role-cl

·

7 min read

Role based access control or RBAC as it would be called throughout the remainder of this article, refers to an authorization process based on user defined roles in the organization, example: a team-member can create and update collections but they can’t delete a collection, only the team-admin role has authority to delete collections. In this article we will be creating an API that implements the above example along with only allowing team-admins and team-members access to collections belonging to their teams and no other teams’ collections.

We will be using Adonis JS which is a Node JS framework along with the Role-acl package.

I will assume you have an Adonis server, with the Lucid ORM and a Database already set up. For authentication we will be taking off from the where this last tutorial social authentication with adonis , we talked about using Ally package for social Authentication using google.

Let’s create the User, teams and collections models and migrations.

node ace make:model User -m
node ace make:model Team -m
node ace make:model collection -m

In the user model file, we will add the following:

import { DateTime } from 'luxon'
import { column, BaseModel } from '@ioc:Adonis/Lucid/Orm'

export default class Users extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public name: string;

  @column()
  public avatar_url: string | null;

  @column({isPrimary: true})
  public email: string;

  @column()   
  public role: string;

  @column()   
  public providerId: string;

  @column()
  public provider: string;

  @column()
  public teams: {} | null;

  @column()
  public rememberMeToken?: string

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime


}

Then the user migrations file:

import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class UsersSchema extends BaseSchema {
  protected tableName = 'users'

  public async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').primary()
      table.string('name').notNullable();
      table.string('avatar_url');
      table.string('email').notNullable().unique();
      table.string('role').defaultTo('basic');
      table.string('provider');
      table.string('provider_id');
      table.string('remember_me_token');

      table.json('teams');
      /**
       * Uses timestampz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp('created_at', { useTz: true }).notNullable()
      table.timestamp('updated_at', { useTz: true }).notNullable()
    })
  }

  public async down() {
    this.schema.dropTable(this.tableName)
  }
}

The teams model and migrations will look like this: Team model :

import { DateTime } from 'luxon'
import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class Team extends BaseModel {
  @column()
  public id: number

  @column({ isPrimary: true })
  public uid: string 

  @column()
  public name: string

  @column()
  public owner_email: string[]

  @column()
  public members: string[]

  @column()
  public collections: string[]

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
import { generateRandomKey } from '../../Utils/generateRandomKey'

export default class Teams extends BaseSchema {
  protected tableName = 'teams'

  public async up () {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')
      table.string('uid').defaultTo( generateRandomKey())
      table.string('name').notNullable()
      table.specificType('owner_email', 'text[]').notNullable()
      table.specificType('members', 'text[]').defaultTo('{}')
      table.specificType('collections', 'text[]').defaultTo('{}')

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp('created_at', { useTz: true })
      table.timestamp('updated_at', { useTz: true })
    })
  }

  public async down () {
    this.schema.dropTable(this.tableName)
  }
}

The collections’ model and migration file; Collections model:

import { DateTime } from 'luxon'
import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class Collection extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public collectionId: string

  @column()
  public name: string

  @column()
  public collectionOwnerId: string

  @column()
  public description: string | null

  @column()
  public team: string

  @column()
  public resultsAddress: string

  @column.dateTime()
  public executionTime: DateTime | null

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
import { generateRandomKey } from '../../Utils/generateRandomKey'

export default class Collections extends BaseSchema {
  protected tableName = 'collections'

  public async up () {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')
      table.string('collection_id').defaultTo(generateRandomKey())
      table.string('name').notNullable().unique()
      table.string('collection_owner_id').notNullable()
      table.string('description', 255).nullable()
      table.string('team_id').notNullable()
      table.string('results_address').notNullable()
      table.timestamp('execution_time',  { useTz: true }).notNullable()

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp('created_at', { useTz: true })
      table.timestamp('updated_at', { useTz: true })
    })
  }

  public async down () {
    this.schema.dropTable(this.tableName)
  }
}

We will then install the Role-acl package, run:

npm i role-acl

We will create a middleware that checks every request to a protected route, it checks if the user

  • Is a part of the team?

  • Is the team-admin?

  • Is a team member

We will also define the team-admin and team member roles in this middleware.

The team middleware file will be like this:

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Database from '@ioc:Adonis/Lucid/Database';
import { AccessControl } from 'role-acl'

let ac = new AccessControl();

ac.grant('basic')
    .execute('create').on('team')

    .grant('team-member')
        .extend('basic')
        .execute('post').on('collections')
        .execute('get').on('collections')
        .execute('put').on('collections')

    .grant('team-owner')
        .extend('team-member')
        .execute('delete').on('collections')

export default class TeamCollectionsMiddleware {
  public async handle ({auth, request, params}: HttpContextContract, next: () => Promise<void>) {

    let userPermission!: string;
    const userEmail: string = auth.user.email
    //CHECK IF USER IS TEAM OWNER
    let user = await Database.from('teams')
      .where((query) => {
        query
        .where('uid', params.id)
        .where("owner_email", '@>', [userEmail])
        userPermission = 'team-owner'
      })

       //CHECK IF USER IS TEAM MEMBER
      if(user.length === 0){
        user = await Database.from('teams')
        .where((query) => {
          query
            .where('uid', params.id)
            .where("members", '@>', [userEmail])
            userPermission = 'team-member'
        })
      }

      if  (user.length == 0) {
        throw new Error("You are not a member of this team")
      }

      const permission = await ac.can(userPermission).execute(request.method()).on('collections'); 
      if(permission.granted) await next();
      else throw new Error('You are not allowed to perform this action');
  }
}

Here we defined the basic role, team-owner, team-member and owner role.

  • Basic role: has the permission to create teams

  • Team-member: can create a collection ie “post”, read and update a collection ie “get and put”.

  • Team-owner: can do everything the team-member role has permission to do and can also delete collections.

In the body of the middleware, we created a variable to store the user permission status and also another variable to get the user email from the auth session data.

let user = await Database.from('teams')
      .where((query) => {
        query
        .where('uid', params.id)
        .where("owner_email", '@>', [userEmail])
        userPermission = 'team-owner'
      })

In the above code snippet, we are checking the teams table in the database, we then get the team through the params (teams id will be passed in with the route), then we check if the owner column contains the user email, if it does we set the userPermission variable to be “team-owner”.

       //CHECK IF USER IS TEAM MEMBER
      if(user.length === 0){
        user = await Database.from('teams')
        .where((query) => {
          query
            .where('uid', params.id)
            .where("members", '@>', [userEmail])
            userPermission = 'team-member'
        })
      }

Else, if the owner_email column does not contain the user email, we then check the members’ column, if it contains the user email, if it does we update the userPermission to be “team-member”.

if  (user.length == 0) {
        throw new Error("You are not a member of this team")
      }

If user email is not in the members’ column or owner column, then the user is not part of the team and we throw an error.

We then check the userPermission variable to see if the user has the right permission to perform the request they want to perform, if they do then the request is sent to the controller, if they do not, an error will be thrown.

      const permission = await ac.can(userPermission).execute(request.method()).on('collections'); 
      if(permission.granted) await next();
      else throw new Error('You are not allowed to perform this action');

We will now define the collections controller

Node ace make:controller Collection

Paste the following code in the controller

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema } from '@ioc:Adonis/Core/Validator'
import Collection from 'App/Models/Collection'

export default class CollectionsController {
    public async createCollection ({ request, response }: HttpContextContract) {
        const data = await schema.create({
            name: schema.string({ trim: true }),
            description: schema.string({ trim: true }),
            collectionOwnerId: schema.string({ trim: true }),
            resultsAddress: schema.string({ trim: true }),
            executionTime: schema.date(),
        });

        const validatedData = await request.validate({schema: data});

        const newCollection = await Collection.create(validatedData);

        return response.status(201).json(newCollection);
    }

      public async getCollection ({ params, response }: HttpContextContract) {
        const collection = await Collection.findByOrFail('collection_id', params.id);

        return response.status(201).json(collection);
    }

    public async getAllCollectionsForATeam ({params, response }: HttpContextContract) {
        const collections = await Collection
            .query()
            .where('team_id', params.teamId)

        return response.status(201).json(collections);
    }

       public async updateCollection ({ params, request, response }: HttpContextContract) {
        const collection = await Collection.findByOrFail('collection_id', params.id);

        const data = await schema.create({
            name: schema.string({ trim: true }),
            description: schema.string({ trim: true }),
            collectionOwnerId: schema.string({ trim: true }),
            resultsAddress: schema.string({ trim: true }),
            executionTime: schema.date(),
        });

        const validatedData = await request.validate({schema: data});

        await collection.merge(validatedData);

        await collection.save();

        return response.status(204).json(collection);

    }

    public async deleteCollection ({ params, response }: HttpContextContract) {
        const collection = await Collection.findByOrFail('collection_id', params.id);

        await collection.delete();

        return response.status(204);
    }

}

We will then add the middleware to the routes for the collections

//COLLECTIONS ROUTES
Route.group(() => {
  Route.get('/get-collections', 'CollectionsController.getAllCollectionsForATeam'); // GET ALL COLLECTIONS FOR A TEAM
  Route.get('/get-collections/:id', 'CollectionsController.getCollection'); // GET ONE COLLECTION
  Route.post('/create-collections', 'CollectionsController.createCollection'); // CREATE COLLECTION
  Route.put('/collections/update/:id', 'CollectionsController.updateCollection'); // UPDATE COLLECTION
  Route.delete('/collections/delete/:id', 'CollectionsController.deleteCollection'); // DELETE COLLECTION
})
.middleware(['auth', 'teamCollectionMiddleware']);

That’s it. Tell me what you think in the comments.