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.
- Create your custom interceptor
- Decorate your controller functions (GET, POST etc) with your custom interceptor
- 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.