Ketika Sentinel ALL Menghilangkan Isi Daftar Admin
Satu string ALL dari middleware diperlakukan sebagai nilai kolom, dan daftar pengumuman kehilangan seluruh record ber-bidang.
Ringkasan
Ada bug seru: super admin malah dapat daftar pengumuman kosong, padahal datanya aman di database. Ternyata scope ALL dari middleware dikira nilai bidang biasa sama repository, plus baris global yang NULL memang gak bakal kena perbandingan "=" biasa. Fix-nya gampang, ALL dianggap tanpa-filter terus tambah IS NULL di query, dikunci pake regression test.
Daftar pengumuman di panel admin sebuah portal kota tiba-tiba menyusut. Akun super admin, yang seharusnya melihat seluruh catatan, justru menerima daftar tanpa satu baris pun. Data dengan nilai bidang tertentu seperti budaya atau olahraga tidak muncul sama sekali, padahal query manual ke database membuktikan baris-baris itu ada.
Regresi ini ditandai severity HIGH pada laporan QA. Dugaan pertama mengarah ke middleware otentikasi: mungkin token tidak membawa scope yang benar. Pemeriksaan membantahnya. Token valid, signature benar, dan scope yang dikirim sesuai ekspektasi: string ALL untuk super admin. Justru nilai yang "benar" itulah yang memicu masalah.
Akar masalahnya ada di cara repository menerjemahkan scope menjadi filter SQL. Middleware menempatkan string ALL di field scope untuk super admin. Repository memperlakukannya sebagai nilai literal kolom bidang. Query yang terbentuk menjadi bidang = 'ALL', dan tidak ada satu baris pun di database yang punya nilai itu.
Mengapa dua layer gagal berbicara
Field yang sama membawa dua semantik. Di layer otentikasi, ALL adalah sentinel: nilai khusus yang berarti "seluruh bidang", bukan nama bidang. Di layer data, baris global ditandai NULL. Manual MySQL menjelaskan bahwa NULL tidak pernah benar dalam perbandingan dengan nilai apa pun, termasuk dengan NULL sendiri, sehingga pencariannya wajib memakai operator IS NULL [1].
Cara termudah membuktikan sifat NULL ini adalah mencoba sendiri: buat tabel kecil dengan satu kolom yang boleh NULL, isi satu baris dengan NULL dan satu baris dengan nilai biasa, lalu jalankan SELECT ... WHERE kolom = NULL. Hasilnya nol baris, dan baris NULL baru muncul ketika query memakai IS NULL.
bidang IS NULL OR bidang = ?, baris NULL dianggap global dan selalu tampil. Begitu parameter diisi string ALL, pola yang sama hanya mencari baris ber-bidang "ALL" yang memang tidak pernah ada.Sentinel ini sebenarnya lahir di SQL boundary auth. Respons login memakai COALESCE(bidang,'ALL') sehingga super admin tanpa nilai bidang membawa pulang string ALL di sesinya. Lapisan itu sudah benar. Yang hilang adalah konversi ketika nilai itu tiba di layer query: satu nilai, dua asumsi makna di dua layer berbeda.
Konversi di titik masuk query
Perbaikannya tidak menambah mode filter baru. Repository hanya mengakui bahwa field itu kini punya dua nilai yang berarti tanpa-filter: string kosong dan sentinel ALL. Di luar dua nilai itu, filter scope berjalan seperti biasa.
if q.Bidang != "" && !scope.IsGlobal(q.Bidang) {
where += " AND (a.bidang IS NULL OR a.bidang = ?)"
args = append(args, q.Bidang)
}Fungsi IsGlobal sendiri hanya satu perbandingan: true ketika nilainya ALL, selain itu false. Kesederhanaan itu justru poinnya: konversi sentinel seharusnya bisa dibaca sekilas, bukan disembunyikan di balik logika bertingkat.
Dua lapis desain ini disengaja. Repository sengaja fail-open untuk nilai tanpa-filter, sementara middleware fail-closed: bidang tak dikenal berarti tidak ada modul yang boleh dikelola. OWASP menuliskan prinsipnya secara tegas: mekanisme keamanan sebaiknya gagal lewat execution path yang sama dengan penolakan akses [2]. Kedua layer memenuhi prinsip itu di tempat yang berbeda, dan bug terjadi tepat di sambungan di antara keduanya.
Regression test sebagai pengunci
Tanpa test, perbaikan seperti ini rawan terulang di refactor berikutnya. Test integrasinya sederhana: seed satu pengumuman global ber-bidang NULL dan satu ber-bidang budaya, lalu jalankan tiga skenario. Scope ALL harus melihat dua-duanya. Scope budaya juga melihat dua, termasuk yang global. Scope olahraga hanya melihat satu, yaitu baris global. Kerangka testing Go menyediakan polanya: kegagalan ditandai lewat T.Error atau semacamnya di dalam fungsi TestXxx yang dijalankan go test [3].
Siapa pun kelak mengubah query dan lupa menyentuh sentinel, skenario pertama langsung merah.
Perlu dicatat, pola semacam ini tidak terbatas pada bidang. Multitenant schema memakai pola yang sama: kolom tenant_id yang NULL berarti milik platform, sementara aplikasi punya peran internal yang boleh melihat semuanya. Setiap kali dua semantik itu bertemu di satu kolom, konversi di boundary adalah pertanyaan pertama yang layak diajukan, bukan setelah data hilang.
Sources: