Guard Sebelum Payload: Tulis Persyaratan Dokumen Global-Only
Checklist dokumen lintas modul bisa ditulis siapa pun yang punya izin tulis layanan. Guard kini menolak sebelum payload dibaca, dan penolakannya diam.
Ringkasan
Akun uji bisa mengubah dokumen. Guard ditambah untuk cek akses. Bidang-scoped ditolak sebelum validasi.
Editor persyaratan dokumen saya buka dari akun uji yang hanya memegang layanan:write. Tidak ada menu admin dinas di sidebar, tidak ada penanda bahwa akun ini seharusnya boleh menyentuh checklist FRM-01 dan FRM-02. Tombol simpan tetap jalan, dan perubahannya langsung tersimpan.
Checklist itu bukan konten satu bidang. Seluruh alur peminjaman lintas modul membaca daftar dokumen yang sama, jadi satu baris yang dihapus mengubah syarat pengajuan untuk semua dinas. Commit ebe34a2 menutup celah ini di empat file, tambah 75 baris, buang 12.
Guard yang tidak menunggu payload
Perbaikannya bukan menambah cabang di handler. Di lapisan service modul persyaratan dokumen muncul sentinel ErrForbidden dan satu fungsi kecil guardWrite yang memanggil scope.IsGlobal. Handler sekarang meneruskan middleware.BidangFrom dari context request ke svc.UpdateItems, sehingga keputusan scope tidak lagi diambil dari body.
Urutannya yang penting. Guard berjalan sebelum validasi form dan sebelum repository disentuh sama sekali. Permintaan dari akun bidang-scoped ditolak tanpa pernah bergantung pada payload yang valid. Kalau guard diletakkan setelah decode dan validasi, penolakan baru terjadi setelah kerja yang tidak perlu, dan setiap kegagalan validasi berubah jadi jalan memutar menuju 400, bukan 403.
func guardWrite(bidang string) error {
if scope.IsGlobal(bidang) {
return nil
}
return ErrForbidden
}
Saya memilih sentinel error, bukan nilai boolean. Pemanggil tidak bisa lupa memeriksa hasil, karena err != nil sudah menjadi jalur wajib di setiap fungsi Go.
Menolak dengan detail yang diam
respondError mendapat satu case baru. errors.Is terhadap ErrForbidden dijawab 403 FORBIDDEN dengan detail statis "persyaratan dokumen hanya dikelola admin dinas". Tidak ada nilai bidang milik pemohon yang dipantulkan kembali. Komentar di kode menyebutnya eksplisit: jangan pernah menggemakan nilai pemohon.
Alasannya bukan estetika pesan. Kalau detail error menyebut bidang yang dikirim pemohon, respons berubah menjadi konfirmasi tentang scope apa yang sedang dipegang akun tersebut. Penolakan yang membocorkan input pemohon berfungsi sebagai alat pemetaan.
Bentuk 403 sendiri sudah tepat. RFC 9110 menyatakan server memahami permintaan tetapi menolak memenuhinya [5]. MDN menambahkan bahwa kegagalan itu terikat pada logika aplikasi, bukan pada format request, dan klien yang menerima 403 sebaiknya menduga pengulangan tanpa perubahan akan gagal dengan error yang sama [6]. Karena itu detail statis justru konsisten: penolakan ini tidak akan berubah sampai scope akun berubah.
Test yang menghitung panggilan repository
TestUpdateItemsGlobalOnly mengulang tiga nilai bidang: "budaya", "olahraga", dan string kosong. Setiap iterasi memastikan ErrForbidden muncul dan repo.calls tetap nol. Fake repository di test itu menghitung setiap panggilan, jadi implementasi yang menolak tetapi tetap menyentuh repository lebih dulu akan gagal.
Kasus string kosong yang paling berguna. Bidang yang tidak terbaca dari context tidak boleh diperlakukan sebagai "tidak ada batasan". Prinsip deny-by-default di OWASP menuntut hal yang sama: akses ditolak secara default, dan pemeriksaan akses yang gagal adalah kejadian normal yang harus ditangani dengan aman [2].
Sisi test integrasi juga berubah. Jalur bahagia sekarang mengirim middleware.BidangAll secara eksplisit. Test itu jadi dokumentasi: untuk mengubah checklist, request harus membawa scope global, bukan sekadar lolos permission gate.
Baca tetap terbuka, tulis menyempit
List sengaja tidak dijaga. Petugas di bidang mana pun masih perlu membaca checklist saat memverifikasi pengajuan, dan gate permission-nya tidak berubah sama sekali. Yang menyempit hanya jalur tulis. Model dua himpunan baca-tulis sudah dibahas di tulisan terpisah; yang belum terjawab di sana adalah urutan guard di jalur tulis dan bentuk penolakannya.
Ini penerapan least privilege yang biasa: hak minimum untuk menyelesaikan pekerjaan [2]. Membaca daftar dokumen adalah bagian pekerjaan verifikasi. Mengubahnya adalah keputusan kebijakan dinas, bukan tugas tiap bidang.
Review gabungan kode dan security menyetujui commit ini dengan satu catatan informasional: decode JSON yang gagal tetap menghasilkan 400 sebelum service sempat menjawab 403. Saya menerimanya. Keduanya terjadi sebelum ada state yang berubah, dan 400 sudah menyatakan body-nya tidak terbaca, bukan menyatakan apa pun tentang scope.
Satu hal yang saya ubah saat menulis guard ini: parameternya tetap string bidang, bukan context. Service jadi bisa diuji tanpa HTTP, dan urutan guard bisa dibuktikan lewat penghitung panggilan repository, bukan lewat asumsi.
Sumber: