← Semua pembelajaran / Go Nol → Enterprise
Fase 6 · Keamanan

Otorisasi — siapa boleh apa

Autentikasi menjawab "kamu siapa"; otorisasi menjawab "kamu boleh menyentuh yang ini?". Yang kedua jauh lebih sering bocor, karena ia harus diperiksa di setiap objek, bukan sekali di gerbang.

Intisari

  • Broken object level authorization menempati puncak daftar risiko API OWASP — dan bentuknya selalu sederhana: mengganti angka di URL.
  • Pertahanan terkuat bukan if, melainkan kueri yang dibatasi kepemilikan: yang bukan miliknya tidak pernah ditemukan.
  • Menyembunyikan tombol di UI bukan otorisasi.
  • Tulis aturannya di satu tempat (kebijakan per domain) dan panggil dari mana-mana.
  • Setiap aturan butuh tes dari dua sisi: yang boleh dan yang tidak boleh.

Bentuk kerentanannya

GET /api/faktur/1001    → milikku, tampil        ✅
GET /api/faktur/1002    → milik orang lain...    ← apakah tetap tampil?
// ❌ RENTAN — memeriksa "sudah login", tidak memeriksa "ini milikmu"
func (h *Handler) AmbilFaktur(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	f, err := h.repo.Ambil(r.Context(), id)      // siapa pun bisa mengambil apa pun
	...
}

Kelas bug ini tidak akan ditemukan tes jalur bahagia. Semua tes lulus, aplikasi terlihat benar, dan kerentanannya hanya terlihat kalau ada yang sengaja mengetik ID milik orang lain. Karena itu tes "pengguna B mengakses milik pengguna A harus 404" adalah tes yang wajib ada untuk setiap sumber daya.

Pertahanan terkuat: batasi di kueri

// ✅ Kepemilikan jadi bagian dari WHERE. Data orang lain tidak pernah
//    ditemukan — bukan sekadar ditolak setelah ditemukan.
func (r *Repo) AmbilMilik(ctx context.Context, id, penggunaID int64) (Faktur, error) {
	var f Faktur
	err := r.db.QueryRowContext(ctx, `
		SELECT id, nomor, total
		FROM faktur
		WHERE id = $1 AND pengguna_id = $2`, id, penggunaID,
	).Scan(&f.ID, &f.Nomor, &f.Total)

	if errors.Is(err, sql.ErrNoRows) {
		return Faktur{}, ErrTidakDitemukan     // 404, BUKAN 403
	}
	return f, err
}

Kembalikan 404, bukan 403. 403 memberi tahu penyerang bahwa sumber daya itu ada — informasi yang cukup untuk memetakan sistemmu (berapa pelanggan, berapa faktur, ID mana yang aktif). 404 tidak membocorkan apa pun. Kecuali dalam konteks di mana keberadaan objek memang bukan rahasia dan pesan "kamu tidak berhak" lebih membantu pengguna.

Empat lapis, dari yang paling lemah

LapisanCukup sendirian?
Menyembunyikan tombol di UITidak — cuma tampilan
Middleware per grup rute (/admin/*)Untuk peran, ya. Untuk objek, tidak
Pemeriksaan kebijakan di serviceYa, kalau tidak ada jalur yang melewatinya
Kueri dibatasi kepemilikanTerkuat — tidak bisa dilupakan

Kebijakan di satu tempat

// internal/faktur/kebijakan.go — aturan tertulis sekali, dipakai dari mana saja
type Aksi string

const (
	AksiLihat Aksi = "lihat"
	AksiUbah  Aksi = "ubah"
	AksiHapus Aksi = "hapus"
	AksiKirim Aksi = "kirim"
)

func Boleh(u auth.Pengguna, a Aksi, f Faktur) bool {
	switch {
	case u.Peran == auth.PeranAdmin:
		return true

	case u.ID == f.PenggunaID:
		// Pemilik boleh melihat selalu, tapi hanya mengubah yang masih draf.
		switch a {
		case AksiLihat:
			return true
		case AksiUbah, AksiHapus:
			return f.Status == StatusDraf
		}

	case u.Peran == auth.PeranAkunting:
		return a == AksiLihat
	}
	return false
}
// Dipanggil di service — bukan di handler, supaya worker dan CLI ikut terlindungi.
func (s *Layanan) Ubah(ctx context.Context, id int64, ubah Perubahan) error {
	u := auth.DariContext(ctx)

	f, err := s.simpan.Ambil(ctx, id)
	if err != nil {
		return err
	}
	if !Boleh(u, AksiUbah, f) {
		return ErrTidakBerhak
	}
	...
}

Tes dua sisi

func TestKebijakanFaktur(t *testing.T) {
	pemilik := auth.Pengguna{ID: 1, Peran: auth.PeranBiasa}
	orangLain := auth.Pengguna{ID: 2, Peran: auth.PeranBiasa}
	admin := auth.Pengguna{ID: 9, Peran: auth.PeranAdmin}

	draf := Faktur{PenggunaID: 1, Status: StatusDraf}
	terkirim := Faktur{PenggunaID: 1, Status: StatusTerkirim}

	kasus := []struct {
		nama string
		u    auth.Pengguna
		a    Aksi
		f    Faktur
		mau  bool
	}{
		{"pemilik lihat draf", pemilik, AksiLihat, draf, true},
		{"pemilik ubah draf", pemilik, AksiUbah, draf, true},
		{"pemilik ubah terkirim", pemilik, AksiUbah, terkirim, false},
		{"orang lain lihat", orangLain, AksiLihat, draf, false},
		{"orang lain ubah", orangLain, AksiUbah, draf, false},
		{"admin ubah terkirim", admin, AksiUbah, terkirim, true},
	}

	for _, k := range kasus {
		t.Run(k.nama, func(t *testing.T) {
			if got := Boleh(k.u, k.a, k.f); got != k.mau {
				t.Errorf("Boleh() = %v, mau %v", got, k.mau)
			}
		})
	}
}

Perhatikan bahwa separuh kasus di atas adalah kasus negatif. Tes yang hanya membuktikan "yang berhak bisa" tidak membuktikan apa pun tentang keamanan — yang penting justru "yang tidak berhak tidak bisa".

Tempat lain yang sering bocor

TitikKebocoran khas
Sumber daya bersarang/pesanan/7/item/999 — item milik pesanan lain
Endpoint daftarLupa WHERE pengguna_id pada daftar, padahal detailnya aman
Endpoint ekspor / laporanJarang dipakai, jarang diuji, sering melewati kebijakan
Berkas di S3URL yang bisa ditebak; pakai presigned URL berumur pendek
Kunci cacheKunci tanpa ID pengguna → data satu pengguna tersaji ke yang lain
Multi-tenantKueri tanpa tenant_id — kebocoran antar pelanggan (Fase 11)
Mass assignmentBody berisi {"peran":"admin"} diterima langsung (Fase 1)

Kunci cache tanpa identitas adalah kebocoran yang paling sulit dipercaya saat pertama terjadi. Kunci "dasbor" berarti dasbor pengguna pertama disajikan kepada semua orang. Aturannya: setiap kunci cache untuk data personal harus memuat ID pengguna atau ID tenant — dan halaman personal tidak boleh ditandai public di CDN (Fase 9).

Daftar periksa

  1. Setiap endpoint yang menerima ID membatasi kuerinya dengan kepemilikan atau tenant.
  2. Setiap sumber daya punya fungsi kebijakan, dan kebijakannya dipanggil di service.
  3. Setiap kebijakan punya tes dari kedua sisi.
  4. Endpoint daftar diuji: pengguna A tidak melihat satu pun baris milik B.
  5. Sumber daya bersarang memverifikasi seluruh rantai kepemilikan.
  6. 404, bukan 403, untuk sumber daya yang keberadaannya sendiri rahasia.

Latihan: ambil satu endpoint detail di aplikasimu, buat dua pengguna, dan panggil endpoint itu dengan ID milik pengguna lain. Kalau datanya tampil, perbaiki dengan membatasi kueri dan tulis tesnya lebih dulu. Lalu ulangi untuk endpoint daftar-nya — itu yang paling sering terlewat.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.