Kembali ke Artikel

Belajar Golang dari Nol #26: Role dan Otorisasi, Siapa Boleh Apa

Solusi IT

Di Belajar Golang dari Nol #25 kita sudah membangun register, login, dan JWT. Server sekarang tahu siapa yang sedang mengakses API. UserID sudah tersimpan rapi di context. Tapi tahu siapa dia itu baru setengah cerita. Pertanyaan berikutnya: dia boleh melakukan apa? Itulah otorisasi, dan itu yang kita bahas di bagian ini. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.

Dua Cerita yang Sering Kejadian

Cerita pertama. Sebuah aplikasi kasir sudah punya login. Semua pegawai punya akun. Suatu hari pemilik toko sadar ada transaksi yang hilang dari laporan. Setelah ditelusuri, ternyata salah satu kasir menghapus transaksi lewat API. Bukan karena dia hacker. Endpoint hapus transaksi memang bisa diakses siapa saja yang sudah login. Sistem cuma bertanya “kamu siapa”, tidak pernah bertanya “kamu boleh tidak”.

Cerita kedua lebih halus. Sebuah API punya endpoint GET /orders/17. User dengan pesanan nomor 17 bisa melihat pesanannya. Lalu dia iseng mengganti angka di URL jadi 18. Muncul pesanan orang lain, lengkap dengan nama dan alamat. Dia coba PUT /users/9 dengan ID user lain, dan berhasil mengubah data orang itu. Ini celah klasik yang punya nama: IDOR, Insecure Direct Object Reference. Artinya sederhana: server memberi akses ke objek hanya berdasarkan ID yang diketik klien, tanpa mengecek apakah objek itu memang milik dia.

Dua cerita ini masalah bisnis, bukan cuma masalah teknis. Versi non-teknisnya pernah kami tulis di artikel hak akses di sistem bisnis kalau kamu mau menjelaskan ini ke atasan atau klien.

Dua Level Otorisasi yang Wajib Dibedakan

Sebelum menulis kode, luruskan dulu konsepnya. Otorisasi punya dua level yang berbeda, dan keduanya harus dicek.

Pertama, otorisasi berbasis peran. Admin boleh menghapus produk, kasir tidak. Ini menjawab pertanyaan “jabatan kamu mengizinkan aksi ini atau tidak”. Cek peran cocok ditaruh di middleware karena aturannya melekat pada route, bukan pada data.

Kedua, otorisasi berbasis kepemilikan. User boleh mengedit profil, tapi hanya profilnya sendiri. User boleh membatalkan pesanan, tapi hanya pesanan miliknya. Ini menjawab pertanyaan “objek ini punya kamu atau bukan”.

Banyak bug keamanan lahir karena developer hanya mengerjakan level pertama. Route sudah dilindungi middleware role, terasa aman, lalu cek kepemilikan dilupakan. Hasilnya persis cerita kedua tadi: semua user sah bisa saling mengutak-atik data lewat menebak ID. Ingat prinsip ini: cek peran dan cek kepemilikan itu dua pagar yang berbeda. Satu pagar tidak menggantikan pagar yang lain.

Menambah Kolom Role di Tabel Users

Kita mulai dari database. Tabel users dari bagian sebelumnya belum punya kolom role. Tambahkan lewat migrasi baru, dengan cara yang sama seperti yang kita pelajari di bagian 23. Buat dua file migrasi:

-- 000006_add_role_to_users.up.sql
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

-- 000006_add_role_to_users.down.sql
ALTER TABLE users DROP COLUMN role;

Default 'user' penting. Semua akun lama otomatis jadi user biasa. Tidak ada akun yang tiba-tiba jadi admin. Untuk membuat admin pertama, ubah manual lewat SQL:

UPDATE users SET role = 'admin' WHERE email = 'owner@toko.com';

Jangan buat endpoint register yang menerima field role dari klien. Kalau klien bisa mendaftar sambil memilih rolenya sendiri, semua pagar yang kita bangun setelah ini percuma.

Membawa Role di Dalam JWT

Sekarang update proses login dari bagian 25. Saat token dibuat, ikutkan role sebagai claim:

claims := jwt.MapClaims{
    "sub":  user.ID,
    "role": user.Role,
    "exp":  time.Now().Add(15 * time.Minute).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(secretKey))

Lalu di middleware autentikasi yang sudah kita punya, tambahkan satu baris untuk menaruh role di context, bersebelahan dengan userID:

c.Set("userID", int(claims["sub"].(float64)))
c.Set("role", claims["role"].(string))

Dengan begitu setiap handler dan middleware setelahnya bisa membaca role tanpa query ke database lagi.

Middleware RequireRole

Ini pagar pertama kita. Middleware yang menerima daftar role yang diizinkan, lalu menolak sisanya:

func RequireRole(roles ...string) gin.HandlerFunc {
    return func(c *gin.Context) {
        roleVal, ok := c.Get("role")
        if !ok {
            c.AbortWithStatusJSON(http.StatusUnauthorized,
                gin.H{"error": "silakan login dulu"})
            return
        }

        userRole := roleVal.(string)
        for _, allowed := range roles {
            if userRole == allowed {
                c.Next()
                return
            }
        }

        c.AbortWithStatusJSON(http.StatusForbidden,
            gin.H{"error": "kamu tidak punya akses untuk aksi ini"})
    }
}

Perhatikan dua status code yang berbeda di situ. Ini sering tertukar, jadi kita bedah pelan-pelan. 401 Unauthorized artinya “saya tidak tahu kamu siapa”. Token tidak ada, atau tidak valid. Solusinya login. 403 Forbidden artinya “saya tahu kamu siapa, dan kamu tetap tidak boleh”. Login ulang tidak akan menolong, karena masalahnya bukan identitas, tapi izin. Klien yang menerima 401 harus diarahkan ke halaman login. Klien yang menerima 403 cukup diberi pesan bahwa aksinya tidak diizinkan.

Pemasangannya per route, ditumpuk setelah middleware autentikasi:

products := r.Group("/products")
products.GET("", h.ListProducts)
products.GET("/:id", h.GetProduct)

adminProducts := r.Group("/products")
adminProducts.Use(AuthMiddleware(), RequireRole("admin"))
adminProducts.POST("", h.CreateProduct)
adminProducts.PUT("/:id", h.UpdateProduct)
adminProducts.DELETE("/:id", h.DeleteProduct)

Urutan middleware penting. AuthMiddleware harus jalan dulu supaya role sudah ada di context saat RequireRole membacanya. Route baca tetap terbuka untuk semua orang, route tulis hanya untuk admin.

Cek Kepemilikan di Service Layer

Sekarang pagar kedua. Kenapa tidak di middleware juga? Karena middleware tidak tahu apa-apa soal data. Untuk memutuskan “pesanan 17 ini milik user 4 atau bukan”, kita harus mengambil pesanan itu dari database dulu. Itu urusan service layer. Middleware hanya kenal request, service yang kenal data.

Contoh pertama, update profil. User hanya boleh mengubah profilnya sendiri:

var ErrForbidden = errors.New("tidak berhak mengakses data ini")

func (s *UserService) UpdateProfile(ctx context.Context,
    targetID, requesterID int, input UpdateProfileInput) error {

    if targetID != requesterID {
        return ErrForbidden
    }
    return s.repo.Update(ctx, targetID, input)
}

Contoh kedua, hapus pesanan. Di sini datanya harus diambil dulu:

func (s *OrderService) Delete(ctx context.Context,
    orderID, requesterID int, role string) error {

    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return err
    }

    isOwner := order.UserID == requesterID
    isAdmin := role == "admin"

    if !isOwner && !isAdmin {
        return ErrForbidden
    }

    if isAdmin && !isOwner {
        s.audit.Log(ctx, requesterID, "delete_order", orderID)
    }

    return s.repo.Delete(ctx, orderID)
}

Di handler, petakan ErrForbidden ke status 403 dengan errors.Is, pola yang sudah kita pakai sejak membahas error handling. Ambil requesterID dan role dari context, jangan pernah dari body atau query. ID di URL adalah objek yang mau diakses. ID di context adalah siapa yang mengakses. Jangan sampai tertukar, karena menukar keduanya itulah yang menciptakan IDOR.

Admin Override yang Tercatat

Lihat lagi kode di atas. Admin boleh menghapus pesanan siapa pun. Kadang memang perlu, misalnya membersihkan pesanan bermasalah. Tapi kekuasaan tanpa catatan itu berbahaya, bahkan untuk admin yang jujur sekalipun. Ketika ada data hilang, pertanyaan pertama pemilik bisnis selalu sama: siapa yang menghapus, kapan, dan kenapa.

Karena itu setiap kali admin menyentuh data milik orang lain, tulis ke log audit: siapa pelakunya, aksi apa, objek mana, jam berapa. Konsepnya sama dengan jejak audit yang kita bahas di bagian 18, sekarang tinggal dipakai di titik yang tepat. Satu baris s.audit.Log hari ini bisa menyelamatkan investigasi berjam-jam di kemudian hari.

Jangan Percaya Klien, Tapi Kenali Batas JWT

Mungkin kamu bertanya: role kan disimpan di JWT, dan JWT ada di tangan klien. Bagaimana kalau user mengedit tokennya sendiri dan mengganti role jadi admin?

Tenang. JWT ditandatangani dengan secret yang hanya diketahui server. Begitu payload diubah satu karakter saja, signature tidak cocok lagi dan token ditolak saat verifikasi. Selama secret tidak bocor, isi klaim aman dari manipulasi.

Tapi ada batas lain yang jujur harus diakui: klaim itu potret saat login, bukan data langsung dari database. Bayangkan seorang admin dicabut haknya siang ini. Token lama di tangannya masih berbunyi role: admin dan tetap valid sampai kadaluarsa. Selama sisa umur token itu, dia masih bisa lewat pagar RequireRole("admin").

Ada dua mitigasi yang umum, dan keduanya punya harga. Pertama, expiry pendek, misalnya 15 menit seperti contoh kita, dipasangkan dengan refresh token. Perubahan role paling lambat berlaku 15 menit kemudian. Harganya: alur refresh lebih rumit. Kedua, untuk aksi yang sangat sensitif seperti menghapus user atau mengubah role orang lain, abaikan klaim dan cek role langsung ke database saat itu juga. Harganya: satu query ekstra per request. Pilihan yang masuk akal untuk kebanyakan aplikasi: expiry pendek untuk umum, cek database khusus untuk endpoint paling berbahaya. Tidak ada jawaban gratis di sini, yang penting kamu memilih dengan sadar.

Latihan: API Produk dan Pesanan

Waktunya merangkai semua jadi satu. Susun route seperti ini:

r := gin.Default()

// publik: siapa pun boleh baca produk
r.GET("/products", h.ListProducts)
r.GET("/products/:id", h.GetProduct)

// admin: CRUD penuh produk
admin := r.Group("/products")
admin.Use(AuthMiddleware(), RequireRole("admin"))
admin.POST("", h.CreateProduct)
admin.PUT("/:id", h.UpdateProduct)
admin.DELETE("/:id", h.DeleteProduct)

// user login: pesanan dengan cek kepemilikan di service
orders := r.Group("/orders")
orders.Use(AuthMiddleware())
orders.POST("", h.CreateOrder)
orders.GET("/:id", h.GetOrder)
orders.DELETE("/:id", h.DeleteOrder)

Siapkan tiga kondisi untuk menguji: token admin, token user biasa, dan tanpa token. Contoh dua di antaranya:

curl -i -X DELETE http://localhost:8080/products/3 \
  -H "Authorization: Bearer $TOKEN_ADMIN"

curl -i -X DELETE http://localhost:8080/products/3 \
  -H "Authorization: Bearer $TOKEN_USER"

Lalu cocokkan hasilnya dengan matriks ini. Kalau ada satu sel yang meleset, berarti ada pagar yang bocor:

Endpoint                  Admin   User    Anon
GET    /products          200     200     200
POST   /products          201     403     401
PUT    /products/:id      200     403     401
DELETE /products/:id      204     403     401
GET    /orders/:id        200     200*    401
DELETE /orders/:id        204**   204*    401

*  hanya untuk pesanan miliknya sendiri,
   pesanan orang lain harus 403
** aksi admin atas pesanan orang lain
   harus tercatat di log audit

Uji juga skenario IDOR secara sengaja. Login sebagai user A, buat pesanan, catat ID-nya. Login sebagai user B, lalu coba GET dan DELETE pesanan milik A. Kalau jawabannya bukan 403, kembali ke service layer dan periksa cek kepemilikannya. Matriks yang lolos penuh artinya dua pagar kita sudah berdiri: peran di middleware, kepemilikan di service.

Penutup

API kita sekarang tidak cuma tahu siapa yang datang, tapi juga tegas soal siapa boleh apa. Peran dicek di middleware, kepemilikan dicek di service, dan aksi admin atas data orang lain selalu meninggalkan jejak. Tapi masih ada satu pintu yang belum kita jaga: data yang dikirim klien itu sendiri. Di bagian 27 kita bahas “Validasi Input: Jangan Percaya Data dari Luar”.

Pola role dan hak akses seperti ini juga yang kami pasang di setiap proyek aplikasi bisnis, dari sistem kasir sampai manajemen gudang. Kalau bisnismu butuh sistem aplikasi dengan pengaturan hak akses yang rapi, tim Arrazy siap bantu merancangnya.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel