Scope Pengumuman: Menulis Nilai vs Mengubah Baris
Dua fungsi otorisasi pengumuman per bidang: resolveBidang untuk nilai baru, canManage untuk baris eksisting, NULL sebagai global.
Ringkasan
Dulu semua admin bebas ubah pengumuman apa aja, sekarang dibatasi per bidang biar nggak tabrakan. Dibuat dua fungsi misah, resolveBidang buat nentuin boleh nulis ke bidang apa dan canManage buat ngecek boleh ngedit baris punya siapa. Konten global ditandai NULL bukan ALL, cuma admin ALL yang bisa ngatur, dan ceknya dipasang sebelum data keubah.
Pengumuman di CMS dinas ini awalnya flat: semua admin bisa edit semua pengumuman. Begitu portal dipecah per bidang, pertanyaan pertama yang muncul bukan soal UI, tapi soal aturan mainnya. Pas mau nulis kodenya saya berhenti sebentar: cukup satu fungsi pengecekan, atau perlu dua?
Tebakan awal saya satu cukup. Toh kelihatannya sama-sama cuma ngecek bidang. Ternyata begitu dijabarkan ke skenario, ada dua pertanyaan yang beda dan jawabannya beda.
Menulis Nilai Baru Itu Satu Soal
Fungsi pertama, resolveBidang, jawabannya untuk pertanyaan: penulis ini berhak nulis ke bidang apa?
Admin ALL bebas: mau bikin pengumuman global (nilainya dikosongkan), mau bikin untuk OLAHRAGA atau BUDAYA, semua sah. Penulis ber-bidang dipaksa ke bidangnya sendiri. Kalau dia ngirim OLAHRAGA padahal dia dari PARIWISATA, request-nya ditolak 403 dengan pesan yang nyebut bidang tujuannya. Nilai di luar daftar juga kena validasi error, bukan diam-diam dirapikan.
Detail yang gampang kelewat: konten global ditandai NULL di database, bukan nilai ALL. Migration 000057 nambah kolom announcements.bidang bertipe ENUM yang boleh NULL plus index, dan 35 pengumuman lama tetap kebaca semua admin karena NULL diartikan lintas bidang. Nilai ALL di enum itu buat tabel lain; di pengumuman, NULL yang jadi sentinel global.
Jalanin skenarionya di kepala gini: penulis OLAHRAGA bikin pengumuman, bidangnya otomatis OLAHRAGA, selesai. Dia coba edit pengumuman BUDAYA punya rekan kerja, guardScope nabrak duluan sebelum satu baris pun keubah. Sisanya dikerjakan unit test; saya tinggal percaya kalau kombinasi lupa-lupun baru ketangkep di situ, bukan di depan user.
Baris Lama Itu Soal Kedua
Fungsi kedua, canManage, jawabannya untuk pertanyaan beda: baris yang mau disentuh ini milik siapa?
Boleh bikin data berlabel OLAHRAGA nggak otomatis bikin berhak ngedit baris OLAHRAGA punya orang lain. Aturannya: konten global (NULL) cuma bisa dikelola admin ALL; konten ber-bidang cuma bisa disentuh pemiliknya. guardScope memasang cek ini di Update, Trash, Restore, sampai DeletePermanent, dan semuanya sebelum mutasi jalan. Unit test TestCanManage ngunci kombinasinya, termasuk kasus penulis OLAHRAGA nggak boleh nyentuh baris global.
Dua fungsi ini sengaja nggak digabung. OWASP nulis prinsip Deny by Default di Authorization Cheat Sheet-nya: aplikasi nggak boleh netral, harus selalu memutuskan deny atau permit, bahkan saat nggak ada aturan yang cocok [4]. Tabel kecil di cheat sheet yang sama juga ngingetin supaya cek otorisasi ditaruh di lokasi yang tepat; di sini artinya di service layer, sebelum request nyampe repository [6]. Gabungin dua pertanyaan ke satu fungsi bikin aturan scope nggak bisa dinalar, dan itu persis celah yang dicari orang.
Sisi editor-nya nggak kalah penting. Form informasi baca session.user.bidang, dan opsi dropdown bidang cuma dirender buat admin global. Penulis ber-bidang nggak perlu mikir: bidangnya otomatis ngikut, field-nya nggak mungkin diisi nilai liar. Kombinasinya pas sama aturan server: UI yang nyembunyiin opsi, server yang menolak sisanya.
Perjalanan datanya juga kelihatan di tanda tangan fungsi. Create dan Update sekarang nerima requesterBidang dari session, bukan dari payload yang bisa dipalsukan. Handler tinggal jadi tukang terus: ambil identitas dari middleware, oper ke service. Pesan 403-nya pun spesifik, nyebut bidang tujuan atau cuma bilang data ini milik bidang lain, jadi pas admin nabrak aturan, dia ngerti harus minta bantuan ke siapa. Pesan generik 403 itu enak buat logs, nyebelin buat manusia yang kena.
Soal tabel hidup: migration additive ini aman dijalankan. Di MySQL 5.7, ALTER TABLE yang jatuh ke algoritma COPY memblokir DML konkuren; ADD COLUMN semacam ini umumnya jalan INPLACE yang tetap mendukung DML [5]. Baris lama nggak disentuh, jadi deploy-nya tanpa jendela khusus.
Soal konten global, saya ambil keputusan: pengumuman harian dinas diberi bidang sejak lahir, sentinel NULL disisain buat yang bener-bener lintas bidang kayak pengumuman libur nasional. Kalau semua rutinitas dipaksa lewat admin ALL biar bisa bikin global, antrian admin ALL malah jadi bottleneck baru. Sentinel yang langka itu lebih berguna jadi penanda pengecualian, bukan jalur default.
Pendapat saya tegas: pisahkan resolveBidang dan canManage sejak awal. Menulis nilai baru dan mengubah baris eksisting itu dua permukaan otorisasi yang kebetulan nempel di satu fitur, bukan satu pertanyaan yang dipaksa jadi satu fungsi.
Satu catatan buat yang mau nurut pola ini: 35 baris lama tadi nggak disentuh migration, jadi keputusan soal konten global itu keputusan operasional yang jalan terus, bukan sekadar pilihan skema. Konvensinya perlu ditulis di dokumentasi tim, karena NULL yang punya makna kebijakan gampang banget dibaca salah sebagai data belum diisi.