Setiap insiden keamanan database besar hampir selalu memuat satu kesamaan yang memalukan: akun yang diretas memiliki jauh lebih banyak hak daripada yang seharusnya dibutuhkan. Prinsip least privilege — beri hak paling sedikit yang masih cukup untuk fungsi akun itu — adalah pertahanan paling murah dan paling sering dilanggar. Kenapa? Karena GRANT ALL PRIVILEGES menyelesaikan semua keluhan “access denied” dalam satu detik, sementara harganya baru muncul berbulan-bulan kemudian saat penyerang masuk dan menemukan akun aplikasi itu bisa menjatuhkan seluruh database. Pada chapter ini kita membangun contoh yang benar: aplikasi toko dengan hak CRUD murni, analis dengan hak baca murni, hak migrasi yang dipinjamkan sementara lalu dicabut, semuanya dibuktikan dari terminal dengan error yang tepat di tempat yang tepat.
Prinsipnya Sederhana: Perkecil Ledakan #
Cara berpikirnya begini: asumsikan akun akan jatuh ke tangan penyerang — bukan karena pesimisme, melainkan karena itu satu-satunya asumsi yang selamat dari kenyataan. Pertanyaannya lalu bukan “hak apa yang nyaman dimiliki akun ini?” melainkan “kerusakan terburuk apa yang masih mungkin dilakukan penyerang bila akun ini diretas?”. Akun aplikasi kasir yang hanya bisa SELECT, INSERT, UPDATE, DELETE di satu database punya blast radius yang kecil: data di satu database bisa dibaca dan diganti, tetapi skema tidak bisa dijatuhkan, database lain tidak tersentuh, akun lain tidak bisa dibuat.
Matriks berikut merangkum pola hak untuk peran-peran yang paling umum:
| Peran | Privilege | Cakupan | Catatan |
|---|---|---|---|
| Aplikasi produksi | SELECT, INSERT, UPDATE, DELETE | satu database | Tanpa DDL, tanpa DROP |
| Aplikasi baca-laporan | SELECT | satu database atau beberapa view | |
| Analitik / BI | SELECT | database laporan | Sering cukup lewat view |
| Migrasi skema | CREATE, ALTER, DROP, INDEX | satu database | Sementara, dicabut setelah selesai |
| Administrator | hak administratif granular | sesuai tugas | Hindari pembagian root |
Menyiapkan Dua Akun dengan Hak Minimal #
Kita bangun skenario lengkapnya: database belajar_aman dengan tabel produk, akun aplikasi_toko untuk aplikasi kasir, dan budi_analyst untuk tim laporan:
DROP DATABASE IF EXISTS belajar_aman;
CREATE DATABASE belajar_aman;
USE belajar_aman;
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(60) NOT NULL,
kategori VARCHAR(30) NOT NULL,
harga DECIMAL(10,2) NOT NULL,
stok INT NOT NULL
);
INSERT INTO produk (nama, kategori, harga, stok) VALUES
('Kopi Arabika Gayo', 'Minuman', 85000.00, 40),
('Teh Melati Premium', 'Minuman', 45000.00, 120),
('Gula Aren Cair', 'Bahan', 32000.00, 75),
('Susu Kental Manis', 'Bahan', 18000.00, 200),
('Sirup Jeruk', 'Minuman', 27000.00, 0);
DROP USER IF EXISTS 'aplikasi_toko'@'localhost';
DROP USER IF EXISTS 'budi_analyst'@'%';
CREATE USER 'aplikasi_toko'@'localhost' IDENTIFIED BY 'SandiKuat2026!';
CREATE USER 'budi_analyst'@'%' IDENTIFIED BY 'SandiKuat2026!';
GRANT SELECT, INSERT, UPDATE, DELETE ON belajar_aman.* TO 'aplikasi_toko'@'localhost';
GRANT SELECT ON belajar_aman.* TO 'budi_analyst'@'%';Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changed
Query OK, 0 rows affected (0.00 sec)
Query OK, 5 rows affected (0.00 sec)
Records: 5 Duplicates: 0 Warnings: 0
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)Empat hak untuk aplikasi, satu hak untuk analitik — persis seperti matriks. Audit pertama kita lakukan segera setelah pemberian:
SHOW GRANTS FOR 'aplikasi_toko'@'localhost';
SHOW GRANTS FOR 'budi_analyst'@'%';Output yang Dihasilkan:
+-----------------------------------------------------------------------------------------+
| Grants for aplikasi_toko@localhost |
+-----------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `aplikasi_toko`@`localhost` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `belajar_aman`.* TO `aplikasi_toko`@`localhost` |
+-----------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)
+--------------------------------------------------------+
| Grants for budi_analyst@% |
+--------------------------------------------------------+
| GRANT USAGE ON *.* TO `budi_analyst`@`%` |
| GRANT SELECT ON `belajar_aman`.* TO `budi_analyst`@`%` |
+--------------------------------------------------------+
2 rows in set (0.00 sec)Hasilnya dua baris untuk masing-masing akun: GRANT USAGE (tanda tanpa hak global) dan satu baris hak objek. Tidak ada ALL, tidak ada WITH GRANT OPTION, tidak ada hak global — bentuk SHOW GRANTS yang seharusnya membosankan.
Membuktikan Batas dari Terminal #
Teori di atas kita uji sebagai penyerang sungguhan — masuk sebagai aplikasi_toko dan mencoba hal-hal di luar mandatnya. Normalnya dulu: membaca dan menambah data:
-- @test-bash
MYSQL_PWD='SandiKuat2026!' mysql -h 127.0.0.1 -P 3306 -u aplikasi_toko -D belajar_aman -t -e "SELECT COUNT(*) AS jml_awal FROM produk; INSERT INTO produk (nama, kategori, harga, stok) VALUES ('Kopi Robusta Temanggung', 'Minuman', 60000.00, 25); SELECT id, nama, stok FROM produk ORDER BY id;"Output yang Dihasilkan:
+----------+
| jml_awal |
+----------+
| 5 |
+----------+
+----+-------------------------+------+
| id | nama | stok |
+----+-------------------------+------+
| 1 | Kopi Arabika Gayo | 40 |
| 2 | Teh Melati Premium | 120 |
| 3 | Gula Aren Cair | 75 |
| 4 | Susu Kental Manis | 200 |
| 5 | Sirup Jeruk | 0 |
| 6 | Kopi Robusta Temanggung | 25 |
+----+-------------------------+------+Aplikasi membaca 5 produk, menambahkan produk keenam (Kopi Robusta Temanggung), lalu membaca ulang hasilnya — CRUD bekerja penuh. Sekarang bagian yang menentukan: perintah di luar mandat:
-- @test-bash
echo "Mencoba DROP TABLE:";
MYSQL_PWD='SandiKuat2026!' mysql -h 127.0.0.1 -P 3306 -u aplikasi_toko -D belajar_aman -e "DROP TABLE produk;" 2>&1
echo "Mencoba CREATE TABLE:";
MYSQL_PWD='SandiKuat2026!' mysql -h 127.0.0.1 -P 3306 -u aplikasi_toko -D belajar_aman -e "CREATE TABLE uji (id INT);" 2>&1Output yang Dihasilkan:
Mencoba DROP TABLE:
ERROR 1142 (42000) at line 1: DROP command denied to user 'aplikasi_toko'@'localhost' for table 'produk'
Mencoba CREATE TABLE:
ERROR 1142 (42000) at line 1: CREATE command denied to user 'aplikasi_toko'@'localhost' for table 'uji'Kedua usaha jatuh pada ERROR 1142 (42000): DROP command denied dan CREATE command denied — server menolak tepat di batas yang kita pasang. Inilah inti least privilege yang bekerja: bukan akunnya yang “kebal serangan”, melainkan serangan yang kehilangan daya jualnya. Lanjut ke akun kedua:
-- @test-bash
echo "Analitik membaca data:";
MYSQL_PWD='SandiKuat2026!' mysql -h 127.0.0.1 -P 3306 -u budi_analyst -D belajar_aman -t -e "SELECT COUNT(*) AS jml_produk FROM produk;" 2>&1
echo "Analitik mencoba mengubah data:";
MYSQL_PWD='SandiKuat2026!' mysql -h 127.0.0.1 -P 3306 -u budi_analyst -D belajar_aman -e "UPDATE produk SET stok = 0 WHERE id = 1;" 2>&1Output yang Dihasilkan:
Analitik membaca data:
+------------+
| jml_produk |
+------------+
| 6 |
+------------+
Analitik mencoba mengubah data:
ERROR 1142 (42000) at line 1: UPDATE command denied to user 'budi_analyst'@'localhost' for table 'produk'budi_analyst membaca 6 baris dengan bebas, tetapi UPDATE ditolak dengan ERROR 1142: UPDATE command denied. Akun analitik tidak pernah menulis — kesalahan query atau akun yang dicuri sekalipun tidak bisa mengubah satu angka stok pun.
Hak Sementara untuk Migrasi #
Tim pengembang kadang butuh mengubah skema — dan godaannya selalu sama: “kasih saja GRANT ALL biar selesai”. Cara yang benar adalah meminjamkan hak migrasi dengan jendela waktu sempit. Kita praktikkan pinjam-cabutnya:
GRANT ALTER, DROP, CREATE ON belajar_aman.* TO 'aplikasi_toko'@'localhost';
SHOW GRANTS FOR 'aplikasi_toko'@'localhost';Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
+--------------------------------------------------------------------------------------------------------------+
| Grants for aplikasi_toko@localhost |
+--------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `aplikasi_toko`@`localhost` |
| GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER ON `belajar_aman`.* TO `aplikasi_toko`@`localhost` |
+--------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)REVOKE ALTER, DROP, CREATE ON belajar_aman.* FROM 'aplikasi_toko'@'localhost';
SHOW GRANTS FOR 'aplikasi_toko'@'localhost';Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
+-----------------------------------------------------------------------------------------+
| Grants for aplikasi_toko@localhost |
+-----------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `aplikasi_toko`@`localhost` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `belajar_aman`.* TO `aplikasi_toko`@`localhost` |
+-----------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)Baris SHOW GRANTS yang pertama memperlihatkan hak DDL menempel sesaat — jendela migrasi terbuka, skrip ALTER TABLE boleh berjalan. Baris kedua, sesudah REVOKE, kembali ke empat hak CRUD murni. Ritualnya di lapangan: berikan GRANT menjelang migrasi, jalankan skripnya, cabut di menit yang sama setelah selesai — jendela yang diukur menit, bukan “sampai lupa”. Bila proses ini berulang tiap rilis, pertimbangkan memindahkan hak DDL ke role migrasi yang hanya diberikan selama jendela berjalan.
Ketika Prinsip Ini Dilanggar: Sebuah Narasi #
Mari kita reramkan versi gelapnya dengan pengalaman lapangan yang sering terjadi. Sebuah aplikasi toko online kewalahan dengan error 1142 di masa pengembangan, lalu sang developer menyerah dan menjalankan GRANT ALL PRIVILEGES ON *.* TO 'aplikasi_toko'@'localhost' — semua masalah selesai hari itu. Enam bulan kemudian, celah SQL injection ditemukan di halaman pencarian produk: input pengguna yang tidak disaring berhasil menyisipkan perintah tambahan ke dalam query yang dijalankan aplikasi. Karena akun aplikasi kini serba bisa, penyerang menjalankan DROP DATABASE toko_online, membuat akun baru untuk dirinya, dan membaca seluruh tabel pengguna — tiga bencana dari satu pintu yang dibuka enam bulan sebelumnya.
Ironisnya, tiga pertahanan yang kita bangun di course ini bekerja berlapis: prepared statement memutus jalur injeksi itu sendiri (chapter berikutnya), dan least privilege memastikan bila injeksi tetap lolos, akunnya hanya bisa menyentuh data — bukan skema, bukan akun, bukan server. Prinsip ini pula yang menjelaskan mengapa audit berkala penting: hak akses cenderung mengendur seiring waktu, dan SHOW GRANTS adalah kaca spionnya.
Audit Berkala: Checklist #
Ritual audit bulanan yang ringan untuk server dengan banyak akun:
- Jalankan
SHOW GRANTS FORpada setiap akun aplikasi — cari barisALL PRIVILEGES,WITH GRANT OPTION, atau cakupan*.*yang tidak ada dalam desain. - Audit akun tanpa sandi:
SELECT user, host FROM mysql.user WHERE authentication_string = '';— hasil idealnya kosong. - Audit akun terkunci dan kedaluwarsa: kolom
account_lockeddanpassword_expireddimysql.user. - Cocokkan daftar akun dengan daftar aplikasi yang benar-benar hidup — akun proyek yang selesai harus dijatuhkan.
- Simpan hasil audit sebagai baseline; perbedaan bulan-ke-bulan adalah sinyal perubahan hak yang perlu penjelasan.
Membersihkan Latihan #
Dua akun dan database latihan kita bereskan dari terminal:
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root -e "DROP USER IF EXISTS 'aplikasi_toko'@'localhost'; DROP USER IF EXISTS 'budi_analyst'@'%'; DROP DATABASE IF EXISTS belajar_aman;"Referensi #
- Privileges Provided by MySQL — MySQL 8.4 Reference Manual
- GRANT Statement — MySQL 8.4 Reference Manual
- REVOKE Statement — MySQL 8.4 Reference Manual
- Security — MySQL 8.4 Reference Manual
Least privilege menutup pintu lebar yang membuat insiden kecil menjadi bencana besar. Tetapi narasi tadi menyebut pertahanan pertama yang lebih fundamental: memutus jalur SQL injection itu sendiri, sehingga input pengguna tidak pernah berubah menjadi perintah. Chapter berikutnya membedah anatomi serangan tersebut — dari string ' OR '1'='1 sampai payload DROP TABLE — sebelum kita membangun bentengnya dengan prepared statement.