---
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](#english) | [Bahasa Indonesia](#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.