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.
Mengapa filter tenant di lapisan aplikasi saja tidak cukup
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.
- Satu filter yang terlupa di satu endpoint saja bisa membocorkan data seluruh tenant melalui endpoint tersebut, bukan hanya tenant yang terdampak.
- Isolasi di lapisan aplikasi harus diverifikasi ulang di setiap jalur kode baru, setiap kenaikan versi ORM, dan setiap skrip ad hoc selamanya.
- Isolasi yang ditegakkan database gagal secara aman secara default: policy yang tidak mengembalikan baris apa pun lebih aman daripada filter yang hilang dan mengembalikan data semua orang.
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.
Mengaktifkan row-level security dan menulis policy tenant
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;Bagaimana session variable itu sebenarnya diset
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.
Apa yang sebenarnya rusak akibat pooling dan cara mengatasinya
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.
- Selalu set konteks tenant dengan SET LOCAL dan selalu lakukan sebagai statement pertama di dalam transaksi eksplisit, jangan mengandalkan mode autocommit per statement untuk membatasi cakupannya dengan benar.
- Jika ORM sudah membungkus setiap permintaan dalam transaksinya sendiri, sisipkan SET LOCAL tenant ke dalam batas transaksi yang sama tersebut, bukan sebagai statement terpisah di luar transaksi.
- Hindari caching statement berbasis PREPARE lintas koneksi yang dipooling kecuali pooler secara eksplisit mendukungnya, karena plan yang di-cache dan terkait status sesi satu tenant bisa saja dipakai ulang untuk query tenant lain.
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.
Menguji bahwa isolasi benar-benar bekerja
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.
- Buat role pengujian khusus yang bukan superuser dan bukan BYPASSRLS, yang mencerminkan persis apa yang bisa dilakukan oleh role runtime aplikasi.
- Untuk setiap tabel dengan policy tenant, tulis pengujian yang mengeset konteks tenant A, menyisipkan baris, berpindah ke konteks tenant B, dan memastikan SELECT yang sesuai mengembalikan nol baris.
- Jalankan suite yang sama melalui koneksi yang dipooling dengan mode pooling yang sama seperti di produksi, karena pengujian yang lolos pada koneksi langsung tidak menjamin policy tetap bertahan di bawah pooling.
-- 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.
Catatan rollout untuk skema multi-tenant yang sudah berjalan
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.

