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
| Lapisan | Cukup sendirian? |
|---|---|
| Menyembunyikan tombol di UI | Tidak — cuma tampilan |
Middleware per grup rute (/admin/*) | Untuk peran, ya. Untuk objek, tidak |
| Pemeriksaan kebijakan di service | Ya, kalau tidak ada jalur yang melewatinya |
| Kueri dibatasi kepemilikan | Terkuat — 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
| Titik | Kebocoran khas |
|---|---|
| Sumber daya bersarang | /pesanan/7/item/999 — item milik pesanan lain |
| Endpoint daftar | Lupa WHERE pengguna_id pada daftar, padahal detailnya aman |
| Endpoint ekspor / laporan | Jarang dipakai, jarang diuji, sering melewati kebijakan |
| Berkas di S3 | URL yang bisa ditebak; pakai presigned URL berumur pendek |
| Kunci cache | Kunci tanpa ID pengguna → data satu pengguna tersaji ke yang lain |
| Multi-tenant | Kueri tanpa tenant_id — kebocoran antar pelanggan (Fase 11) |
| Mass assignment | Body 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
- Setiap endpoint yang menerima ID membatasi kuerinya dengan kepemilikan atau tenant.
- Setiap sumber daya punya fungsi kebijakan, dan kebijakannya dipanggil di service.
- Setiap kebijakan punya tes dari kedua sisi.
- Endpoint daftar diuji: pengguna A tidak melihat satu pun baris milik B.
- Sumber daya bersarang memverifikasi seluruh rantai kepemilikan.
- 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.