Backend
Role-based access control (RBAC) di NestJS dengan Guard dan Decorator

Mengapa Otorisasi sering jadi afterthought
Banyak developer Nest JS yang sudah paham autentikasi (siapa kamu?) tapi mengimplementasi otorisasi (kamu boleh ngapain?) secara ad-hoc, pengecekan tersebar di mana-mana, tidak konsisten, dan sulit di maintain.
Di artikel ini kita akan bangun sistem RBAC yang rapi dari nol: mendefinisikan roles, membuat custom decorator @Roles(),membangun RolesGuard yang membaca metadata dari decorator tersebut, dan mengintegrasikannya dengan JWT auth yang sudah ada. Semua dengan TypeScript penuh dan tanpa library RBAC eksternal.
Yang akan kita bangun: endpoint yang bisa di proteksi seperti ini -
@Get('users')
@Roles(Role.ADMIN)
findAll() { ... }Konsep dasar RBAC dan posisinya di NestJS
sebelum coding, penting untuk paham dulu apa yang kita bangun dan kenapa.
Apa itu RBAC? RBAC adalah model otorisasi dimana akses ke resource ditentukan oleh role yang dimiliki user, bukan oleh identitas user secara langsung. User budi@mail.com bisa mengakses endpoint admin bukan karena dia Budi, tapi karena dia punya role ADMIN.
Tiga lapisan keamanan di NestJS yang perlu dipahami posisinya masing-masing:
- Middleware - berjalan sebelum route handler, cocok untuk logging atau parsing token kasar.
- Guard - berjalan setelah middleware tapi sebelum handler, cocok untuk autentikasi dan otorisasi. Ini tempatnya RBAC kita.
- Interceptor & Pipe - berjalan setelah guard, untuk transformasi data dan validasi input.
RBAC yang kita bangun hidup di lapisan Guard, karena Guard punya akses ke ExecutionContext - dari sana kita bisa baca route metadata (roles yang diizinkan) dan user object (role yang dimiliki user).
Alur request yang akan kita bangun:
Request -> JwtAuthGuard (siapa kamu?) -> RolesGuard (boleh tidak?) -> Handler
JwtAuthGuard memvalidasi token dan menaruh user object ke request.user. RolesGuard kemudian membaca request.user.role dan membandingkan dengan metadata yang dipasang oleh decorator @Roles().
Setup awal: enum Role dan struktur project
Mulai dari fondasi yang benar akan menghemat refactor di kemudian hari.
Definisikan Role sebagai enum, bukan string literal. Ini penting untuk type safety dan mencegah typo yang bisa jadi bug silent.
export enum Role {
USER = 'user',
MODERATOR = 'moderator',
ADMIN = 'admin',
}Kenapa enum dan bukan as const object? Keduanya valid, tapi enum lebih mudah dipakai di decorator parameter dan Swagger documentation. Kalau kamu prefer as const, kita bahas trade-off nya di catatan dibawah section ini.
Tambahkan role ke User entity. Asumsikan kamu sudah punya entity User (TypeORM atau prisma - sama saja):
Import { Role } from '../common/enums/role.enum';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column({ type: 'enum', enum: Role, default: Role.USER })
role: Role;
}Struktur folder yang rapi untuk fitur ini:
src/
common/
decorators/
roles.decorator.ts
enums/
role.enum.ts
guards/
jwt-auth.guard.ts ← sudah ada, tidak kita ubah
roles.guard.ts ← yang akan kita buat
auth/
...
users/
...Menaruh decorator dan guard di common/ penting karena keduanya dipakai lintas module. Jangan taruh di dalam module tertentu.
Membuat custom decorator @Roles()
Decorator adalah cara NestJS menyimpan metadata ke route handler. Kita akan buat decorator @Roles() yang menerima satu atau lebih role sebagai argument.
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);Hanya tiga baris, tapi ada beberapa hal penting di sini:
SetMetadata adalah fungsi dari @nestjs/common yang menyimpan key-value pair ke metadata route. ROLES_KEY kita export sebagai konstanta agar RolesGuard bisa membaca metadata dengan key yang sama persis, tidak ada magic string yang duplikat.
Decorator ini menerima rest parameter ...roles: Role[] sehingga bisa dipakai fleksibel:
@Roles(Role.ADMIN) // satu role
@Roles(Role.ADMIN, Role.MODERATOR) // beberapa role sekaligusCatatan penting: @Roles() hanya menyimpan metadata. Dia tidak memproteksi apa pun sendirian. Proteksi yang sebenarnya ada di Guard yang akan kita buat berikutnya.
Membangun RolesGuard
Ini inti dari artikel. Guard adalah class yang mengimplementasi interface CanActivate dan mengembalikan boolean — true berarti request boleh lanjut, false berarti ditolak (403 Forbidden).
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}Mari bedah baris per baris:
Reflector adalah service NestJS khusus untuk membaca metadata yang disimpan oleh SetMetadata. Kita inject lewat constructor.
getAllAndOverride membaca metadata dari dua level: method handler (context.getHandler()) dan class controller (context.getClass()). Kalau ada @Roles() di method, itu yang menang — kalau tidak ada, fallback ke level class. Ini behavior yang paling intuitif.
Kalau requiredRoles adalah undefined (route tidak punya decorator @Roles()), guard return true — artinya route tersebut terbuka untuk semua authenticated user. Ini disebut opt-in protection.
user?.role === role — kita pakai optional chaining karena user bisa undefined kalau JwtAuthGuard belum dipasang di depannya.
Kenapa some() dan bukan every()? Karena kita ingin logika OR: user perlu punya salah satu dari role yang diizinkan, bukan semua role sekaligus.
Integrasi dengan JwtAuthGuard dan registrasi global
Guard yang sudah dibuat perlu diregistrasi. Ada dua pendekatan dengan trade-off berbeda.
Pendekatan 1: global guard (direkomendasikan untuk sebagian besar kasus)
Daftarkan kedua guard secara global di AppModule:
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}Urutan array penting: JwtAuthGuard harus lebih dulu karena RolesGuard bergantung pada request.user yang diisi oleh JWT guard.
Dengan pendekatan ini, semua route otomatis memerlukan autentikasi dan pengecekan role. Untuk route publik (login, register), tambahkan decorator @Public():
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);Lalu baca metadata ini di JwtAuthGuard:
// di dalam JwtAuthGuard.canActivate()
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;Pendekatan 2: per-controller atau per-route
Pakai @UseGuards() hanya di tempat yang butuh. Lebih eksplisit tapi mudah lupa dipasang:
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController { ... }Kapan pilih pendekatan 2? Kalau aplikasimu campuran antara route publik dan privat, dan tim sepakat bahwa proteksi harus eksplisit — bukan default.
Implementasi di controller
import { Controller, Get, Delete, Param } from '@nestjs/common';
import { Roles } from '../common/decorators/roles.decorator';
import { Role } from '../common/enums/role.enum';
@Controller('users')
export class UsersController {
@Get()
@Roles(Role.ADMIN)
findAll() {
return this.usersService.findAll();
}
@Get('profile')
// tidak ada @Roles() → semua authenticated user boleh akses
getProfile(@Request() req) {
return req.user;
}
@Delete(':id')
@Roles(Role.ADMIN, Role.MODERATOR)
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}Perhatikan getProfile tidak punya @Roles() — karena RolesGuard return true kalau tidak ada metadata, semua user yang sudah login bisa akses. Ini behavior yang kita inginkan.
@Roles() di level method selalu override @Roles() di level class, jadi kamu bisa set default role di class lalu override di method tertentu yang butuh permission lebih longgar atau lebih ketat.
Error Handling dan Pesan yang Informatif
Default behavior saat RolesGuard return false adalah 403 Forbidden dengan body yang generik. Kita bisa buat lebih informatif:
canActivate(context: ExecutionContext): boolean {
// ... kode sebelumnya
const hasRole = requiredRoles.some((role) => user?.role === role);
if (!hasRole) {
throw new ForbiddenException(
`Akses ditolak. Role yang dibutuhkan: ${requiredRoles.join(', ')}`
);
}
return true;
}Satu peringatan keamanan penting: jangan terlalu verbose di production. Pesan seperti "dibutuhkan role ADMIN" bisa memberi informasi ke attacker tentang struktur otorisasi aplikasimu. Pertimbangkan untuk hanya menampilkan pesan detail di environment development:
const message = process.env.NODE_ENV === 'development'
? `Dibutuhkan role: ${requiredRoles.join(', ')}`
: 'Akses ditolak';
throw new ForbiddenException(message);Testing Guard dan Decorator
Guard adalah pure class yang mudah di-unit test karena semua dependency masuk lewat constructor.
import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { Role } from '../enums/role.enum';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
guard = new RolesGuard(reflector);
});
it('harus return true kalau tidak ada roles metadata', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockContext({ role: Role.USER });
expect(guard.canActivate(context)).toBe(true);
});
it('harus return true kalau user punya role yang sesuai', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
const context = createMockContext({ role: Role.ADMIN });
expect(guard.canActivate(context)).toBe(true);
});
it('harus return false kalau user tidak punya role yang dibutuhkan', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
const context = createMockContext({ role: Role.USER });
expect(guard.canActivate(context)).toBe(false);
});
});
function createMockContext(user: Partial<{ role: Role }>): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}Helper createMockContext adalah pattern yang sangat berguna — simpan di test/helpers/ agar bisa dipakai di semua guard test.
Apa yang sudah dibangun dan ke mana selanjutnya
Kita sudah punya sistem RBAC yang:
- Type-safe dari ujung ke ujung lewat enum
Role - DRY — logika proteksi terpusat di
RolesGuard, bukan tersebar di tiap handler - Deklaratif — membaca kode controller cukup untuk tahu siapa yang boleh akses apa
- Mudah di-test karena guard adalah pure class tanpa side effect
Limitasi yang perlu disadari: sistem ini flat — satu user punya satu role. Untuk kebutuhan yang lebih kompleks (user punya banyak role, permission granular per resource, atau ABAC/attribute-based access control), kamu butuh arsitektur yang lebih kompleks — misalnya dengan library seperti CASL.
Langkah selanjutnya yang bisa ditulis sebagai seri:
- Integrasi
@Roles()dengan Swagger/OpenAPI untuk dokumentasi otomatis - Multi-role per user: ubah
role: Rolemenjadiroles: Role[]dan sesuaikan guard - Row-level security: proteksi bukan hanya "boleh akses endpoint ini" tapi "boleh akses resource milik user ini"