Tujuh Guard Berurutan: Bagaimana Otorisasi Tumbuh di API NestJS Saya
Sebelas API NestJS, dan tumpukan guard-nya tumbuh dengan pola yang sama di tiap proyek: dua guard, lalu tiga, lalu tujuh. Apa yang diajarkan urutan itu, dan satu bug fail-open yang bersembunyi di pertemuan row-level security dengan default yang permisif.

Otorisasi dimulai dari satu baris. @UseGuards(AuthGuard) — selesai. Token-nya valid, user-nya nyata, silakan masuk.
Lalu ada yang minta endpoint khusus admin. Lalu admin ternyata kurang spesifik. Lalu produknya jadi multi-tenant, dan tiba-tiba “apakah user ini boleh?” sama sekali bukan satu pertanyaan — itu enam pertanyaan, dan semuanya harus ditanyakan berurutan.
Saya menengok kembali sebelas API NestJS yang pernah saya bangun dan menjajarkan bagaimana tumpukan guard-nya tumbuh di masing-masing. Ternyata hasilnya satu garis lurus:
- Dua guard — auth, lalu roles
- Tiga guard — auth, verified, lalu permission
- Tujuh guard — rantai penuh yang sadar tenant
Tidak ada satu pun dari itu yang dirancang sejak awal. Tiap lapisan muncul di minggu yang sama dengan kebutuhannya.
Tahap satu: role itu sebuah string
Versi pertamanya adalah versi yang semua orang tulis:
const requiredRoles = check<string[]>(ROLES_KEY) ?? [];
if (requiredRoles.length && !requiredRoles.includes(user.role)) {
throw new ForbiddenException("Insufficient role");
}user.role itu string. Guard-nya membandingkannya dengan apa pun yang ditaruh @Roles('admin') di handler. Dua puluh baris, dan sejujurnya sudah cukup untuk sementara.
Itu berhenti cukup pada momen yang bisa ditebak: ketika Anda ingin sebuah role yang bisa melakukan hampir semua yang admin bisa. Anda tambahkan editor. Lalu editor butuh satu kewenangan admin, jadi Anda tambahkan senior-editor. Daftar role-nya diam-diam berubah jadi daftar orang, bukan daftar pekerjaan.
Tahap dua: permission, dan masalah urutan yang pertama
Perbaikannya adalah berhenti menanyakan siapa seseorang dan mulai menanyakan apa yang boleh dia lakukan. Role berubah jadi kantong berisi permission code, dan guard-nya memeriksa code:
const userPermissions = new Set(user.role.permissions.map((p) => p.permission.code));
const required = check<PermissionCode[]>(PERMISSIONS_KEY) ?? [];
if (required.length && !required.some((p) => userPermissions.has(p))) {
throw new ForbiddenException("Insufficient permission");
}Kira-kira di waktu yang sama muncul guard kedua karena alasan yang sama sekali lain: user yang belum terverifikasi bisa mendaftar lalu langsung memanggil API. Itu jadi VerifiedGuard, dan dia memperkenalkan batasan yang membentuk segalanya sesudahnya.
VerifiedGuard membaca req.user.is_verified. Dia hanya bisa begitu karena AuthGuard sudah jalan duluan dan menaruh user di request. Guard-guardnya bukan lagi pemeriksaan yang berdiri sendiri — mereka satu pipeline, dan urutannya adalah bagian dari logikanya.
Tahap tiga: satu deployment, banyak tenant
Multi-tenancy adalah titik di mana tumpukan itu berhenti jadi tumpukan pemeriksaan dan berubah jadi tumpukan konteks. Pertanyaannya berlipat:
- Apakah token-nya valid? → AuthGuard
- Apakah user ini sudah memverifikasi email-nya? → VerifiedGuard
- Tenant mana yang sedang dia pakai saat ini, dan apakah dia anggotanya? → EntityContextGuard
- Apakah module ini aktif untuk tenant tersebut? → ModuleGuard
- Apakah feature flag ini menyala untuk user ini di tenant ini? → FeatureFlagGuard
- Apakah dia punya permission yang diminta endpoint ini? → PermissionGuard
Didaftarkan secara global, berurutan:
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: VerifiedGuard },
{ provide: APP_GUARD, useClass: EntityContextGuard },
{ provide: APP_GUARD, useClass: ModuleGuard },
{ provide: APP_GUARD, useClass: FeatureFlagGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
]NestJS menjalankan guard global sesuai urutan pendaftaran, dan urutan itu menanggung beban. Tiap guard meninggalkan sesuatu untuk guard berikutnya:
- AuthGuard — membaca bearer token, menaruh
req.user - VerifiedGuard — membaca
req.user, tidak menaruh apa-apa - EntityContextGuard — membaca
req.userdan header tenant, menaruhreq.entityIddanreq.entityPermissions - ModuleGuard — membaca
req.entityId - FeatureFlagGuard — membaca
req.entityIddanreq.user - PermissionGuard — membaca
req.userdanreq.entityPermissions
Pindahkan EntityContextGuard ke belakang ModuleGuard dan tidak ada satu pun compile error. Anda cuma dapat ForbiddenException di setiap endpoint yang dijaga module, karena req.entityId belum ada di sana. Dan itu sore yang benar-benar tidak menyenangkan.
Bagian yang nyaris menjebak saya
ModuleGuard menanyakan hal sederhana: apakah module ini aktif untuk tenant ini? Dia mencari satu baris.
Tapi tabel itu dilindungi row-level security Postgres. Policy-nya menyaring baris berdasarkan tenant yang sedang aktif, yang di-set per transaksi lewat sebuah helper:
const record = await this.prisma.forEntity(entityId, (tx) =>
tx.entityModule.findUnique({
where: { entity_id_module: { entity_id: entityId, module } },
}),
);Query tanpa lewat forEntity dan RLS melakukan persis apa yang Anda perintahkan: dia menyembunyikan barisnya. Guard-nya tidak melihat apa-apa.
Dan tidak melihat apa-apa berarti tidak ada baris, dan tidak ada baris berarti aktif — karena default-nya memang sengaja permisif, supaya tenant yang lebih tua dari sebuah module tetap jalan sampai ada yang mematikannya secara eksplisit.
Jadi bug-nya bukan sebuah error. Bug-nya adalah setiap module diam-diam melaporkan dirinya aktif, untuk setiap tenant, tanpa batas waktu. RLS bekerja dengan sempurna. Default permisifnya masuk akal. Digabung, keduanya fail open.
Pelajaran yang saya ambil: kalau sebuah pemeriksaan membaca dari sumber yang terfilter, default yang permisif berhenti jadi kenyamanan dan berubah jadi keputusan keamanan. Entah pembacaannya yang harus dijamin melihat kebenarannya, atau default-nya yang harus dibalik.
Dua default yang mengarah berlawanan
Itulah kenapa dua guard penjaga gerbang di codebase ini sengaja tidak sepakat.
ModuleGuard permisif. Tidak ada baris berarti aktif. Module itu soal provisioning: sebuah tenant menandatangani kontrak lalu mendapat sekumpulan module. Kalau default-nya mati, setiap tenant yang sudah ada kehilangan semuanya di hari guard itu rilis.
FeatureFlagGuard gelap. Flag yang tidak dikenal berarti mati. Flag itu untuk pekerjaan yang belum selesai, dan mode gagal dari flag tak dikenal yang default-nya menyala adalah mengirim sesuatu yang setengah jadi ke semua orang.
Bentuknya sama, susunan file-nya sama, default-nya berlawanan — dan masing-masing benar untuk apa yang dijaganya. Pertanyaannya bukan “apa default yang aman”, tapi “apa arti benda ini ketika belum ada yang mengatakan apa pun tentangnya”.
Guard global yang secara default tidak melakukan apa-apa
Tujuh guard global terdengar berat. Ternyata tidak, karena masing-masing keluar di baris pertamanya kecuali ada handler yang memintanya:
const module = check<string>(REQUIRE_MODULE_KEY);
if (!module) return true;Decorator-nya adalah keseluruhan antarmukanya:
@Public() // skip the chain entirely
@AllowUnverified() // signed in, email not confirmed yet
@RequireEntity() // the tenant header is mandatory here
@RequireModule('inventory')
@RequireFeature('new-reporting')
@Permissions('invoice.create')Handler biasa tanpa decorator tetap melewati ketujuhnya, dan enam di antaranya langsung mengembalikan true di baris pertama. Ongkosnya bisa diabaikan; imbalannya, menambah pemeriksaan ke sebuah endpoint cukup satu decorator, bukan satu hal lagi yang harus diingat untuk dipasang.
Ada keuntungan yang lebih halus. Karena guard-nya global, default untuk endpoint baru adalah terlindungi. Kalau otorisasinya opt-in, mode gagal dari lupa adalah endpoint yang terbuka. Di sini, lupa justru memberi Anda endpoint yang tidak bisa dijangkau siapa pun — menjengkelkan, tapi jauh lebih baik.
Apakah saya akan membangun ketujuhnya lagi?
Tidak di hari pertama. Versi tujuh guard ini cuma ada di dua dari sebelas proyek saya, dan keduanya sistem multi-tenant dengan provisioning per tenant. Sembilan sisanya cukup dengan dua atau tiga, dan menambahkan sisanya cuma jadi ongkos tanpa pembeli.
Yang akan saya lakukan lebih awal justru jauh lebih kecil: begitu dua guard mulai saling bergantung, putuskan bahwa rantainya adalah sebuah pipeline, lalu tuliskan apa yang ditaruh tiap mata rantai di request. Daftar di atas cuma butuh sepuluh menit dan akan menyelamatkan satu sore yang saya habiskan menatap ForbiddenException yang ternyata cuma soal urutan guard yang salah.
Otorisasi bukan satu pemeriksaan. Dia sebuah urutan, dan urutan itulah desainnya.


