Semua alat keamanan sejauh ini bekerja pada tingkat objek: sebuah role boleh membaca tabel atau tidak boleh sama sekali. Kenyataan aplikasi sering lebih halus — tabel catatan memuat enam baris, dan aturan bisnis menghendaki Budi hanya melihat tiga baris miliknya, Siti hanya miliknya, tanpa tabel yang dipecah-pecah atau view per orang seperti yang kita lakukan di chapter View dan Keamanan Data
. Row Level Security (RLS) memindahkan penyaringan itu ke dalam tabel itu sendiri: satu saklar pada tabel, plus kebijakan berupa ekspresi SQL, dan setiap SELECT — dari psql, aplikasi, sampai tool pelaporan — otomatis tersaring menurut identitas pembacanya. Chapter ini mengaktifkan RLS langkah demi langkah sampai perilaku-perilaku pentingnya terbukti: lubang hitam tanpa policy, penyaringan lewat USING, validasi tulisan lewat WITH CHECK, serta posisi istimewa pemilik tabel dan superuser.
Sebelum RLS: Semua yang Bisa Membaca Melihat Segalanya #
Titik awalnya adalah kondisi default yang perlu dipahami dulu supaya dampak RLS terasa. File pembuka membuat dua role pengguna dan satu tabel catatan yang memuat enam baris — tiga milik budi_tmj, tiga milik siti_tmj — lalu memberi hak SELECT kepada keduanya:
-- Bahan latihan Row Level Security: tabel catatan milik bersama
-- dengan kolom pemilik sebagai pembeda baris.
DROP ROLE IF EXISTS budi_tmj;
DROP ROLE IF EXISTS siti_tmj;
CREATE ROLE budi_tmj LOGIN;
CREATE ROLE siti_tmj LOGIN;
CREATE TABLE catatan (
id integer PRIMARY KEY,
pemilik text NOT NULL,
isi text NOT NULL
);
INSERT INTO catatan (id, pemilik, isi) VALUES
(1, 'budi_tmj', 'belanja mingguan'),
(2, 'budi_tmj', 'kontak pemasok'),
(3, 'siti_tmj', 'jadwal lembur'),
(4, 'siti_tmj', 'daftar retur'),
(5, 'budi_tmj', 'catatan stok opname'),
(6, 'siti_tmj', 'rekap kas kecil');
GRANT SELECT ON catatan TO budi_tmj, siti_tmj;
-- Sebelum RLS: siapa pun yang memegang SELECT melihat seluruh tabel.
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_budi FROM catatan;
RESET ROLE;Output yang Dihasilkan:
DROP ROLE
DROP ROLE
CREATE ROLE
CREATE ROLE
CREATE TABLE
INSERT 0 6
GRANT
SET
terlihat_budi
---------------
6
(1 row)
RESETHasilnya sesuai intuisi lama: terlihat_budi bernilai 6. Privilege SELECT bekerja serba-atau-tidak-ada pada objek — Budi melihat seluruh tabel, termasuk lima baris yang bukan miliknya. Selama tiga chapter privilege kita menganggap ini wajar; mulai file berikut, anggapan itu dibongkar.
Mengaktifkan RLS: Lubang Hitam Sebelum Policy Ditulis #
RLS dinyalakan dengan satu klausa ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Kejutan bagi pemula: mengaktifkannya tanpa menulis satu policy pun tidak membuat tabel terbuka bebas, melainkan menutupnya total bagi role biasa:
-- Mengaktifkan RLS tanpa membuat policy sama sekali.
-- Hasilnya: lubang hitam -- role biasa tidak melihat satu baris pun.
ALTER TABLE catatan ENABLE ROW LEVEL SECURITY;
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_budi FROM catatan;
RESET ROLE;
-- Superuser selalu melewati RLS: seluruh tabel tetap terlihat.
SELECT count(*) AS terlihat_superuser FROM catatan;Output yang Dihasilkan:
ALTER TABLE
SET
terlihat_budi
---------------
0
(1 row)
RESET
terlihat_superuser
--------------------
6
(1 row)Kontras dua angkanya adalah pelajaran paling penting chapter ini. Dari balik SET ROLE budi_tmj, count(*) menjawab 0 — padahal GRANT SELECT masih berlaku dan tabel berisi enam baris. Perilaku ini disebut default deny: bagi role non-pemilik, baris hanya lolos bila ada policy yang mengizinkannya, dan “belum ada policy” berarti tidak ada izin sama sekali. Superuser, sebaliknya, tetap menjawab 6 — kita akan kembali ke posisi istimewa ini di bagian akhir.
Desain default deny ini disengaja dan sehat: kesalahan konfigurasi membuat data tertutup, bukan terbuka. Memigrasikan tabel ke RLS dengan aman karena itu berarti menulis policy lebih dulu, baru mengaktifkan RLS — bukan sebaliknya.
Policy Pertama: Baris Milik Si Pembaca #
Policy didefinisikan dengan CREATE POLICY: nama, tabel, operasi yang disasar, daftar role penerimanya, dan ekspresi penyaring. Ekspresi paling berguna untuk pola “baris milik pengguna” membandingkan kolom pemilik dengan current_user — identitas yang sedang bertindak dan yang kita kenal dari chapter pertama section ini:
-- Policy pertama: tiap kasir hanya melihat baris miliknya sendiri.
-- USING menentukan baris mana yang lolos untuk operasi baca.
CREATE POLICY lihat_milik ON catatan
FOR SELECT
TO budi_tmj, siti_tmj
USING (pemilik = current_user);
SET ROLE budi_tmj;
SELECT id, isi FROM catatan ORDER BY id;
SET ROLE siti_tmj;
SELECT id, isi FROM catatan ORDER BY id;
RESET ROLE;Output yang Dihasilkan:
CREATE POLICY
SET
id | isi
----+---------------------
1 | belanja mingguan
2 | kontak pemasok
5 | catatan stok opname
(3 rows)
SET
id | isi
----+-----------------
3 | jadwal lembur
4 | daftar retur
6 | rekap kas kecil
(3 rows)
RESETKlausa USING (pemilik = current_user) berjalan sebagai filter pada setiap pembacaan: dari balik SET ROLE budi_tmj, current_user bernilai budi_tmj, sehingga hanya tiga baris dengan pemilik = 'budi_tmj' yang lolos — id 1, 2, dan 5. SET ROLE siti_tmj memberikan hasil cermin: id 3, 4, dan 6. Satu tabel, satu policy, dua tampilan berbeda menurut identitas pembaca; tidak ada view per orang, dan penyaringan berlaku bahkan untuk query yang ditulis pengguna sendiri. Perhatikan juga klausa TO budi_tmj, siti_tmj — policy berlaku selektif per role, bukan untuk semua pengguna tabel.
WITH CHECK: Validasi untuk Baris Baru #
Untuk penulisan, RLS memakai dua kacamata. USING menyaring baris lama — baris mana yang boleh dilihat atau diubah; WITH CHECK memvalidasi baris baru — keadaan apa yang boleh masuk ke tabel. Policy untuk INSERT cukup memakai WITH CHECK karena tidak ada baris lama yang disaring:
-- Policy untuk penulisan: WITH CHECK memeriksa baris BARU
-- sebelum diizinkan masuk.
GRANT INSERT ON catatan TO budi_tmj, siti_tmj;
CREATE POLICY tulis_milik ON catatan
FOR INSERT
TO budi_tmj, siti_tmj
WITH CHECK (pemilik = current_user);
SET ROLE budi_tmj;
-- INSERT sah: pemilik baris sama dengan role yang menulis.
INSERT INTO catatan (id, pemilik, isi)
VALUES (7, 'budi_tmj', 'catatan baru budi');
RESET ROLE;
-- Baris baru itu terlihat bagi pemiliknya lewat policy lihat_milik.
SET ROLE budi_tmj;
SELECT id, pemilik, isi
FROM catatan
WHERE id = 7;
RESET ROLE;Output yang Dihasilkan:
GRANT
CREATE POLICY
SET
INSERT 0 1
RESET
SET
id | pemilik | isi
----+----------+-------------------
7 | budi_tmj | catatan baru budi
(1 row)
RESETAlurnya dua arah yang saling menguatkan: GRANT INSERT membuka privilege tabel, policy tulis_milik dengan WITH CHECK (pemilik = current_user) membatasi isi barisnya. INSERT atas nama budi_tmj dengan pemilik = 'budi_tmj' lolos — INSERT 0 1 — dan baris baru id 7 itu langsung terlihat bagi pemiliknya lewat policy lihat_milik dari contoh sebelumnya. Coba bayangkan tanpa RLS: untuk hasil yang sama kita butuh trigger yang menolak penulisan, atau aplikasi yang berdisiplin menulis pemilik dengan benar — dua hal yang tidak menjamin apa-apa terhadap query liar.
Sisi penolakannya kini bisa dibuktikan — Budi mencoba menitipkan baris atas nama Siti:
-- Budi mencoba menitipkan baris atas nama siti:
-- WITH CHECK menolak baris baru yang tidak cocok dengan identitasnya.
SET ROLE budi_tmj;
INSERT INTO catatan (id, pemilik, isi)
VALUES (8, 'siti_tmj', 'catatan titipan');Output yang Dihasilkan:
psql:tulis-ditolak.sql:6: ERROR: new row violates row-level security policy for table "catatan"Pesan new row violates row-level security policy for table "catatan" menegaskan bahwa WITH CHECK adalah penjaga terakhir sebelum baris menyentuh tabel — bukan sekadar peringatan. Privilege INSERT dipegang, baris id 8 secara struktural valid, tetapi pemilik = 'siti_tmj' tidak cocok dengan current_user sehingga seluruh perintah dibatalkan. Penyelamatan yang lebih dalam tentang interaksi USING dan WITH CHECK pada UPDATE — termasuk jebakan “query berhasil tapi nol baris berubah” — menjadi pembuka chapter Policies
.
Pemilik Tabel, FORCE, dan Superuser #
Satu pengecualian besar masih tersisa: pemilik tabel. Konsepnya masuk akal — pemilik bisa ALTER TABLE atau bahkan DROP TABLE sewaktu-waktu, sehingga membatasi pembacaannya lewat RLS terasa sia-sia — namun ada skenario nyata di mana pengecualian ini justru celah: aplikasi yang berjalan sebagai pemilik tabel dan berhasil disusupi SQL injection akan melihat semua baris. PostgreSQL menyediakan FORCE ROW LEVEL SECURITY untuk skenario itu, dan file terakhir chapter ini membuktikan ketiga tingkahnya sekaligus sebelum membersihkan seluruh role latihan:
-- Pemilik tabel lewat dari RLS kecuali dipaksa dengan FORCE.
CREATE TABLE arsip (
id integer PRIMARY KEY,
pemilik text NOT NULL,
isi text NOT NULL
);
INSERT INTO arsip (id, pemilik, isi) VALUES
(1, 'budi_tmj', 'arsip lama'),
(2, 'siti_tmj', 'arsip retur'),
(3, 'budi_tmj', 'arsip pajak');
-- Tabel ini milik budi_tmj, lengkap dengan RLS dan policy.
ALTER TABLE arsip OWNER TO budi_tmj;
ALTER TABLE arsip ENABLE ROW LEVEL SECURITY;
-- Policy untuk PUBLIC: cocok untuk semua role, termasuk pemiliknya.
CREATE POLICY arsip_milik ON arsip
FOR SELECT
TO PUBLIC
USING (pemilik = current_user);
-- Pemilik tabel MEMOTONG RLS: budi melihat seluruh 3 baris.
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_pemilik FROM arsip;
RESET ROLE;
-- FORCE ROW LEVEL SECURITY menundukkan pemilik pada policy-nya sendiri.
ALTER TABLE arsip FORCE ROW LEVEL SECURITY;
SET ROLE budi_tmj;
SELECT id, isi FROM arsip ORDER BY id;
RESET ROLE;
-- Superuser tetap kebal terhadap RLS apa pun kondisinya.
SELECT count(*) AS terlihat_superuser FROM arsip;
-- Pembersihan tuntas role latihan chapter ini.
DROP OWNED BY budi_tmj, siti_tmj;
DROP ROLE budi_tmj;
DROP ROLE siti_tmj;
SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname IN ('budi_tmj', 'siti_tmj');Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
ALTER TABLE
ALTER TABLE
CREATE POLICY
SET
terlihat_pemilik
------------------
3
(1 row)
RESET
ALTER TABLE
SET
id | isi
----+-------------
1 | arsip lama
3 | arsip pajak
(2 rows)
RESET
terlihat_superuser
--------------------
3
(1 row)
DROP OWNED
DROP ROLE
DROP ROLE
sisa_role_latihan
-------------------
0
(1 row)Tiga angka pada file itu membentuk garis pemisah yang tegas. Sebelum FORCE, budi_tmj sebagai pemilik tabel arsip menjawab 3 — kebijakan arsip_milik yang berlaku untuk PUBLIC sama sekali tidak menyentuhnya. Sesudah ALTER TABLE arsip FORCE ROW LEVEL SECURITY, jawaban yang sama jatuh ke 2 — hanya baris pemilik = 'budi_tmj'; pemilik kini tunduk pada policy-nya sendiri. Dan di baris terakhir, postgres tetap menjawab 3 karena superuser tidak pernah tunduk pada RLS, dengan atau tanpa FORCE — satu-satunya jalan lain melewati RLS adalah atribut BYPASSRLS yang kita lihat di tabel atribut chapter Role dan User
.
File penutup juga memperagakan pembersihan lengkap: DROP OWNED BY budi_tmj, siti_tmj menjatuhkan tabel arsip (milik budi) sekaligus seluruh grant di database ini, dan kedua role kemudian bisa dihapus tanpa hambatan — pola kebersihan yang sama dari seluruh chapter sebelumnya, kini dengan objek yang dimiliki role.
Mekanisme RLS sudah menyala, tapi CREATE POLICY yang kita tulis masih bentuk paling sederhana. Kebutuhan nyata menghendaki policy permissive yang digabungkan dengan OR, policy restrictive yang dipangkas dengan AND, subquery yang menghubungkan tabel lain, dan parameter sesi ala current_setting() untuk isolasi tenant — anatomi lengkapnya menunggu di chapter Policies
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
aktifkan-rls.sql
Unduh berkas-- Mengaktifkan RLS tanpa membuat policy sama sekali.
-- Hasilnya: lubang hitam -- role biasa tidak melihat satu baris pun.
ALTER TABLE catatan ENABLE ROW LEVEL SECURITY;
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_budi FROM catatan;
RESET ROLE;
-- Superuser selalu melewati RLS: seluruh tabel tetap terlihat.
SELECT count(*) AS terlihat_superuser FROM catatan;
buat-policy.sql
Unduh berkas-- Policy pertama: tiap kasir hanya melihat baris miliknya sendiri.
-- USING menentukan baris mana yang lolos untuk operasi baca.
CREATE POLICY lihat_milik ON catatan
FOR SELECT
TO budi_tmj, siti_tmj
USING (pemilik = current_user);
SET ROLE budi_tmj;
SELECT id, isi FROM catatan ORDER BY id;
SET ROLE siti_tmj;
SELECT id, isi FROM catatan ORDER BY id;
RESET ROLE;
pemilik-bypass.sql
Unduh berkas-- Pemilik tabel lewat dari RLS kecuali dipaksa dengan FORCE.
CREATE TABLE arsip (
id integer PRIMARY KEY,
pemilik text NOT NULL,
isi text NOT NULL
);
INSERT INTO arsip (id, pemilik, isi) VALUES
(1, 'budi_tmj', 'arsip lama'),
(2, 'siti_tmj', 'arsip retur'),
(3, 'budi_tmj', 'arsip pajak');
-- Tabel ini milik budi_tmj, lengkap dengan RLS dan policy.
ALTER TABLE arsip OWNER TO budi_tmj;
ALTER TABLE arsip ENABLE ROW LEVEL SECURITY;
-- Policy untuk PUBLIC: cocok untuk semua role, termasuk pemiliknya.
CREATE POLICY arsip_milik ON arsip
FOR SELECT
TO PUBLIC
USING (pemilik = current_user);
-- Pemilik tabel MEMOTONG RLS: budi melihat seluruh 3 baris.
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_pemilik FROM arsip;
RESET ROLE;
-- FORCE ROW LEVEL SECURITY menundukkan pemilik pada policy-nya sendiri.
ALTER TABLE arsip FORCE ROW LEVEL SECURITY;
SET ROLE budi_tmj;
SELECT id, isi FROM arsip ORDER BY id;
RESET ROLE;
-- Superuser tetap kebal terhadap RLS apa pun kondisinya.
SELECT count(*) AS terlihat_superuser FROM arsip;
-- Pembersihan tuntas role latihan chapter ini.
DROP OWNED BY budi_tmj, siti_tmj;
DROP ROLE budi_tmj;
DROP ROLE siti_tmj;
SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname IN ('budi_tmj', 'siti_tmj');
siapkan-rls.sql
Unduh berkas-- Bahan latihan Row Level Security: tabel catatan milik bersama
-- dengan kolom pemilik sebagai pembeda baris.
DROP ROLE IF EXISTS budi_tmj;
DROP ROLE IF EXISTS siti_tmj;
CREATE ROLE budi_tmj LOGIN;
CREATE ROLE siti_tmj LOGIN;
CREATE TABLE catatan (
id integer PRIMARY KEY,
pemilik text NOT NULL,
isi text NOT NULL
);
INSERT INTO catatan (id, pemilik, isi) VALUES
(1, 'budi_tmj', 'belanja mingguan'),
(2, 'budi_tmj', 'kontak pemasok'),
(3, 'siti_tmj', 'jadwal lembur'),
(4, 'siti_tmj', 'daftar retur'),
(5, 'budi_tmj', 'catatan stok opname'),
(6, 'siti_tmj', 'rekap kas kecil');
GRANT SELECT ON catatan TO budi_tmj, siti_tmj;
-- Sebelum RLS: siapa pun yang memegang SELECT melihat seluruh tabel.
SET ROLE budi_tmj;
SELECT count(*) AS terlihat_budi FROM catatan;
RESET ROLE;
tulis-ditolak.sql
Unduh berkas-- Budi mencoba menitipkan baris atas nama siti:
-- WITH CHECK menolak baris baru yang tidak cocok dengan identitasnya.
SET ROLE budi_tmj;
INSERT INTO catatan (id, pemilik, isi)
VALUES (8, 'siti_tmj', 'catatan titipan');
tulis-policy.sql
Unduh berkas-- Policy untuk penulisan: WITH CHECK memeriksa baris BARU
-- sebelum diizinkan masuk.
GRANT INSERT ON catatan TO budi_tmj, siti_tmj;
CREATE POLICY tulis_milik ON catatan
FOR INSERT
TO budi_tmj, siti_tmj
WITH CHECK (pemilik = current_user);
SET ROLE budi_tmj;
-- INSERT sah: pemilik baris sama dengan role yang menulis.
INSERT INTO catatan (id, pemilik, isi)
VALUES (7, 'budi_tmj', 'catatan baru budi');
RESET ROLE;
-- Baris baru itu terlihat bagi pemiliknya lewat policy lihat_milik.
SET ROLE budi_tmj;
SELECT id, pemilik, isi
FROM catatan
WHERE id = 7;
RESET ROLE;