Custom role based access in Nest.js, MongoDB
Tags: nestjs, mongodb, typescript, mongoose
Published: 2020-10-31 06:47:16

Here, we have four roles: Sme, Sponsor, Admin, Operations.Initially, we had only 3 roles.Operations role was added later and Operations user has permissions similar to the Admin user.In the code, we had to replace every instance of **if (user.type == USER_TYPES.ADMIN)** with **if (user.type == USER_TYPES.ADMIN || user.type == USER_TYPES.OPERATIONS)**.As this is time consuming and we can also miss many instances, we have created a roles module. In the roles module,the roles are defined along with their respective permissions as seen in Code (Part-III). Based on the permissions for each role, we will evaluate the authorization for the user in each of our controller methods.If the user has access, only then he will be granted the resources.
**Code (Part-I):**
*src/common/constants/enum.ts*
{% runkit
// hidden setup JavaScript code goes in this preamble area
const hiddenVar = 42
%}

export enum USER_TYPES {
  SME = "Sme",
  SPONSOR = "Sponsor",
  ADMIN = "Admin",
  OPERATIONS_TEAM = "Operations"
}


//rolesAccessAction 
export enum ROLES_ACCESS_ACTION {
  USERS_CONTROLLER_FINDLIST_OPERATIONS = "users.controller.findList_operations",
  USERS_CONTROLLER_FINDLIST_ADMIN = "users.controller.findList_admin",
  USERS_CONTROLLER_FIND_ONE = "users.controller.findOne",
  USERS_CONTROLLER_KYC_FILTER = "users.controller.findListFilterKYCStatus",
  USERS_CONTROLLER_USER_STATUS_FILTER = "users.controller.findListFilterUserStatus",
  USERS_CONTROLLER_USER_UPDATE = "users.controller.update",
  USERS_CONTROLLER_DELETE = "users.controller.delete",
  USERS_SERVICE_CHECK_FOR_UPDATE_STATUS_ERROR = "users.service.checkForUpdateStatusError",
  USERS_SERVICE_CREATE = "users.service.create",
  USERS_SERVICE_UPDATE_USER_CRMID_AND_ENTITYDETAILCODE = "users.service.updateUserCrmIdAndEntityDetailCode",
  REMARKS_CONTROLLER_CREATE = "remarks.controller.create",
  REMARKS_CONTROLLER_FINDLIST = "remarks.controller.findList",
  REMARKS_CONTROLLER_FINDLIST_SME = "remarks.controller.findList_sme",
  REMARKS_CONTROLLER_FINDLIST_SPONSOR = "remarks.controller.findList_sponsor",
  SME_PROJECT_CONTROLLER_FINDLIST = "sme-project.controller.findList",
  SME_PROJECT_CONTROLLER_FINDONE = "sme-project.controller.findOne",
  SME_PROJECT_CONTROLLER_RECOMMENDED_PROJECTS = "sme-project.controller.getRecommendedProjects",
  SME_PROJECT_CONTROLLER_CREATE = "sme-project.controller.create",
  SME_PROJECT_CONTROLLER_SPONSOR_FILTER = "sme-project.controller.projectSponsorFilter",
  SME_PROJECT_CONTROLLER_UPDATE = "sme-project.controller.update",
  BID_DETAILS_CONTROLLER_FINDLIST = "bid-details.controller.findList",
  BID_DETAILS_CONTROLLER_FINDLIST_SME = "bid-details.controller.findList_sme",
  BID_DETAILS_CONTROLLER_FINDLIST_SPONSOR = "bid-details.controller.findList_sponsor",
  BID_DETAILS_CONTROLLER_CREATE =  "bid-details.controller.create",
  BID_DETAILS_CONTROLLER_COMPLETE_BID_PROCESS = "bid-details.controller.completeBidProcess",
  BID_DETAILS_CONTROLLER_REJECT_ALL_BIDS_DELETE_PROJECT = "bid-details.controller.rejectAllBidsDeleteProject",
  BID_DETAILS_CONTROLLER_UPDATE = "bid-details.controller.update",
  BID_DETAILS_CONTROLLER_UPDATE_SPONSOR = "bid-details.controller.update_sponsor",
  BID_DETAILS_SERVICE_CALCULATE_BID_DETAILS = "bid-details.controller.calculatebiddetails",
  BID_DETAILS_CONTROLLER_CREATE_TRANSACTION = "bid-details.controller.createTransaction"
  //BID_DETAILS_CONTROLLER_GET_FUNDED_PROJECTS = "bid-details.controller.getfundedProjects"
}
{% endrunkit %}
Above, we have defined the rolesAction for each of the methods in our project.The convention used here is the controller/service name of the file followed by method name. For example, USERS_CONTROLLER_FINDLIST_OPERATIONS = "users.controller.findList_operations", we have users.controller as the controller name followed by method name as findList.
**Code (Part-II):**
*src/users/users.controller.ts*
{% runkit
// hidden setup JavaScript code goes in this preamble area
const hiddenVar = 42
%}
import {
  Body,
  Controller,
  Param,
  Post,
  UseGuards,
  Get,
  Request,
  Query,
  Put,
  NotFoundException,
  Delete,
  BadRequestException,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import {
  CreateUserDto,
  UpdateUserDto,
  UserDto,
  CloseAccount,
} from "./objects/create-user.dto";
import { abstractBaseControllerFactory } from "../common/base/base.controller";
import { LoggedInToken } from "./objects/login-user.dto";
import {
  BASEROUTES,
  USER_TYPES,
  KYC_VERIFICATION_STATUS,
  USER_STATUS,
  ROLES_ACCESS_ACTION,
} from "../common/constants/enum";
import { JwtAuthGuard } from "../auth/auth.guard";
import {
  RequestUser,
  LastUpdatedTime,
  IdOrCodeParser,
} from "../common/utils/controller.decorator";
import {
  UNKNOWN_PARAM,
  NOT_FOUND,
  PAGE_NOT_FOUND_404,
  NEW_PASSWORD_AND_CONFIRM_NEW_PASSWORD_ERROR,
  USERNAME_OR_PASSWORD_INCORRECT,
  CURRENT_PASSWORD_AND_NEW_PASSWORD_ERROR,
  KYC_PENDING_STATUS_CHANGE_ERROR,
  KYC_APPROVED_STATUS_CHANGE_ERROR,
  KYC_REJECTED_STATUS_CHANGE_ERROR,
  USER_ACTIVE_STATUS_CHANGE_ERROR,
  USER_CLOSED_STATUS_CHANGE_ERROR,
  USER_IN_REVIEW_STATUS_CHANGE_ERROR,
  USER_KYC_INCOMPLETE_STATUS_CHANGE_ERROR,
  ONLY_FOR_ADMIN,
} from "../common/constants/string";
import { plainToClass } from "class-transformer";
import { success } from "../common/base/httpResponse.interface";
import { AbstractClassTransformerPipe } from "../common/pipes/class-transformer.pipe";
import { normalizeObject } from "../common/utils/helper";
import * as _ from "lodash";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { IUser, User } from "./objects/user.schema";
import { CrmService } from "./crm/crm.service";
import { KycPendingEmail } from "./objects/user.registered.email";
import { EmailDto } from "../email/objects/email.dto";
import { normalizePaginateResult } from "../common/interfaces/pagination";
import { RolesService } from "../roles/roles.service";

const BaseController = abstractBaseControllerFactory<any>({
  DTO: UserDto,
  DisabledRoutes: [
    BASEROUTES.PATCH,
    //, BASEROUTES.DETELEONE
  ],
});

@Controller("users")
export class UsersController extends BaseController {
  constructor(
    private usersService: UsersService,
    private crmService: CrmService,
    @InjectModel("User") private readonly usersModel: Model<IUser>,
    private rolesservice: RolesService
  ) {
    super(usersService);
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  async findList(@Request() req, @Query() query, @RequestUser() user) {
   let _user = await this.rolesservice.findOneByQuery({roleName: user.type})
    // if(user.type == USER_TYPES.OPERATIONS_TEAM){
    let hasAccessOperations = _user.rolesAccessAction.some(
      (e) => e === ROLES_ACCESS_ACTION.USERS_CONTROLLER_FINDLIST_OPERATIONS
    );
    let hasAccessAdmin = _user.rolesAccessAction.some(
      (e) => e === ROLES_ACCESS_ACTION.USERS_CONTROLLER_FINDLIST_ADMIN
    );
    
    if(hasAccessOperations) {
      console.log('userstype', user.type, _user, hasAccessOperations, hasAccessAdmin)
      let t = { $or: [{ type: USER_TYPES.SME }, { type: USER_TYPES.SPONSOR }] };
      return await super.findList(req, { ...query, ...t });
    }
    //if (user.isAdmin) {
      if (hasAccessAdmin){
      // <--- only admin can see the user lists
      return await super.findList(req, { ...query });
    }
    throw new NotFoundException();
  }


 @UseGuards(JwtAuthGuard)
  @Get(":idOrCode")
  async findOne(
    @IdOrCodeParser("idOrCode") idOrCode: string,
    @RequestUser() user
  ) {
    let _user = await this.rolesservice.findOneByQuery({roleName: user.type});
    console.log('userstype', user.type, _user)
    let hasAccess = _user.rolesAccessAction.some(
      (e) => e === ROLES_ACCESS_ACTION.USERS_CONTROLLER_FIND_ONE
    );
  
    if (hasAccess || user.code === idOrCode || user.id === idOrCode) {
      // <--- only admin or the same person can view a profile
      return await super.findOne(idOrCode);
    }
    throw new NotFoundException();
  }

  @UseGuards(JwtAuthGuard)
  @Post("filter/kycFilter")
  async findListFilterKYCStatus(
    @Request() req,
    @Query() query,
    @RequestUser() user,
    @Body() body: { isProfileCompleted: number }
  ) {
    let t = { $or: [{ type: USER_TYPES.SME }, { type: USER_TYPES.SPONSOR }] };
    //if (user.type == USER_TYPES.ADMIN) {
      let _user = await this.rolesservice.findOneByQuery({roleName: user.type});
      console.log('userstype', user.type, _user)
      let hasAccess = _user.rolesAccessAction.some(
        (e) => e === ROLES_ACCESS_ACTION.USERS_CONTROLLER_KYC_FILTER
      );
     // console.log('userstype', user.type, _user, hasAccess)
   if(hasAccess) {
    var options = {
        limit: 30,
        page: 1,
        sort: "_id",
        skip: query.page ? (query.page - 1) : 0
      };
      
      // <--- only admin and Sponsor can see all the USER lists
      console.log("filterrrrrrrrrrr", query, query.page, body.isProfileCompleted);
     
      let d = await this.usersModel.find(
        { "verification.isProfileCompleted": body.isProfileCompleted, ...t },
        {},
        { sort: { _id: 1 }, skip: options.skip * options.limit, limit: options.limit, projection: {} }
      );
      let dCount = await this.usersModel.count(
        { "verification.isProfileCompleted": body.isProfileCompleted }
      );
      console.log(d.length, dCount);
      await d.map((data) => {
        return plainToClass(UserDto, data, { excludeExtraneousValues: true });
      });
      let pagination = normalizePaginateResult({
        total: dCount,//d.length,
        limit: options.limit,
        page: options.page,
        pages: d.pages,
      });

      return success({ d, pagination });
    }
    throw new NotFoundException();
  }
{% endrunkit %}
In the 3 methods listed above, findList, findOne, findListFilterKYCStatus, we are checking if the user has access/authorization.For the method findListFilterKYCStatus **let hasAccess = _user.rolesAccessAction.some(
        (e) => e === ROLES_ACCESS_ACTION.USERS_CONTROLLER_KYC_FILTER
      );** , we are checking if the user has **ROLES_ACCESS_ACTION.USERS_CONTROLLER_KYC_FILTER** listed in his roles Schema as shown below in the file.Here, only users of type **USER_TYPES.OPERATIONS_TEAM** and **USER_TYPES.ADMIN** have the permissions and only they are allowed access to the findListFilterKYCStatus() method. 
Code (Part-III):
*src/roles/roles.controller.ts*
{% runkit
// hidden setup JavaScript code goes in this preamble area
const hiddenVar = 42
%}
import { RolesDto, CreateRolesDto } from './objects/roles.dto';
import { abstractBaseControllerFactory } from '../common/base/base.controller';
import { BASEROUTES, USER_TYPES, ROLES_ACCESS_ACTION } from '../common/constants/enum';
import { RolesService } from './roles.service';
import { JwtAuthGuard } from '../auth/auth.guard';
import {
  Controller,
  Get,
  UseGuards,
  Request,
  Query,
  Put,
  Body,
  Post,
  BadRequestException,
  NotFoundException,
  Delete,
} from "@nestjs/common";
import { AbstractClassTransformerPipe } from '../common/pipes/class-transformer.pipe';
import { RequestUser } from '../common/utils/controller.decorator';
import { plainToClass } from 'class-transformer';
import { success } from '../common/base/httpResponse.interface';


const BaseController = abstractBaseControllerFactory<any>({
    DTO: RolesDto,
    //Todo: Remove after creating records in Db.
    CreateDTO: CreateRolesDto,
    DisabledRoutes: [
      //Todo: Uncomment BASEROUTES.CREATE after creating records in Db.
      // BASEROUTES.CREATE,
     // BASEROUTES.DETELEONE,
  
      BASEROUTES.PATCH,
     // BASEROUTES.UPDATEONE,
    ],
  });

@UseGuards(JwtAuthGuard)
@Controller('roles')
export class RolesController extends BaseController {
    constructor(private rolesservice: RolesService) {
        super(rolesservice);
      }
      @Post()
      public async create(
        @Request() req,
        @Body(AbstractClassTransformerPipe(CreateRolesDto)) body: any,
        @Query() query,
        @RequestUser() user
      ) {
         switch(body.roleName){
           case USER_TYPES.ADMIN:
             body.rolesAccessAction = [
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_FINDLIST_ADMIN,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_FIND_ONE,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_KYC_FILTER,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_USER_STATUS_FILTER,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_USER_UPDATE,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_DELETE,
              ROLES_ACCESS_ACTION.USERS_SERVICE_CHECK_FOR_UPDATE_STATUS_ERROR,
              ROLES_ACCESS_ACTION.USERS_SERVICE_CREATE,
              ROLES_ACCESS_ACTION.USERS_SERVICE_UPDATE_USER_CRMID_AND_ENTITYDETAILCODE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_CREATE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDONE,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_UPDATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_COMPLETE_BID_PROCESS,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_REJECT_ALL_BIDS_DELETE_PROJECT,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_UPDATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_CREATE_TRANSACTION
             ];
             break;
           case USER_TYPES.OPERATIONS_TEAM:
            body.rolesAccessAction = [
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_FINDLIST_OPERATIONS,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_FIND_ONE,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_KYC_FILTER,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_USER_STATUS_FILTER,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_USER_UPDATE,
              ROLES_ACCESS_ACTION.USERS_CONTROLLER_DELETE,
              ROLES_ACCESS_ACTION.USERS_SERVICE_CHECK_FOR_UPDATE_STATUS_ERROR,
              ROLES_ACCESS_ACTION.USERS_SERVICE_CREATE,
              ROLES_ACCESS_ACTION.USERS_SERVICE_UPDATE_USER_CRMID_AND_ENTITYDETAILCODE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_CREATE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDONE,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_UPDATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_COMPLETE_BID_PROCESS,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_REJECT_ALL_BIDS_DELETE_PROJECT,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_UPDATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_CREATE_TRANSACTION
            ];
             break;
           case USER_TYPES.SME:
            body.rolesAccessAction = [
              ROLES_ACCESS_ACTION.USERS_SERVICE_UPDATE_USER_CRMID_AND_ENTITYDETAILCODE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_FINDLIST_SME,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_CREATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_FINDLIST_SME
            ];
           
             break;
           case USER_TYPES.SPONSOR:
            body.rolesAccessAction = [
              ROLES_ACCESS_ACTION.USERS_SERVICE_UPDATE_USER_CRMID_AND_ENTITYDETAILCODE,
              ROLES_ACCESS_ACTION.REMARKS_CONTROLLER_FINDLIST_SPONSOR,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDLIST,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_FINDONE,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_RECOMMENDED_PROJECTS,
              ROLES_ACCESS_ACTION.SME_PROJECT_CONTROLLER_SPONSOR_FILTER,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_FINDLIST_SPONSOR,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_CREATE,
              ROLES_ACCESS_ACTION.BID_DETAILS_CONTROLLER_UPDATE_SPONSOR,
              ROLES_ACCESS_ACTION.BID_DETAILS_SERVICE_CALCULATE_BID_DETAILS
            ];
             break; 
          } 
          let roles =  await this.rolesservice.create(body);
          const _data = plainToClass(RolesDto, roles, { excludeExtraneousValues: true });
          return success(_data);
      }
}

{% endrunkit %}
The role's permissions are stored in the backend (MongoDB)
Code (Part-IV):
*src/roles/objects/roles.schema.ts*
{% runkit
// hidden setup JavaScript code goes in this preamble area
const hiddenVar = 42
%}
import { Schema } from "mongoose";
import { createModel, Entity, IEntity } from "../../common/base/base.model";

export class Roles extends Entity {
  roleName: string;
  roleCode: string;
  type: string;
  rolesAccessAction: string[];
}

export interface IRoles extends Roles, IEntity {
  id: string;
}

export const RolesSchema: Schema = createModel("AdminRoles", {
  roleName: { type: String, required: true },
  roleCode: { type: String, required: true },
  type: { type: String, required: true},
  rolesAccessAction: [
    {
      type: String,
    },
  ]
});
{% endrunkit %}
This is how custom role based access is implemented without any 3rd party libraries.
Source code: [Link] https://gitlab.com/adh.ranjan/nestjs