
Row-Level Security vs Schema-per-Tenant: Saya Coba Keduanya untuk Isolasi Data Multi-Tenant, Ini Hasilnya
Perbandingan praktis RLS PostgreSQL vs schema-per-tenant untuk aplikasi SaaS multi-tenant: keamanan data, performa query, kompleksitas migrasi, dan kapan masing-masing layak dipakai.
Minggu lalu seorang junior di tim saya bertanya: "Pak, kita cukup pakai kolom tenant_id di WHERE clause aja kan? Kenapa repot-repot pakai RLS atau schema terpisah?" Pertanyaan bagus — dan jujur, dulu saya juga berpikir begitu. Sampai satu insiden di 2021 ketika sebuah bug di query builder membuat customer A bisa melihat data invoice customer B. Untung ketahuan saat internal testing, bukan oleh customer. Sejak itu saya tidak pernah lagi percaya bahwa WHERE clause di level aplikasi cukup untuk isolasi data multi-tenant.
Artikel ini bukan teori. Saya akan membandingkan dua pendekatan yang benar-benar saya pakai di production: Row-Level Security (RLS) PostgreSQL dan schema-per-tenant, plus kapan masing-masing masuk akal dan kapan tidak.
Kenapa WHERE Clause Saja Tidak Cukup
Pendekatan paling umum: setiap tabel punya kolom tenant_id, dan setiap query di aplikasi menyaring berdasarkan tenant yang sedang login. Masalahnya, ini menaruh keamanan di tangan setiap developer dan setiap query. Cukup satu endpoint lupa memfilter — atau satu raw query manual yang kelewat review — dan isolasi data bolong.
Keamanan yang bergantung pada disiplin manual akan bocor pada skala tertentu. Itu bukan pertanyaan apakah, tapi kapan.
Pendekatan 1: Row-Level Security (RLS)
RLS adalah fitur bawaan PostgreSQL sejak versi 9.5 yang memindahkan filter tenant ke level database. Database yang menolak mengembalikan baris milik tenant lain, apa pun yang ditulis aplikasi Anda.

Konsepnya sederhana: Anda definisikan policy di tabel, dan PostgreSQL menyembunyikan baris yang tidak lolos policy seolah-olah baris itu tidak pernah ada.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Lalu di aplikasi, setiap kali request masuk, Anda set variabel session:
SET app.current_tenant = '9b4c1e2a-...';
Dengan Prisma, saya pakai extension client seperti ini:
const tenantClient = prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query, operation }) {
return prisma.$transaction([
prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`,
query(args),
]).then(([, result]) => result);
},
},
},
});
Satu catatan penting yang dulu bikin saya bingung: user yang berperan BY_PASSRLS (termasuk superuser dan owner tabel secara default) melewati RLS. Jadi aplikasi produksi harus connect dengan user non-owner, atau set FORCE ROW LEVEL SECURITY di tabelnya.
Yang saya suka
- Satu database, satu skema, satu set tabel. Migrasi skema jalan sekali untuk semua tenant. Kalau Anda pernah membaca pengalaman saya migrasi skema tanpa downtime, Anda tahu betapa berharganya ini — bayangkan menjalankan expand-and-contract berkali-kali ratusan skema.
- Kebocoran isolasi tertutup di level database. Bug di aplikasi tidak otomatis jadi kebocoran data lintas tenant.
- Query lintas tenant untuk keperluan analitik/admin masih gampang — tinggal sesi tanpa variabel tenant (dengan user yang tepat).
Yang membuat saya meringis
- RLS tidak berlaku di view secara default. Ini jebakan klasik yang sempat menggigit saya: tabel pakai RLS, tapi aplikasi query lewat view — view berjalan dengan hak owner-nya, dan policy diabaikan. Detailnya dibahas bagus di jawaban Stack Overflow tentang RLS di Postgres views.
- Lupa
SET app.current_tenant= data kosong atau error, tergantung nilainya. Error yang bagus (fail closed), tapi tetap harus di-test. - Semua query menyentuh tabel besar berisi data SEMUA tenant. Index harus selalu leading di
tenant_id, dan ini nyambung dengan apa yang saya tulis di artikel indexing PostgreSQL: index composite yang tidak diawalitenant_idpraktis tidak terpakai.

Pendekatan 2: Schema-per-Tenant
Setiap tenant dapat skema sendiri: tenant_acme.invoices, tenant_globex.invoices, struktur identik. Isolasi fisik di level katalog PostgreSQL.
Yang saya suka
- Isolasi benar-benar keras. Tidak ada policy yang bisa bocor karena tidak ada policy — data tenant lain memang tidak ada di skema itu.
- Restore/backup per tenant jadi trivial. Customer pulang, drop satu skema.
- Ukuran tabel per tenant kecil, query planner sering lebih sehat.
- Tenant enterprise yang minta "data kami terpisah" bisa dilayani tanpa kode berbeda.
Yang membuat saya meringis
- Migrasi skema jadi mimpi buruk di atas ~50 tenant. Anda butuh runner yang menjalankan migrasi per skema, tracking versi per skema, dan strategi kalau ada skema gagal di tengah jalan.
- Connection pool membengkak. Kalau tenant di-route ke database connection berbeda, Anda kena masalah yang saya bahas di connection pooling — 100 tenant × pool 10 koneksi = 1.000 koneksi, jauh melebihi default
max_connectionsPostgreSQL. - Dinamis:
SET search_pathper request rawan salah route kalau tidak hati-hati.
Perbandingan Cepat
| Aspek | RLS | Schema-per-tenant |
|---|---|---|
| Kompleksitas migrasi | Sekali untuk semua | Per skema, butuh orkestrasi |
| Kekerasan isolasi | Bagus (dengan setup benar) | Sangat keras |
| Kebutuhan koneksi | Satu pool | Bisa banyak pool |
| ONBOARDING tenant baru | Insert row | CREATE SCHEMA + migrasi |
| Analitik lintas tenant | Mudah | Butuh UNION/FDW |
| Cocok untuk | Ratusan-ribuan tenant kecil | Puluhan tenant besar/enterprise |
Pendapat Saya (dan Kapan Saya Salah)
Default saya sekarang: RLS untuk SaaS dengan banyak tenant kecil-menengah. Ia memberi 90% keamanan isolasi dengan 20% kompleksitas operasional schema-per-tenant. Praktik yang saya lihat di studi kasus implementasi multi-tenant dengan RLS dan panduan hands-on authorization PostgreSQL sejalan dengan pengalaman saya: kuncinya bukan fitur RLS-nya, tapi disiplin dalam setup user, policy, dan testing-nya.
Schema-per-tenant saya pakai hanya kalau tenant-nya sedikit (di bawah ~30), besar, dan sensitif soal isolasi — misalnya kontrak enterprise yang mensyaratkan data terpisah secara fisik. Di atas itu, biaya operasionalnya melampaui manfaatnya.
Dan jujur, ada opsi ketiga yang sering dilupakan: database-per-tenant. Untuk customer yang benar-benar paranoik, itu opsi paling bersih — tapi itu topik lain.
Checklist Praktis Kalau Anda Pilih RLS
- Aplikasi connect dengan user bukan owner tabel (atau
FORCE ROW LEVEL SECURITY). - Set
app.current_tenantviaset_config(..., true)di transaction yang sama dengan query — janganSETglobal di pooled connection. - Audit semua view — pastikan tidak ada yang tidak sengaja melewati policy.
- Semua index composite di tabel tenant mulai dari
tenant_id. - Tulis integration test yang sengaja mencoba membaca data tenant lain. Kalau test itu gagal gagal-merevolusi, bagus. Kalau loloh, Anda tahu ada yang bocor.
Isolasi data bukan fitur yang Anda tambahkan belakangan. Itu keputusan arsitektur paling awal yang paling mahal diubah nanti — dan semoga perbandingan ini menghemat satu-dua insiden Anda.
Sumber
- Shipping Multi-Tenant SaaS Using Postgres Row-Level Security — studi kasus implementasi RLS di produk SaaS nyata, sejalan dengan setup yang saya pakai
- Hands-On with PostgreSQL Authorization – Part 2 – Row-Level Security — penjelasan detail perilaku policy, user, dan privilege di RLS
- Why isn't row level security enabled for Postgres views? — jebakan view yang melewati RLS, pernah menggigit saya di production
- Postgres 9.5 feature highlight: row-level security and policies — latar belakang fitur RLS sejak diperkenalkan di PostgreSQL 9.5
Tomi Hartanto
Senior Software Engineer
Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.
Artikel Terkait
Tomi Hartanto
Senior Software EngineerBackend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.


