PostgreSQL Row-Level Security untuk SaaS Multi-Tenant

Foto oleh MattHurst on flickr
Row-level security adalah fitur PostgreSQL yang memungkinkan policy dipasang langsung pada tabel sehingga database sendiri yang memfilter baris mana yang boleh dilihat atau diubah oleh suatu koneksi. Untuk SaaS multi-tenant, ini berarti isolasi tenant ditegakkan di lapisan data, bukan hanya bergantung pada kode aplikasi yang ingat menambahkan filter tenant_id di setiap query. Bahkan skrip SQL mentah atau job latar belakang yang bermasalah tetap tunduk pada policy yang sama.
Secara default, PostgreSQL mengecualikan pemilik tabel dari policy row-level security miliknya sendiri, dan ini penting karena kebanyakan role database aplikasi adalah pemilik tabel atau memiliki hak setara pemilik. FORCE ROW LEVEL SECURITY menghapus pengecualian tersebut sehingga role pemilik tunduk pada filter tenant yang sama seperti role lainnya. Tanpa FORCE, policy bisa tampak benar saat pengujian namun diam-diam tidak berpengaruh di produksi jika aplikasi terhubung sebagai role pemilik.
Session variable adalah parameter konfigurasi kustom, biasanya dinamai dengan namespace seperti app.current_tenant, yang diset aplikasi di awal setiap permintaan dan dibaca kembali oleh policy melalui current_setting. Policy membandingkan kolom tenant_id dengan nilai tersebut, sehingga kode query tidak perlu menyertakan filter tenant secara manual. Nilai ini harus dibatasi cakupannya dengan benar menggunakan SET LOCAL di dalam transaksi, jika tidak nilai tersebut bisa bocor ke permintaan lain yang tidak terkait.
Bisa. Connection pooling mode transaksi, yang menjadi default pada tool seperti PgBouncer, mendaur ulang koneksi fisik antar transaksi dan tidak mempertahankan status SET bercakupan sesi lintas daur ulang tersebut. Jika session variable tenant diset dengan SET biasa alih-alih SET LOCAL di dalam transaksi yang sama dengan query, nilai tersebut bisa terbawa ke permintaan tenant yang sama sekali berbeda pada transaksi berikutnya. Selalu batasi variabel tenant dengan SET LOCAL di dalam batas transaksi eksplisit ketika pooling digunakan.
Tulis pengujian negatif yang berjalan sebagai role non-superuser dan bukan BYPASSRLS yang mencerminkan hak akses database aplikasi yang sebenarnya, set konteks satu tenant, sisipkan data, pindah ke konteks tenant kedua, lalu pastikan query yang sesuai mengembalikan nol baris, bukan error. Jalankan suite yang sama melalui mode connection pooling yang sama seperti di produksi, karena policy yang lolos pada koneksi langsung belum tentu bertahan di bawah pooling. Masukkan pengujian ini ke CI agar perubahan di masa depan yang melemahkan policy atau hak akses role tertangkap otomatis.

Foto oleh MattHurst on flickr
Setiap SaaS multi-tenant pada akhirnya menghadapi pertanyaan yang sama: apa yang terjadi ketika suatu hari bug aplikasi lupa menyertakan klausa WHERE tenant_id. Jika isolasi tenant hanya bergantung pada kode aplikasi, satu filter yang hilang saja bisa membocorkan invoice, tiket, atau pesan satu pelanggan langsung ke dashboard pelanggan lain. Row-level security memindahkan jaminan tersebut ke dalam PostgreSQL sendiri, sehingga database menolak mengembalikan atau menulis baris yang bukan milik pemanggil, apa pun yang terlupa dilakukan oleh lapisan aplikasi.
Tulisan ini membahas konfigurasi bergaya produksi: mengaktifkan row-level security dengan FORCE agar pemilik tabel sekalipun tidak dikecualikan, menulis policy yang mengacu pada session variable yang diset per permintaan, cara spesifik connection pooling bisa diam-diam merusak session variable tersebut, serta pendekatan pengujian yang membuktikan isolasi benar-benar bekerja, bukan sekadar berharap begitu.
Sebagian besar aplikasi multi-tenant mengisolasi tenant dengan menambahkan kolom tenant_id dan memfilter setiap query di lapisan ORM atau repository. Cara ini bekerja sampai suatu saat gagal. Kegagalan umum meliputi: engineer baru menambahkan raw query yang melewati filter, job latar belakang atau skrip admin yang mengakses tabel secara langsung, lapisan cache yang menyimpan hasil dengan key yang salah lintas tenant, atau refactor yang diam-diam menghilangkan klausa WHERE saat migrasi. Semua kasus ini tidak terlihat saat code review karena query yang kehilangan filter tampak identik dengan query normal.
Perlakukan row-level security sebagai lapisan pertahanan kedua yang independen di bawah filter lapisan aplikasi, bukan pengganti filter tersebut. Pertahanan berlapis berarti bug di satu lapisan tetap tertangkap oleh lapisan yang lain.
Row-level security diaktifkan per tabel dan secara default mati bahkan untuk tabel yang sudah memiliki policy. Varian FORCE penting untuk setup multi-tenant karena, tanpa itu, role pemilik tabel melewati semua policy, dan role itulah yang biasanya dipakai oleh koneksi aplikasi.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);Policy itu sendiri membandingkan kolom tenant_id dengan session variable PostgreSQL yang dibaca melalui current_setting. Aplikasi mengeset variabel tersebut sekali per permintaan atau per transaksi, dan setiap query berikutnya otomatis mewarisi filter yang sama tanpa perubahan apa pun pada kode query.
BEGIN;
SET LOCAL app.current_tenant = '3f2504e0-4f89-11d3-9a0c-0305e82c3301';
SELECT * FROM invoices WHERE status = 'overdue';
COMMIT;Variabel diset dengan SET LOCAL di dalam transaksi yang sama yang menjalankan query tenant tersebut, sehingga cakupannya terbatas pada transaksi itu saja. Menggunakan SET tanpa LOCAL membuat nilai tersebut bertahan untuk sisa umur koneksi fisik, yang berbahaya begitu koneksi tersebut dipakai ulang untuk tenant lain.
| Perintah | Cakupan | Risiko pada multi-tenant |
|---|---|---|
| SET app.current_tenant | Sisa sesi atau koneksi yang dipooling | Tinggi: nilai bocor ke permintaan berikutnya yang dilayani dari koneksi yang sama |
| SET LOCAL app.current_tenant | Hanya transaksi saat ini | Rendah: otomatis terhapus saat commit atau rollback |
| current_setting dengan missing_ok true | Dibaca saat query dijalankan | Mencegah error keras jika variabel tidak pernah diset, sehingga konteks tenant yang hilang membuat policy gagal, bukan membuat query crash |
Connection pooling mode transaksi, yang menjadi default pada tool seperti PgBouncer, mendaur ulang koneksi fisik antar transaksi. Status sesi dari SET dan RESET memang tidak bertahan lintas daur ulang tersebut secara sengaja, dan itulah sebabnya SET LOCAL di dalam batas transaksi bukan opsional di sini, itu satu-satunya cara set yang bertahan benar di bawah pooling.
Session pooling mempertahankan satu koneksi fisik per klien selama seluruh sesi, sehingga SET bercakupan sesi berjalan baik, tetapi efisiensi resource yang menjadi tujuan pooling banyak hilang. Transaction pooling mengambil kembali koneksi begitu satu transaksi selesai, dan di titik inilah banyak tim celaka: SET app.current_tenant bercakupan sesi diam-diam terbawa ke transaksi berikutnya milik permintaan lain yang tidak terkait.
Dalam praktiknya ini berarti mengaudit setiap tempat koneksi diambil dari pool dan memastikan SET LOCAL tenant terjadi di dalam transaksi yang sama dengan query yang membutuhkannya, bukan sebelum transaksi dimulai dan bukan dalam round trip terpisah. Sebagian besar ORM dan query builder menyediakan callback atau middleware hook yang membungkus seluruh permintaan dalam satu transaksi, dan hook itulah tempat yang tepat untuk mengeluarkan SET LOCAL tenant tepat satu kali, alih-alih menyebarkannya di berbagai method repository terpisah yang membuat satu jalur kode mudah terlewat.
Policy yang tampak benar di atas kertas tetap bisa membocorkan data lewat kasus tepi: tenant ID yang NULL, role dengan BYPASSRLS yang tidak sengaja diberikan, atau koneksi superuser yang dipakai untuk query yang seharusnya terbatas pada satu tenant. Satu-satunya cara yang andal untuk menangkap ini adalah suite pengujian negatif yang berjalan sebagai role non-superuser dan memastikan nol baris kembali untuk data tenant lain, bukan sekadar percaya bahwa sintaks policy saja sudah cukup.
-- Negative isolation test: assert tenant B cannot see tenant A rows
SET LOCAL app.current_tenant = '<tenant_b_uuid>';
SELECT count(*) FROM invoices WHERE tenant_id = '<tenant_a_uuid>';
-- expected: 0 rows, not an errorSetelah pengujian negatif ini ada di CI, refactor di masa depan yang tanpa sengaja melemahkan policy, mengubah atribut BYPASSRLS suatu role, atau menghapus FORCE ROW LEVEL SECURITY pada suatu tabel akan tertangkap otomatis sebelum mencapai produksi, alih-alih menunggu pelanggan menyadari datanya tercampur dengan milik orang lain.
Mengaktifkan row-level security pada tabel yang sudah berisi data produksi aman dilakukan secara bertahap: aktifkan dulu tanpa FORCE, verifikasi perilaku aplikasi di staging dengan variabel tenant tersambung di setiap jalur kode, lalu aktifkan FORCE setelah yakin role pemilik tabel tidak pernah diandalkan untuk melewati policy. Set row_security ke off sementara selama skrip backup dan migrasi agar tooling administratif tidak diam-diam memfilter baris yang perlu dilihatnya, lalu nyalakan kembali segera setelahnya. Lakukan rollout tabel demi tabel, bukan sekaligus untuk seluruh skema dalam satu migrasi, mulai dari tabel berisiko paling tinggi yang menyimpan data yang langsung terlihat pelanggan, sehingga kesalahan pada satu policy mudah diisolasi dan dibatalkan tanpa menyentuh semua tabel lain sekaligus.
Beri index pada kolom tenant_id yang menjadi filter setiap policy. Policy menambahkan klausa WHERE ke setiap query yang direncanakan PostgreSQL, dan tenant_id tanpa index mengubah klausa itu menjadi sequential scan di seluruh tabel untuk setiap permintaan.