Skip to content

Sidebar Admin Per-Modul: Ilusi Aman di UI, Sejati di API

Adityo Guni Waluyo

Menyembunyikan menu di sidebar admin bukan otorisasi. Pengalaman membangun halaman konten per-modul dengan sidebar ter-scope bidang.

Ringkasan

Awalnya ngira cukup sembunyiin menu sidebar biar aman, ternyata itu cuma kosmetik doang. Soalnya cek di layout Nextjs bisa basi dan gampang dibobol lewat manipulasi URL manual. Akhirnya solusinya dobel lapis, frontend cuma buat UX, backend pakai NarrowModules yang balikin list kosong biar nggak bocorin info.

Pertama kali menatap kode sidebar admin, saya merasa sudah selesai. Fungsi canSeeModule di admincms/layout.tsx berjalan mulus. Modul yang tidak berhak diakses user langsung hilang dari daftar navigasi. UI terlihat bersih, logika terasa rapi. Saya awalnya kira ini sudah cukup untuk mengamankan admincms halaman per modul sidebar bidang.

Ternyata, itu cuma ilusi keamanan.

Saat saya menambahkan empat halaman modul baru (budaya, ekonomi-kreatif, kepemudaan, dan olahraga), saya sadar ada celah mendasar. Saya mencoba memanipulasi URL secara manual di browser, mengarahkannya ke modul yang seharusnya tersembunyi bagi role tertentu. Dan ternyata, jika layer di bawahnya tidak diverifikasi, data tetap bisa bocor. UI yang menyembunyikan menu hanyalah lapisan kosmetik, **bukan otorisasi** yang sebenarnya.

Kenapa cek di layout jadi basi

Masalahnya terletak pada cara framework bekerja. Layout di Next.js dirancang untuk menjaga state dan tidak melakukan rerender penuh saat navigasi klien terjadi [1]. Artinya, pemeriksaan otorisasi yang hanya diletakkan di level layout bisa menjadi usang (stale) seiring berjalannya waktu atau perubahan state sesi. Pengguna mungkin saja log out atau role-nya diturunkan di tab lain, tetapi layout wrapper tidak serta-merta mengevaluasi ulang logika canSeeModule tersebut. Panduan otentikasi Next.js sendiri dengan tegas menyarankan untuk melakukan pemeriksaan **dekat dengan sumber data** [8].

Dua lapis: UX optimis, API yang menahan

Saya memisahkan tanggung jawab menjadi dua lapisan yang jelas: UX optimis di frontend, dan pertahanan ketat di backend.

Di sisi frontend, BIDANG_MODULE_SLUGS dan canSeeModule tetap dipakai. Tujuannya murni untuk kenyamanan. Kita tidak mau membanjiri user dengan menu yang pada akhirnya akan ditolak oleh server. Saya juga memastikan elemen navigasi ini memiliki role ARIA yang tepat sebagai landmark, supaya screen reader bisa mengenali kelompok tautan utama ini dengan baik dan memudahkan navigasi bagi pengguna disabilitas [2].

Untuk menjaga performa saat daftar modul ini dirender, proses filtering kategori di komponen editor saya bungkus dengan useMemo. Ini memastikan hasil filter di-cache dan tidak dihitung ulang kecuali dependensinya benar-benar berubah [3]. Sisi pasangannya, pengambilan daftar kategori sendiri adalah sinkronisasi dengan sistem eksternal, jadi tempatnya memang di effect yang mengikuti perubahan scope [7]. Jika ada efek samping dari pemuatan izin pengguna, itu ditangani secara terpisah agar tidak memblokir render awal.

Tapi ini hanya lapisan luar. Pertahanan yang sesungguhnya ada di api/internal/shared/scope/scope.go.

// Nilai balik ok=false berarti module yang diminta di luar scope.
func NarrowModules(requested string, allowed []string) ([]string, bool) {
	if requested == "" {
		return allowed, true
	}
	if !In(allowed, requested) {
		return nil, false
	}
	return []string{requested}, true
}

Di sini, fungsi `NarrowModules` bekerja sebagai penjaga gerbang yang sebenarnya. Ketika `entityadmin/handler.go` menerima permintaan dengan parameter `?module=` yang berada di luar cakupan user, sistem tidak langsung melempar error 403 Forbidden yang berisik.

Sebaliknya, API tersebut mengembalikan daftar kosong.

Ini adalah pola **fail-closed** yang disengaja. Mengembalikan daftar kosong memberikan sinyal UX yang sama persis ke frontend (tidak ada data untuk ditampilkan), tetapi sekaligus mencegah enumerasi modul yang valid oleh pihak yang tidak berwenang. Yang lebih penting, pendekatan ini tidak membebani database dengan kueri yang tidak perlu karena sistem langsung memotong akses di layer scope sebelum kueri dieksekusi.

Saya sempat ragu di awal. Bukannya 403 itu lebih eksplisit dan standar? Setelah menimbang, saya sadar bahwa untuk endpoint daftar (list endpoints), konsistensi respons jauh lebih aman. Jika kita mengembalikan 403 untuk modul A, tapi 200 OK untuk modul B, kita secara tidak sengaja memberi tahu penyerang bahwa modul B itu ada dan valid di sistem. Daftar kosong menghapus kebocoran informasi itu sama sekali.

Untuk memastikan perilaku ini tidak mengalami regresi di masa depan, saya menambahkan spesifikasi e2e di `modules-nav.spec.ts`. Tes ini secara eksplisit memverifikasi bahwa navigasi ke modul di luar cakupan tidak hanya menyembunyikan tautan di sidebar, tetapi juga memastikan endpoint API merespons dengan aman saat dipaksa melalui manipulasi URL.

Pada akhirnya, membangun fitur ini mengajarkan saya satu hal. Jangan pernah percaya pada apa yang disembunyikan oleh UI. Otorisasi yang baik itu tidak terlihat, tapi ia bekerja keras di lapisan data, memastikan setiap permintaan diverifikasi ulang tepat sebelum data itu diserahkan.

Sources:

Artikel terkait