Skip to content

Guard yang Menolak Sebelum Payload Diperiksa

Adityo Guni Waluyo

Modul Kelembagaan akhirnya punya pemilik: satu guard di lapisan service, satu status 403 tetap, dan satu grup navigasi yang ikut berpindah.

Ringkasan

Guard menolak akses sebelum memeriksa payload. Fungsi guardWrite memeriksa bidang pemanggil dan menolak jika bukan olahraga atau global. Peta kepemilikan modul sekarang melayani tiga hal sekaligus.

Guard yang menolak sebelum payload diperiksa

Pemicunya satu baris di peta kepemilikan modul: baris bernama Kelembagaan tidak punya bidang pemilik. Selama baris itu kosong, tidak ada satu pun tempat yang bisa menjawab siapa yang boleh menulis modul tersebut. Commit 96e93ae menutup celah itu, tetapi bukan dengan menambah pemeriksaan izin di banyak tempat. Perubahan yang dikerjakan justru satu fungsi kecil di lapisan service, ditambah pemindahan satu item navigasi di sidebar.

Di berkas service modul organisasi ada fungsi baru bernama guardWrite. Isinya hanya menerima dua keadaan: pemanggil dari bidang olahraga atau aktor global; selain itu ditolak dengan error yang sudah disiapkan. Yang penting bukan isi fungsinya, melainkan posisi pemanggilannya. Pada operasi tulis modul, guard itu dijalankan paling awal, sebelum fungsi validasi menyentuh payload.

var ErrForbidden = errors.New("kelembagaan hanya dikelola bidang olahraga")

// guardWrite dijalankan SEBELUM validasi dan sebelum akses repo.
func guardWrite(bidang string) error {
	if bidang == scope.Olahraga || scope.IsGlobal(bidang) {
		return nil
	}
	return ErrForbidden
}

func (s *Service) Create(ctx context.Context, bidang string, req UpsertRequest) (*OrganizationResponse, error) {
	if err := guardWrite(bidang); err != nil {
		return nil, err
	}
	if err := validateUpsert(&req); err != nil {
		return nil, err
	}
	// ... sisanya tidak pernah tercapai untuk pemanggil yang ditolak
}

Urutan itu bukan detail kosmetik. [4] Prinsip deny by default menuntut keputusan akses diambil lebih dulu, dan [1] pemeriksaan akses hanya efektif bila berada di kode server yang tidak bisa diubah pemanggil. Kalau validasi dijalankan lebih dulu, pemanggil terlarang masih bisa memancing pesan kesalahan yang berbeda-beda hanya dengan mengubah body request. Ia belajar bentuk data yang benar dari pesan yang seharusnya bukan urusannya. Menaruh guard di depan menutup jalur belajar itu: payload tidak pernah dibaca, sehingga tidak ada informasi yang bisa disimpulkan dari respons.

Handler admin modul tersebut memetakan error itu lewat errors.Is dari [3] dan mengembalikan satu kalimat tetap: hanya bidang olahraga yang dapat mengelola kelembagaan. Bidang pemanggil tidak disebut, modulnya tidak disebut ulang, dan payload yang dikirim tidak pernah muncul di respons.

[6] Kode status 403 sendiri berada di tempat yang tepat. Status itu menyatakan server memahami permintaan tetapi [2] menolak memenuhinya, dan penolakan itu soal otoritas pemanggil, bukan soal isi permintaan. Beda dengan 400 yang menandakan payload tidak sah, 403 tetap 403 meskipun body yang dikirim sebenarnya cacat. Justru itu yang diuji di unit test: payload tidak valid ditambah bidang terlarang tetap menghasilkan ErrForbidden, bukan error validasi.

Waktu memeriksa handler pertama kali, dugaan awal adalah guard-nya dipasang setelah validasi, karena itu urutan yang paling lazim ditulis. Yang terjadi sebaliknya. Konsekuensinya jelas setelah dibaca ulang: kalau urutannya dibalik, pemanggil dari bidang lain bisa mengirim payload kosong dan menerima pesan validasi, lalu mengirim payload yang makin mendekati benar sampai pesannya berubah. Deny by default tidak memberi ruang untuk negosiasi seperti itu.

Tes yang menghitung panggilan ke repo

Berkas unit test service modul itu menambahkan penghitung repoCalls pada mock repository. Setiap jalur tulis menaikkan angka tersebut, mulai dari pembacaan baris sampai penulisan anggota. Setelah guard menolak, tes memastikan angka itu tetap nol.

func TestService_WriteGuard_Bidang(t *testing.T) {
	for _, bidang := range []string{"budaya", "", "pariwisata", "kepemudaan", "ekraf"} {
		_, err := svc.Create(ctx, bidang, req)
		if !errors.Is(err, organisasi.ErrForbidden) {
			t.Errorf("bidang %q: want ErrForbidden, got %v", bidang, err)
		}
	}
	if repo.repoCalls != 0 {
		t.Errorf("repo tersentuh %d kali pada jalur tolak", repo.repoCalls)
	}
}

Asersi jumlah panggilan itu bagian yang paling berguna dari seluruh commit. Ia membuat posisi guard menjadi properti yang bisa gagal, bukan sekadar niat penulis kode. Siapa pun yang nanti memindahkan guardWrite ke bawah validateUpsert tidak akan membuat tes gagal lewat pesan error yang samar; tesnya langsung menunjukkan repo sudah tersentuh. Aturan urutan yang tadinya cuma hidup di komentar dan pesan commit sekarang punya penjaga sendiri.

Satu batas yang perlu dicatat: tes ini memakai mock di berkas yang sama, jadi ia membuktikan urutan pemanggilan di dalam service, bukan perilaku MySQL di belakangnya. Kalau guard-nya sendiri dilepas dari handler, jalur HTTP tetap perlu tesnya sendiri untuk menangkap bahwa handler tidak lagi memetakan error itu ke 403.

Satu peta kepemilikan, dua konsumen

Setengah kedua commit ada di frontend. Item navigasi Kelembagaan di layout admin sebelumnya tidak punya penanda modul, sehingga selalu dianggap lintas bidang dan muncul dengan ikon mata "baca saja" untuk semua orang. Setelah commit ini item tersebut memakai module: "olahraga", jadi bagi akun olahraga ia masuk grup Bidang Anda, sementara akun bidang lain tetap melihatnya sebagai bacaan.

Grup navigasi yang hanya berisi satu item, Menu Navigasi, ikut hilang dan itemnya bergabung ke grup Sistem. Jumlahnya berkurang satu dari enam menjadi lima. [5] Landmark navigasi sebaiknya dipakai hemat dan setiap landmark yang tersisa punya label sendiri, jadi menghapus satu grup yang cuma membawa satu tautan mengurangi kebisingan tanpa menghilangkan apa pun.

Yang membuat kedua setengah perubahan ini satu cerita adalah sumber datanya. Peta kepemilikan modul sekarang melayani tiga hal sekaligus: guard tulis di service, status 403 yang keluar dari handler, dan grup tempat item itu mendarat di sidebar. Sebelumnya sidebar bisa menjanjikan hak tulis yang ditolak API, dan tidak ada tes yang membuat kedua sisi itu tidak sinkron. Tes e2e navigasi admin menutupnya dengan asersi yang sederhana: tautan Kelembagaan terlihat untuk akun olahraga, dan nama aksesibel Kelembagaan dengan penanda baca saja berjumlah nol. Penanda itulah yang membedakan item pemilik dari item lintas bidang, sehingga asersi tersebut sekaligus mengunci keputusan pemilikannya.

Hikmah yang bisa dibawa ke proyek lain: kalau satu keputusan kepemilikan harus dibaca oleh lebih dari satu bagian sistem, jangan buat dua daftar. Buat satu peta, lalu biarkan guard, kode status, dan navigasi membaca peta yang sama. Menambah pemeriksaan izin di tempat kedua memang cepat, tetapi tidak ada yang akan memberi tahu ketika keduanya mulai berbeda.

Sumber

Artikel terkait