Langsung ke konten
Row-Level Security vs Schema-per-Tenant: Saya Coba Keduanya untuk Isolasi Data Multi-Tenant, Ini Hasilnya
Kembali ke Blog
Database·6 menit baca·

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.

TH
Tomi Hartanto·Senior Software Engineer

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.

Ilustrasi row-level security PostgreSQL menyaring baris per tenant

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 diawali tenant_id praktis tidak terpakai.

Ilustrasi schema-per-tenant dengan skema database terpisah per pelanggan

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_connections PostgreSQL.
  • Dinamis: SET search_path per 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

  1. Aplikasi connect dengan user bukan owner tabel (atau FORCE ROW LEVEL SECURITY).
  2. Set app.current_tenant via set_config(..., true) di transaction yang sama dengan query — jangan SET global di pooled connection.
  3. Audit semua view — pastikan tidak ada yang tidak sengaja melewati policy.
  4. Semua index composite di tabel tenant mulai dari tenant_id.
  5. 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

TH

Tomi Hartanto

Senior Software Engineer

Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.