| name | saas-multi-tenant |
| description | Design and implement multi-tenant SaaS architectures with RLS, tenant isolation, and PostgreSQL / Desain dan implementasikan arsitektur SaaS multi-tenant dengan RLS, isolasi tenant, dan PostgreSQL. |
| author | Roedy Rustam |
SaaS Multi-Tenant Architecture
English | Bahasa Indonesia
English
Trigger Conditions
Use this skill when:
- The user is building a SaaS application where multiple customers share the same database.
- The user asks about tenant isolation, Row-Level Security (RLS), or data leakage prevention.
- The user needs to scope every database query to a specific tenant automatically.
- The user asks about tradeoffs between shared-schema, schema-per-tenant, and database-per-tenant.
- The user is implementing admin endpoints that must access data across tenants.
- The user needs to add
tenant_id columns to an existing single-tenant application.
Core Workflow
1. Determine the Tenancy Model
Discuss scale expectations and isolation requirements with the user. Choose the appropriate model:
- Shared Schema (Row-Level Isolation): For most SaaS apps under 10,000 tenants. Uses a
tenant_id column on every table. Easy to maintain, query across tenants, and migrate.
- Isolated Schema (Schema-per-Tenant): Each tenant gets their own PostgreSQL schema. Better for enterprise compliance (HIPAA, SOC2), strict data isolation, and per-tenant backup/restore. Requires dynamic schema routing and multi-schema migrations.
- Database-per-Tenant: Highest isolation, but extremely complex to manage and scale infrastructure. Generally avoid unless strictly required by enterprise contracts.
2. Shared Schema: Add tenant_id to Every Tenant-Scoped Table
If using Shared Schema, the column must be NOT NULL, type UUID or TEXT, and included in every composite index. Never allow a tenant-scoped table to exist without this column to prevent data leaks.
3. Shared Schema: Set Up PostgreSQL Row-Level Security (RLS)
If using Shared Schema, create a policy on each tenant-scoped table that filters rows by a session variable like current_setting('app.current_tenant_id'). This acts as a database-level safety net if application code forgets a WHERE clause.
4. Isolated Schema: Dynamic Schema Routing
If using Isolated Schema, set the PostgreSQL search_path dynamically at the start of every request/transaction to point to the tenant's specific schema (e.g., SET LOCAL search_path TO tenant_abc, public;). Ensure connection poolers (like PgBouncer) reset this properly.
5. Build Tenant-Aware Middleware
At the start of every request, extract the tenant_id from the authenticated session, JWT claims, subdomain, or custom domain.
- For Shared Schema: Set it on the database connection using
SET LOCAL app.current_tenant_id = '...' inside a transaction.
- For Isolated Schema: Set the
search_path to the tenant's schema securely.
6. Scope ORM Queries Automatically
- For Shared Schema: If using Prisma, apply a global middleware/extension that injects
where: { tenantId } automatically. If using Drizzle, create a base query builder that includes the tenant filter.
- For Isolated Schema: Ensure the ORM executes queries against the currently active schema. Some ORMs require specific configuration for multi-schema tenancy.
7. Separate Cross-Tenant Admin Routes
Admin endpoints that aggregate data across tenants must:
- For Shared Schema: Bypass RLS explicitly using a dedicated database role (e.g.,
bypassrls or an admin bypass role).
- For Isolated Schema: Query multiple schemas or an aggregation schema, which adds complexity.
8. Multi-Page Application (MPA) & Multiple Entry Points Approach
When organizing a multi-tenant SaaS as a Multi-Page Application within a single repository, adhere to the following guidelines:
- Routing & Multiple Entry Points: Use the
multiple-entry-points skill to logically separate traffic. For example, use a tenant.php entry point that strictly mandates a valid tenant_id resolution before bootstrapping the app, while a landing.php entry point handles anonymous public traffic safely.
- Shared Layouts & Partials: Avoid duplicating HTML (headers, footers, navigation). Create a
src/Views/layouts/ directory for base templates and a src/Views/partials/ directory for reusable UI components. Controllers should inject page-specific content into the base layout.
- Asset Management: Store all static assets in the
public/ directory. Use cache-busting techniques when linking assets in the views.
- State Management: Use server-side sessions securely for user authentication, tenant context, flash messages, and tracking state across full page reloads.
Best Practices & Pitfalls
- Never query a tenant-scoped table without a
tenant_id filter or active RLS.
- Never use auto-incrementing integer IDs for tenant-scoped resources. Use UUIDs to prevent ID enumeration attacks.
- Connection Pooling Mitigation: When using connection pooling (e.g., PgBouncer), session variables set by
SET LOCAL are only scoped to the transaction. If you use session-scoped variables, ensure you reset them (RESET ALL or SET app.current_tenant_id = '') before returning the connection to the pool, or run them strictly inside a BEGIN...COMMIT transaction block.
- RLS Bypass Risk: Ensure database migrations and triggers are run with
SECURITY DEFINER only when strictly necessary, and explicitly set a safe search_path to prevent search path hijacking.
- Test with at least 3 tenants in your seed data to catch cross-tenant data leakage bugs.
Bahasa Indonesia
Kondisi Pemicu
Gunakan skill ini ketika:
- Pengguna membangun aplikasi SaaS di mana beberapa pelanggan berbagi database yang sama.
- Pengguna bertanya tentang isolasi tenant, Row-Level Security (RLS), atau pencegahan kebocoran data.
- Pengguna perlu membatasi setiap query database ke tenant tertentu secara otomatis.
- Pengguna bertanya tentang trade-off antara shared-schema, schema-per-tenant, dan database-per-tenant.
- Pengguna mengimplementasikan endpoint admin yang perlu mengakses data lintas tenant.
- Pengguna perlu menambahkan kolom
tenant_id ke aplikasi single-tenant yang sudah ada.
Alur Kerja Inti
1. Tentukan Model Tenancy
Diskusikan dengan pengguna mengenai skala dan persyaratan isolasi mereka. Pilih model yang sesuai:
- Shared Schema (Isolasi Baris / Row-Level): Untuk sebagian besar aplikasi SaaS di bawah 10.000 tenant. Menggunakan kolom
tenant_id pada setiap tabel. Mudah dipelihara, query lintas tenant, dan migrasi.
- Isolated Schema (Schema-per-Tenant): Setiap tenant mendapatkan skema PostgreSQL mereka sendiri. Lebih baik untuk kepatuhan enterprise (HIPAA, SOC2), isolasi data ketat, dan backup/restore per-tenant. Membutuhkan routing skema dinamis dan migrasi multi-skema.
- Database-per-Tenant: Isolasi tertinggi, namun sangat kompleks untuk dikelola dan diskalakan. Umumnya dihindari kecuali diwajibkan secara ketat oleh kontrak enterprise.
2. Shared Schema: Tambahkan tenant_id di Setiap Tabel yang Terkait Tenant
Jika menggunakan Shared Schema, kolom ini harus NOT NULL, bertipe UUID atau TEXT, dan dimasukkan ke dalam setiap composite index. Jangan biarkan ada tabel tanpa kolom ini untuk mencegah kebocoran data.
3. Shared Schema: Konfigurasikan PostgreSQL Row-Level Security (RLS)
Jika menggunakan Shared Schema, buat kebijakan (policy) RLS pada setiap tabel yang memfilter baris berdasarkan variabel sesi seperti current_setting('app.current_tenant_id'). Ini bertindak sebagai pengaman tingkat database jika kode aplikasi lupa menyertakan filter WHERE.
4. Isolated Schema: Routing Skema Dinamis
Jika menggunakan Isolated Schema, atur search_path PostgreSQL secara dinamis pada awal setiap request/transaksi untuk mengarah ke skema spesifik tenant (misal: SET LOCAL search_path TO tenant_abc, public;). Pastikan connection pooler (seperti PgBouncer) mereset ini dengan benar.
5. Buat Middleware yang Sadar Tenant (Tenant-Aware)
Pada awal setiap request, ekstrak tenant_id dari sesi autentikasi, JWT, subdomain, atau custom domain.
- Untuk Shared Schema: Atur nilai tersebut pada koneksi database menggunakan
SET LOCAL app.current_tenant_id = '...' di dalam transaksi.
- Untuk Isolated Schema: Atur
search_path ke skema tenant secara aman.
6. Batasi Query ORM Secara Otomatis
- Untuk Shared Schema: Jika menggunakan Prisma, gunakan middleware/ekstensi global untuk menyisipkan
where: { tenantId } secara otomatis. Jika menggunakan Drizzle, buat base query builder yang menyertakan filter tenant.
- Untuk Isolated Schema: Pastikan ORM mengeksekusi query pada skema yang sedang aktif. Beberapa ORM memerlukan konfigurasi khusus untuk multi-schema tenancy.
7. Pisahkan Jalur Akses Admin Lintas Tenant
Endpoint admin yang memerlukan agregasi data lintas tenant harus:
- Untuk Shared Schema: Melewati RLS secara eksplisit menggunakan peran database khusus (misal:
bypassrls atau role admin terdedikasi).
- Untuk Isolated Schema: Melakukan query ke berbagai skema atau skema agregasi, yang mana menambah kompleksitas.
8. Pendekatan Multi-Page Application (MPA) & Multiple Entry Points
Saat mengatur proyek SaaS multi-tenant sebagai Multi-Page Application di dalam satu repositori, ikuti panduan berikut:
- Routing & Multiple Entry Points: Gunakan skill
multiple-entry-points untuk memisahkan lalu lintas secara logis. Misalnya, gunakan entry point tenant.php yang secara ketat mewajibkan resolusi tenant_id yang valid sebelum memuat aplikasi, sementara entry point landing.php melayani lalu lintas publik anonim dengan aman.
- Layout & Parsial Bersama (Shared Layouts & Partials): Hindari duplikasi HTML (header, footer, navigasi). Buat direktori
src/Views/layouts/ untuk template dasar dan direktori src/Views/partials/ untuk komponen UI yang dapat digunakan kembali. Controller harus menyuntikkan konten spesifik halaman ke dalam layout dasar.
- Manajemen Aset: Simpan semua aset statis di direktori
public/. Gunakan teknik cache-busting saat menautkan aset di dalam view.
- Manajemen State: Gunakan session sisi server secara aman untuk autentikasi pengguna, konteks tenant, pesan flash, dan melacak state di seluruh proses reload halaman secara penuh.
Praktik Terbaik & Hal yang Harus Dihindari
- Jangan pernah melakukan query pada tabel bertingkat tenant tanpa filter
tenant_id atau tanpa RLS yang aktif.
- Jangan pernah menggunakan ID integer berurutan (auto-increment) untuk resource bertingkat tenant. Gunakan UUID untuk mencegah penjelajahan ID oleh penyerang.
- Mitigasi Connection Pooling: Saat menggunakan connection pool (seperti PgBouncer), variabel sesi yang diatur oleh
SET LOCAL hanya berlaku selama transaksi berlangsung. Jika Anda menggunakan variabel sesi, pastikan Anda meresetnya (RESET ALL atau SET app.current_tenant_id = '') sebelum mengembalikan koneksi ke pool, atau jalankan perintah secara ketat di dalam blok transaksi BEGIN...COMMIT.
- Risiko RLS Bypass: Pastikan fungsi migrasi database dan triggers yang dijalankan dengan
SECURITY DEFINER hanya digunakan saat benar-benar diperlukan, dan atur search_path secara aman untuk mencegah pembajakan search path.
- Uji dengan minimal 3 tenant dalam database pengembangan (seed data) untuk mendeteksi bug kebocoran data lintas tenant.