Authentication hanya menuntaskan setengah pekerjaan: server tahu siapa pemanggilnya, tetapi belum tahu apa yang boleh dilakukan pemanggil tersebut. Authorization menjawab setengah kedua itu — menentukan endpoint mana yang boleh diakses, resource milik siapa yang boleh dibaca, dan operasi mana yang hanya untuk peran tertentu. Kita akan menerapkan RBAC (Role-Based Access Control) sederhana dengan tabel izin endpoint, middleware pemeriksa peran, izin granular pemilik resource, dan penyaringan data per peran — semuanya dengan prinsip deny by default.
Peran dan Tabel Izin #
RBAC memetakan peran string (user, admin) ke daftar aksi yang diizinkan. Kita menyimpan pemetaan itu sebagai satu tabel: kunci berupa pola endpoint seperti DELETE /dokumen/{id}, nilai berupa slice peran yang boleh melintas. Tabel berikut adalah kebijakan lengkap program di bawah:
| Endpoint | user | admin |
|---|---|---|
GET /dokumen | hanya dokumen miliknya | seluruh dokumen |
GET /dokumen/{id} | hanya miliknya | dokumen siapa pun |
DELETE /dokumen/{id} | ditolak (403) | diizinkan (204) |
Satu tabel terpisah dari kode handler membuat kebijakan mudah dibaca sekaligus mudah diaudit: siapa pun yang meninjau kode bisa membandingkan tabel dengan routing dalam sekejap.
Deny by Default #
Prinsip inti kebijakan ini: akses hanya diberikan bila eksplisit terdaftar. Middleware wajibPeran menolak pola endpoint yang tidak ada di tabel dan menolak peran yang tidak masuk daftar — keduanya dijawab 403 tanpa pengecualian. Kebalikannya, allow by default, membuat endpoint baru yang lupa didaftarkan otomatis terbuka; dengan deny by default, kelalaian justru menutup akses — mode gagal yang jauh lebih aman.
Middleware Pemeriksa Peran #
Pola wajibPeran(pola, handler) membungkus handler dengan pemeriksaan tabel izin, seperti RequireRole("admin") pada framework populer. Handler yang sudah dibungkus tidak perlu memeriksa peran lagi; handler hapus pada program di bawah langsung menjalankan penghapusan karena hanya admin yang bisa sampai ke sana. Urutan pemrosesan tetap: 401 dulu (belum dikenal), baru 403 (dikenal tetapi ditolak).
Izin Granular Pemilik Resource #
Peran saja belum cukup untuk data pribadi: dokumen 2 milik siti harus tetap tertutup bagi budi meskipun keduanya berperan user. Handler detail menerapkan pemeriksaan granular — boleh membaca bila pemanggil adalah pemilik atau admin. Kombinasi dua lapis inilah bentuk paling umum di API nyata: peran menentukan kelas aksi, kepemilikan menentukan objek spesifiknya.
Penyaringan Data per Peran #
Izin akses dan penyaringan data adalah dua hal berbeda. Menolak 403 cocok untuk satu resource, tetapi daftar /dokumen harus tetap terkirim dengan isi yang disaring: budi menerima dua dokumen miliknya, admin menerima ketiganya.
Program: Tujuh Skenario Izin #
Program rbac-demo.go memuat tiga dokumen milik dua pengguna dan melayani daftar, detail, serta penghapusan lewat tabel izin. Tujuh permintaan memperagakan penyaringan daftar, izin granular, penolakan peran, penghapusan oleh admin, dan efeknya terhadap data.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
)
// Identitas pemanggil yang dibawa dari proses autentikasi.
type Identitas struct {
Nama string
Peran string
}
// kunciKonteks memisahkan namespace nilai context milik program ini.
type kunciKonteks struct{}
// Dokumen adalah resource yang dimiliki tepat satu pengguna.
type Dokumen struct {
ID int `json:"id"`
Judul string `json:"judul"`
Pemilik string `json:"pemilik"`
}
// amplopDaftar membungkus daftar dokumen sesuai konvensi respons section ini.
type amplopDaftar struct {
Data []Dokumen `json:"data"`
}
type amplopSatu struct {
Data Dokumen `json:"data"`
}
// Handler menyimpan data, tabel izin, dan tabel hash token.
type Handler struct {
dokumen []Dokumen
izin map[string][]string
token map[string]Identitas
}
// petaToken mengubah pasangan token-identitas menjadi tabel hash SHA-256,
// pola penyimpanan kredensial dari chapter Authentication.
func petaToken(kredensial map[string]Identitas) map[string]Identitas {
peta := make(map[string]Identitas, len(kredensial))
for token, id := range kredensial {
jumlah := sha256.Sum256([]byte(token))
peta[hex.EncodeToString(jumlah[:])] = id
}
return peta
}
// routes mendaftarkan endpoint; tiap endpoint dibungkus wajibPeran
// dengan pola yang sama sehingga izin terkumpul pada satu tabel.
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /dokumen", h.wajibPeran("GET /dokumen", h.daftar))
mux.HandleFunc("GET /dokumen/{id}", h.wajibPeran("GET /dokumen/{id}", h.detail))
mux.HandleFunc("DELETE /dokumen/{id}", h.wajibPeran("DELETE /dokumen/{id}", h.hapus))
return mux
}
// autentikasi memverifikasi token lalu menempelkan identitas di context.
func (h *Handler) autentikasi(berikut http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, token, _ := strings.Cut(r.Header.Get("Authorization"), " ")
jumlah := sha256.Sum256([]byte(token))
id, ok := h.token[hex.EncodeToString(jumlah[:])]
if !ok {
tulisGalat(w, http.StatusUnauthorized, "UNAUTHENTICATED", "token tidak dikenal")
return
}
ctx := context.WithValue(r.Context(), kunciKonteks{}, id)
berikut.ServeHTTP(w, r.WithContext(ctx))
})
}
// wajibPeran memeriksa tabel izin: pola yang tidak terdaftar, atau peran
// yang tidak masuk daftar, langsung ditolak — deny by default.
func (h *Handler) wajibPeran(pola string, berikut http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
if !slices.Contains(h.izin[pola], id.Peran) {
tulisGalat(w, http.StatusForbidden, "FORBIDDEN",
fmt.Sprintf("peran %s tidak diizinkan untuk %s", id.Peran, pola))
return
}
berikut(w, r)
}
}
// daftar menyaring data: pengguna biasa hanya menerima dokumen miliknya,
// admin menerima seluruh dokumen.
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
terlihat := make([]Dokumen, 0, len(h.dokumen))
for _, d := range h.dokumen {
if id.Peran == "admin" || d.Pemilik == id.Nama {
terlihat = append(terlihat, d)
}
}
tulisJSON(w, http.StatusOK, amplopDaftar{Data: terlihat})
}
// detail menerapkan izin granular: hanya pemilik atau admin boleh membaca.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
nomor, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
return
}
for _, d := range h.dokumen {
if d.ID != nomor {
continue
}
if d.Pemilik != id.Nama && id.Peran != "admin" {
tulisGalat(w, http.StatusForbidden, "FORBIDDEN", "dokumen bukan milik Anda")
return
}
tulisJSON(w, http.StatusOK, amplopSatu{Data: d})
return
}
tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
fmt.Sprintf("dokumen %d tidak ditemukan", nomor))
}
// hapus tidak memeriksa peran lagi — tabel izin sudah menjamin hanya admin.
func (h *Handler) hapus(w http.ResponseWriter, r *http.Request) {
nomor, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
return
}
for i, d := range h.dokumen {
if d.ID == nomor {
h.dokumen = slices.Delete(h.dokumen, i, i+1)
w.WriteHeader(http.StatusNoContent)
return
}
}
tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
fmt.Sprintf("dokumen %d tidak ditemukan", nomor))
}
type galatBody struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{
dokumen: []Dokumen{
{ID: 1, Judul: "Laporan Penjualan", Pemilik: "budi"},
{ID: 2, Judul: "Rencana Anggaran", Pemilik: "siti"},
{ID: 3, Judul: "Notulen Rapat", Pemilik: "budi"},
},
izin: map[string][]string{
"GET /dokumen": {"user", "admin"},
"GET /dokumen/{id}": {"user", "admin"},
"DELETE /dokumen/{id}": {"admin"},
},
token: petaToken(map[string]Identitas{
"tok-budi-7f3a": {Nama: "budi", Peran: "user"},
"tok-siti-9c2e": {Nama: "siti", Peran: "admin"},
}),
}
server := h.autentikasi(h.routes())
// Penyaringan data: budi hanya menerima dokumen miliknya.
kirim(server, "GET", "/dokumen", "budi", "tok-budi-7f3a")
// Penyaringan data: admin menerima seluruh dokumen.
kirim(server, "GET", "/dokumen", "admin", "tok-siti-9c2e")
// Izin granular: dokumen 2 milik siti, budi ditolak.
kirim(server, "GET", "/dokumen/2", "budi", "tok-budi-7f3a")
// Izin granular: admin boleh membaca dokumen milik siapa pun.
kirim(server, "GET", "/dokumen/2", "admin", "tok-siti-9c2e")
// Tabel izin: DELETE hanya untuk admin, budi ditolak 403.
kirim(server, "DELETE", "/dokumen/1", "budi", "tok-budi-7f3a")
// Tabel izin: admin berhasil menghapus dokumen 1.
kirim(server, "DELETE", "/dokumen/1", "admin", "tok-siti-9c2e")
// Efek penghapusan: dokumen 1 sudah tidak ada.
kirim(server, "GET", "/dokumen/1", "admin", "tok-siti-9c2e")
}
// kirim mengeksekusi satu permintaan atas nama satu pemohon,
// lalu mencetak status dan body tanpa jaringan sungguhan.
func kirim(server http.Handler, metode, target, pemohon, token string) {
req := httptest.NewRequest(metode, target, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
fmt.Printf("%-7s %-13s %-6s -> %d\n", metode, target, pemohon, rec.Code)
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}Output yang Dihasilkan:
GET /dokumen budi -> 200
{"data":[{"id":1,"judul":"Laporan Penjualan","pemilik":"budi"},{"id":3,"judul":"Notulen Rapat","pemilik":"budi"}]}
GET /dokumen admin -> 200
{"data":[{"id":1,"judul":"Laporan Penjualan","pemilik":"budi"},{"id":2,"judul":"Rencana Anggaran","pemilik":"siti"},{"id":3,"judul":"Notulen Rapat","pemilik":"budi"}]}
GET /dokumen/2 budi -> 403
{"error":{"code":"FORBIDDEN","message":"dokumen bukan milik Anda"}}
GET /dokumen/2 admin -> 200
{"data":{"id":2,"judul":"Rencana Anggaran","pemilik":"siti"}}
DELETE /dokumen/1 budi -> 403
{"error":{"code":"FORBIDDEN","message":"peran user tidak diizinkan untuk DELETE /dokumen/{id}"}}
DELETE /dokumen/1 admin -> 204
GET /dokumen/1 admin -> 404
{"error":{"code":"NOT_FOUND","message":"dokumen 1 tidak ditemukan"}}Mari kita telaah baris demi baris program di atas:
- Baris 41-46 (
type Handler): handler menyimpan data, tabelizin, dan tabel hash token — autentikasi tetap memakai pola hash chapter sebelumnya. - Baris 59-67 (
routes): tiap endpoint didaftarkan dengan dibungkuswajibPeranmemakai pola yang sama, sehingga routing dan izin mudah dibandingkan. - Baris 69-82 (
autentikasi): middleware ini menjawab 401 untuk token tak dikenal sebelum pemeriksaan peran sempat berjalan. - Baris 86-96 (
wajibPeran):slices.Containsmemeriksa keanggotaan peran; pola yang tidak terdaftar menghasilkan slice kosong sehingga otomatis ditolak — deny by default. - Baris 100-109 (
daftar): loop menyaring dokumen — admin menerima semuanya, pengguna lain hanya miliknya — lalu mengirimnya lewat envelopedata. - Baris 119-131 (
detail): setelah dokumen ditemukan, baris 123-126 menolak 403 bila pemanggil bukan pemilik dan bukan admin. - Baris 134-150 (
hapus): tidak ada pemeriksaan peran di sini karena tabel izin sudah menjamin hanya admin yang tiba; keberhasilan dijawab 204 tanpa body. - Baris 178-182 (tabel izin): tiga baris inilah seluruh kebijakan akses program — mengubah satu baris langsung mengubah perilaku endpoint terkait.
- Baris 190-209 (
main): tujuh permintaan berpasangan budi/admin memperlihatkan perbedaan hasil untuk endpoint yang sama.
Output membuktikan pemisahan tanggung jawab: budi dan admin memanggil GET /dokumen dan menerima isi berbeda; budi ditolak di tiga titik dengan dua alasan berbeda; admin menghapus dokumen 1 sehingga permintaan terakhir menemukan 404. Peran dan kepemilikan kini menjaga seluruh endpoint — lapisan yang sering dilupakan adalah batas laju permintaan, topik chapter berikutnya.
Referensi #
- Dokumentasi package net/http
— pola middleware
func(http.Handler) http.Handler. - Dokumentasi package slices
—
Containsuntuk keanggotaan peran. - Dokumentasi package httptest — pengujian izin tanpa jaringan.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
rbac-demo.go
Unduh berkaspackage main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
)
// Identitas pemanggil yang dibawa dari proses autentikasi.
type Identitas struct {
Nama string
Peran string
}
// kunciKonteks memisahkan namespace nilai context milik program ini.
type kunciKonteks struct{}
// Dokumen adalah resource yang dimiliki tepat satu pengguna.
type Dokumen struct {
ID int `json:"id"`
Judul string `json:"judul"`
Pemilik string `json:"pemilik"`
}
// amplopDaftar membungkus daftar dokumen sesuai konvensi respons section ini.
type amplopDaftar struct {
Data []Dokumen `json:"data"`
}
type amplopSatu struct {
Data Dokumen `json:"data"`
}
// Handler menyimpan data, tabel izin, dan tabel hash token.
type Handler struct {
dokumen []Dokumen
izin map[string][]string
token map[string]Identitas
}
// petaToken mengubah pasangan token-identitas menjadi tabel hash SHA-256,
// pola penyimpanan kredensial dari chapter Authentication.
func petaToken(kredensial map[string]Identitas) map[string]Identitas {
peta := make(map[string]Identitas, len(kredensial))
for token, id := range kredensial {
jumlah := sha256.Sum256([]byte(token))
peta[hex.EncodeToString(jumlah[:])] = id
}
return peta
}
// routes mendaftarkan endpoint; tiap endpoint dibungkus wajibPeran
// dengan pola yang sama sehingga izin terkumpul pada satu tabel.
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /dokumen", h.wajibPeran("GET /dokumen", h.daftar))
mux.HandleFunc("GET /dokumen/{id}", h.wajibPeran("GET /dokumen/{id}", h.detail))
mux.HandleFunc("DELETE /dokumen/{id}", h.wajibPeran("DELETE /dokumen/{id}", h.hapus))
return mux
}
// autentikasi memverifikasi token lalu menempelkan identitas di context.
func (h *Handler) autentikasi(berikut http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, token, _ := strings.Cut(r.Header.Get("Authorization"), " ")
jumlah := sha256.Sum256([]byte(token))
id, ok := h.token[hex.EncodeToString(jumlah[:])]
if !ok {
tulisGalat(w, http.StatusUnauthorized, "UNAUTHENTICATED", "token tidak dikenal")
return
}
ctx := context.WithValue(r.Context(), kunciKonteks{}, id)
berikut.ServeHTTP(w, r.WithContext(ctx))
})
}
// wajibPeran memeriksa tabel izin: pola yang tidak terdaftar, atau peran
// yang tidak masuk daftar, langsung ditolak — deny by default.
func (h *Handler) wajibPeran(pola string, berikut http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
if !slices.Contains(h.izin[pola], id.Peran) {
tulisGalat(w, http.StatusForbidden, "FORBIDDEN",
fmt.Sprintf("peran %s tidak diizinkan untuk %s", id.Peran, pola))
return
}
berikut(w, r)
}
}
// daftar menyaring data: pengguna biasa hanya menerima dokumen miliknya,
// admin menerima seluruh dokumen.
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
terlihat := make([]Dokumen, 0, len(h.dokumen))
for _, d := range h.dokumen {
if id.Peran == "admin" || d.Pemilik == id.Nama {
terlihat = append(terlihat, d)
}
}
tulisJSON(w, http.StatusOK, amplopDaftar{Data: terlihat})
}
// detail menerapkan izin granular: hanya pemilik atau admin boleh membaca.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id := r.Context().Value(kunciKonteks{}).(Identitas)
nomor, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
return
}
for _, d := range h.dokumen {
if d.ID != nomor {
continue
}
if d.Pemilik != id.Nama && id.Peran != "admin" {
tulisGalat(w, http.StatusForbidden, "FORBIDDEN", "dokumen bukan milik Anda")
return
}
tulisJSON(w, http.StatusOK, amplopSatu{Data: d})
return
}
tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
fmt.Sprintf("dokumen %d tidak ditemukan", nomor))
}
// hapus tidak memeriksa peran lagi — tabel izin sudah menjamin hanya admin.
func (h *Handler) hapus(w http.ResponseWriter, r *http.Request) {
nomor, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
return
}
for i, d := range h.dokumen {
if d.ID == nomor {
h.dokumen = slices.Delete(h.dokumen, i, i+1)
w.WriteHeader(http.StatusNoContent)
return
}
}
tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
fmt.Sprintf("dokumen %d tidak ditemukan", nomor))
}
type galatBody struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{
dokumen: []Dokumen{
{ID: 1, Judul: "Laporan Penjualan", Pemilik: "budi"},
{ID: 2, Judul: "Rencana Anggaran", Pemilik: "siti"},
{ID: 3, Judul: "Notulen Rapat", Pemilik: "budi"},
},
izin: map[string][]string{
"GET /dokumen": {"user", "admin"},
"GET /dokumen/{id}": {"user", "admin"},
"DELETE /dokumen/{id}": {"admin"},
},
token: petaToken(map[string]Identitas{
"tok-budi-7f3a": {Nama: "budi", Peran: "user"},
"tok-siti-9c2e": {Nama: "siti", Peran: "admin"},
}),
}
server := h.autentikasi(h.routes())
// Penyaringan data: budi hanya menerima dokumen miliknya.
kirim(server, "GET", "/dokumen", "budi", "tok-budi-7f3a")
// Penyaringan data: admin menerima seluruh dokumen.
kirim(server, "GET", "/dokumen", "admin", "tok-siti-9c2e")
// Izin granular: dokumen 2 milik siti, budi ditolak.
kirim(server, "GET", "/dokumen/2", "budi", "tok-budi-7f3a")
// Izin granular: admin boleh membaca dokumen milik siapa pun.
kirim(server, "GET", "/dokumen/2", "admin", "tok-siti-9c2e")
// Tabel izin: DELETE hanya untuk admin, budi ditolak 403.
kirim(server, "DELETE", "/dokumen/1", "budi", "tok-budi-7f3a")
// Tabel izin: admin berhasil menghapus dokumen 1.
kirim(server, "DELETE", "/dokumen/1", "admin", "tok-siti-9c2e")
// Efek penghapusan: dokumen 1 sudah tidak ada.
kirim(server, "GET", "/dokumen/1", "admin", "tok-siti-9c2e")
}
// kirim mengeksekusi satu permintaan atas nama satu pemohon,
// lalu mencetak status dan body tanpa jaringan sungguhan.
func kirim(server http.Handler, metode, target, pemohon, token string) {
req := httptest.NewRequest(metode, target, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
fmt.Printf("%-7s %-13s %-6s -> %d\n", metode, target, pemohon, rec.Code)
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}