1

I had the following question "How do I return API results differently based on user role" for example using one endpoint GET /users

If the user requesting the data is a "MEMBER" I want to show a cut down version of what UI would return to an "ADMIN".

It took me all day but I finally figured it out. Note, I am new to NestJS and so this maybe obvious but I searched the internet high and low and could not find the answer, so I am posting it here for others.

The key here is creating a custom interceptor which you can then include in your controllers.

  1. Create your custom interceptor
  2. Decorate your controller functions (GET, POST etc) with your custom interceptor
  3. Update your Entity object to specify which roles can see data

I'm using Passport and Jwt tokens for authentication but you can modify the below code to access your authenticated users Role data some other way. I put the role data into the token.

1. Create your custom interceptor

Use the Nest CLI to create the scaffolding for your new interceptor with

nest g interceptor common/interceptors/role-sanitize

This file should get the authenticated users role and filter the outbound data object

import { CallHandler, ClassSerializerInterceptor, ExecutionContext, Injectable, NestInterceptor, UseInterceptors } from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from 'rxjs/operators';
import { JwtService } from "@nestjs/jwt";
import { classToPlain } from "class-transformer";

@Injectable()
@UseInterceptors(ClassSerializerInterceptor)
export class RoleSanitizeInterceptor implements NestInterceptor {

constructor(
    private readonly jwtService: JwtService
) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {

interface token {
  email: string;
  id: number;
  role: string;
}

let role = 'MEMBER'
const req = context.switchToHttp().getRequest()
const token = <string>req.headers.authorization
if(token){
  let user = <token>this.jwtService.decode(<string>token.replace('Bearer ', ''))
  role = user.role
}
  return next.handle().pipe(map(data => {
    return classToPlain(data, {groups: [role]})
  }))
}
}

2. Decorate your controller functions (GET, POST etc) with your custom interceptor

Now you have your custom interceptor, you can decorate your endpoints with it, e.g.

import { RoleSanitizeInterceptor } from "../common/interceptors/role-sanitize.interceptor";

....

@UseGuards(JwtAuthGuard)
@UseInterceptors(RoleSanitizeInterceptor)
@Get(':id')
async findOne(@Param('id') id: number): Promise<User> {
    let record = await this.usersService.findOne({ where: [{id: id}] })
    if(!record){
        throw new NotFoundException(`User not found`);
    }
    return record
}

3. Update your Entity object to specify which roles can see data

Here is an example of my entity file, you will note that I am using @Expose to make certain values available based on role:

import { Column, Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, BeforeInsert, BeforeUpdate } from "typeorm";

import { Exclude, Expose } from 'class-transformer';
import { hash } from 'bcrypt'
import { UserMembership, UserRole } from "../dto/create-user.dto";

@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number

@Column()
first_name: string

@Column()
last_name: string

@Column({ unique: true })
email: string

@Column()
@Exclude()
password: string

@Column({default: true})
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
password_reset: boolean

@Column({default: 'MEMBER'})
role: UserRole

@Column({default: 'FREE'})
membership: UserMembership

@Column({default: false})
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
paid: boolean

@Column({default: false})
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
auto_renew: boolean

@CreateDateColumn()
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
created_at: Date

@UpdateDateColumn()
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
updated_at: Date

@Column({nullable: true, default: null})
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
last_login: Date

@Column({nullable: true, default: null})
renew_at: Date

@Column({nullable: true, default: null})
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
canceled_at: Date

@DeleteDateColumn()
@Expose({ groups: ["ADMIN", "SUPERADMIN"] })
deleted_at: Date

@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
    if(this.password){
        this.password = await hash(this.password, 10);
    }
}

constructor(partial: Partial<User>) {
    Object.assign(this, partial);
}
}

Here are the results when I call the endpoint, firstly for a normal "MEMBER":

{
    "id": 1,
    "first_name": "Jon",
    "last_name": "Doe",
    "email": "jon.doe@email.com",
    "role": "MEMBER",
    "membership": "FREE",
    "renew_at": null
}

And here is the result for an "ADMIN" user:

{
    "id": 1,
    "first_name": "Jon",
    "last_name": "Doe",
    "email": "jon.doe@email.com",
    "password_reset": true,
    "role": "ADMIN",
    "membership": "FREE",
    "paid": false,
    "auto_renew": false,
    "created_at": "2021-11-16T18:42:31.699Z",
    "updated_at": "2021-11-17T21:26:26.823Z",
    "last_login": null,
    "renew_at": null,
    "canceled_at": null,
    "deleted_at": null
}

I hope this helps! It took me all day to work this out so hopefully it saves you some time.

Andy
  • 59
  • 2
  • 11

1 Answers1

0

Rather Than Doing this by associating roles in each entity, i would suggest to store Roles in another table, with their id's and make an association table for roles mapping to users, and add it to JwTAuthGuard, that whenever a user sign-in. the auth guards searches for roles that are accessible by the user, and thus limits the access.

That would be a more scalable approach

  • Great reply, that sounds really helpful, any chance you can share code examples or references to resources to help implement this? – Andy Nov 18 '21 at 21:07