Lewati ke konten
Chandler Nguyen
AI11 menit baca

Saya Bangun Multi-Tenancy di Hari 2. Di Hari 67, Saya Bangun Ulang

Saya pikir menambah org_id ke setiap tabel berarti multi-tenancy yang anti peluru. Lalu audit keamanan mengungkapkan agensi menulis ke tabel SME — bukan lewat bug, tapi secara desain.

27 Oktober 2025, 11:47 PM. Saya menjalankan apa yang saya pikir akan jadi audit keamanan rutin di STRAŦUM. Semuanya sudah berfungsi baik selama berminggu-minggu — SME punya datanya, agensi punya datanya, multi-tenancy solid.

Kopi sudah dingin. Script audit sedang memproses log. Lalu saya melihatnya: Agensi menulis ke tabel SME.

Bukan lewat bug. Bukan lewat celah keamanan. Lewat arsitekturnya sendiri.

Dua bulan lalu, saya membuat keputusan untuk membangun arsitektur multi-tenant dari Hari 2. Langkah berani untuk solo founder dengan satu AI agent yang berfungsi. Saya menambahkan `org_id` ke setiap tabel, menulis RLS policies, membangun routing terpisah untuk SME dan Agensi. Itu berfungsi — SME punya campaign-nya, agensi punya klien-nya, data mengalir ke tempat yang tepat.

Atau begitu saya pikir.

Saya duduk di sana mungkin 20 menit hanya menatap schema. Bagaimana saya bisa melewatkan ini? Saya menghabiskan berminggu-minggu membangun arsitektur multi-tenant, menulis 83 RLS policies, testing dengan akun SME dan Agensi. Semuanya berfungsi. Tapi "berfungsi" dan "benar" bukan hal yang sama.

Ini jenis bug yang membuatmu mempertanyakan apakah kamu seharusnya membangun software sama sekali. Karena ini bukan typo. Bukan edge case yang terlewat. Ini naivitas arsitektural.

Saya membuat kesalahan klasik: saya mengasumsikan filtering `org_id` sudah cukup untuk isolasi multi-tenant. Ternyata tidak.

Ini cerita tentang menemukan bahwa isolasi multi-tenant sejati membutuhkan lebih dari hanya menambah `org_id` ke setiap tabel — dan 33 migrasi selama 48 jam yang akhirnya menyelesaikannya.

---

> Catatan: Contoh SQL di postingan ini menggunakan schema dan nama tabel yang digeneralisir (`tenant_b`, `workspace_entities`, `entity_data`) untuk keamanan. Konsepnya tetap sama terlepas dari konvensi penamaan spesifikmu.

---

Masalahnya: Tidak Semua Tenant Itu Sama

Ini yang awalnya saya bangun:

```sql
-- Tabel brand guidelines (dibagi SME dan Agensi)
CREATE TABLE brand_guidelines (
  id UUID PRIMARY KEY,
  org_id UUID REFERENCES organizations(id),
  name TEXT,
  guidelines JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS policy (terlihat aman)
CREATE POLICY brand_guidelines_org_isolation ON brand_guidelines
  FOR ALL TO authenticated
  USING (org_id = get_user_org_id());
```

Ini bekerja sempurna untuk SME. Setiap organisasi punya brand guidelines sendiri. Row-Level Security memastikan mereka tidak bisa melihat data satu sama lain. (Setidaknya, begitu saya pikir. Lebih lanjut nanti.)

Tapi Agensi berbeda.

Agensi tidak hanya punya satu set brand guidelines. Mereka punya satu per klien:

- Brand guidelines Klien A (palet warna cerah, tipografi tebal, messaging fokus inovasi)

- Brand guidelines Klien B (palet warna redup, desain minimal, positioning fokus kualitas)

Agensi yang sama, klien berbeda, brand yang sepenuhnya berbeda.

Solusi naif (yang saya bangun pertama):

```sql
-- Tambah entity_id ke tabel bersama
ALTER TABLE brand_guidelines ADD COLUMN entity_id UUID;

-- Update RLS policy
CREATE POLICY brand_guidelines_isolation ON brand_guidelines
  FOR ALL TO authenticated
  USING (
    organization_id = get_user_org_id() AND
    (entity_id IS NULL OR entity_id = get_user_entity_id())
  );
```

Masalah: Ini menciptakan tabel dengan dua data model berbeda:

```
Baris SME:    organization_id='org-123',  entity_id=NULL,      guidelines={...}
Baris Agensi: organization_id='org-456',  entity_id='entity-a', guidelines={...}
Baris Agensi: organization_id='org-456',  entity_id='entity-b', guidelines={...}
```

Query jadi berantakan dengan penanganan `NULL` yang kompleks, dan setiap fitur butuh logika "if SME, else Agency" di kode aplikasi.

Dan ya, saya menulis semua ini sebelum menyadari itu gejala dari masalah yang lebih dalam. Berminggu-minggu kerja, semuanya menunjuk ke kesimpulan yang sama: saya telah mengarsitektur diri ke sudut.

Setiap fitur butuh logika kustom: "Kalau SME, lakukan ini. Kalau Agensi, lakukan itu."

Lebih buruk, arsitekturnya membuat asumsi yang salah:

- Agensi yang menulis `entity_id=NULL` akan mencemari data SME

- SME tidak bisa punya sub-entity meskipun mereka mau sub-akun

- Schema jadi "keju Swiss" dengan kolom nullable

Ini bukan arsitektur multi-tenant. Ini satu tabel yang mencoba melayani dua data model berbeda.

---

Pengungkapan: Tenant Berbeda Butuh Schema Berbeda

Pada akhir Oktober, saya menyadari kebenaran: SME dan Agensi tidak berbagi data model yang sama.

Data model SME:

```
organization → campaigns → agent_outputs
```

Data model Agensi:

```
organization → workspace_entities → campaigns → agent_outputs
                   ↓
            entity_data (misalnya brand guidelines, persona)
```

Agensi punya seluruh layer (workspace entities) yang tidak dimiliki SME. Mereka juga punya intelligence spesifik entity yang seharusnya tidak ada di dunia SME.

Solusinya: Schema database terpisah.

```sql
-- Tabel SME (schema public)
public.brand_guidelines
public.campaigns
public.outputs

-- Tabel Agensi (schema tenant_b)
tenant_b.workspace_entities
tenant_b.entity_data  -- Termasuk brand guidelines, persona, dll.
tenant_b.campaigns
tenant_b.outputs
```

Sekarang SME dan Agensi punya tabel yang sepenuhnya berbeda. Tidak ada schema bersama. Tidak ada polusi `entity_id` nullable. Tidak ada logika "if SME, else Agency".

---

Kenapa Ini Penting: Business Case untuk Schema Routing

Sebelum masuk ke implementasi teknis, mari bicara kenapa keputusan arsitektural ini penting di luar "kode yang lebih bersih."

Future-Proofing untuk Pertumbuhan (Mungkin)

Schema routing bukan hanya soal menyelesaikan masalah hari ini. Ini soal menjaga pintu tetap terbuka untuk peluang yang bahkan belum bisa saya prediksi.

Saya masih di private alpha dengan 15 pengguna. Saya tidak punya pelanggan enterprise. Saya belum bicara dengan pengacara GDPR. Tapi ini yang bisa dimungkinkan schema routing jika STRAŦUM tumbuh:

Ekspansi Internasional:

- Jika kami ekspansi ke EU: Schema terpisah bisa memungkinkan data residency (data klien EU di schema `eu_agency` di server EU)

- Right to deletion jadi lebih sederhana: Query satu schema, bukan filter melalui tabel campuran

- Audit trail: "Tunjukkan semua data Klien X" = satu query schema

Percakapan Compliance:

- Ketika seseorang akhirnya bertanya "Bagaimana kamu menjamin isolasi data?"

- Dengan filtering `org_id`: "Kami menggunakan Row-Level Security policies" (samar, sulit diverifikasi)

- Dengan schema routing: "Data setiap klien hidup di schema database terpisah" (konkret, bisa diaudit)

- Saya tidak tahu apakah ini penting belum. Tapi bisa penting kalau kami mendapat percakapan itu.

Kebenaran Jujur:

Saya tidak membangun untuk compliance HIPAA atau SOC 2 sekarang. Saya membangun untuk SME dan agensi kecil yang butuh strategi marketing yang lebih baik.

Tapi schema routing berarti kalau seseorang bertanya "Bisakah kamu handle klien healthcare?" atau "Apakah kamu mendukung data residency?" suatu hari, jawabannya "ya, biar saya tunjukkan arsitekturnya" alih-alih "biar saya bangun ulang semuanya dulu."

Kerugiannya (Jujur)

Schema routing bukan semua keuntungan. Ini yang sebenarnya dikorbankan:

Kompleksitas Development:

- Setiap operasi WRITE butuh router function

- Setiap operasi READ butuh security view

- Testing membutuhkan jalur SME dan Agensi

- Dengan Claude Code: 2 hari kerja intens (27-29 Okt 2025) di malam hari

- Tanpa tool AI: Akan butuh berminggu-minggu

Risiko Migrasi:

- 33 migrasi berurutan = 33 peluang untuk typo

- Satu `ALTER TABLE` yang salah = korupsi data production

- Harus menjalankan setiap migrasi 3X di staging sebelum menyentuh prod

- Paranoia-nya nyata

Overhead Performa Query:

- View dengan `UNION ALL` = read sedikit lebih lambat

- Router function = extra function call pada write

- RLS + view = query plan yang lebih kompleks

- (Dalam praktik: Saya belum merasakan perlambatan, tapi saya juga hanya punya 15 pengguna alpha)

Kompleksitas Operasional:

- Migrasi schema sekarang memengaruhi 2+ schema (public + agency)

- Backup database butuh restore yang schema-aware

- Query monitoring perlu memeriksa multiple schema

- Ini akan menggigit saya akhirnya, saya hanya tidak tahu kapan

Kenapa Saya Tetap Membuat Trade-Off Itu

Option value mungkin sangat besar. Atau mungkin tidak penting sama sekali.

Schema routing menjaga pintu tetap terbuka yang bahkan saya tidak yakin ingin saya masuki:

- Partnership white-label: Bisa memberikan partner schema mereka sendiri, rebrand UI

- Peluang reseller: Agensi bisa menjual kembali dengan isolasi data yang terbukti

- Tier harga berbeda: Pelanggan "Premium" bisa dapat schema khusus

- Ekspansi geografis: Schema EU, schema US, schema APAC — codebase yang sama

Inilah masalahnya: saya di private alpha. Saya tidak tahu apakah semua ini akan penting. Mungkin saya tidak akan pernah dapat permintaan white-label. Mungkin ekspansi geografis masih bertahun-tahun. Mungkin seluruh bisnis pivot dan semua ini tidak relevan.

Tapi ini yang saya tahu: dengan schema routing, opsi-opsi ini ada. Dengan filtering `org_id`, sebagian besar akan membutuhkan rewrite lengkap.

Itulah taruhan yang saya buat: Habiskan 2 hari ekstra sekarang (dengan Claude Code) untuk menjaga opsi tetap terbuka nanti.

Apakah taruhan yang tepat? Tanya saya setahun lagi.

---

Arsitektur: Schema Routing

Pola 1: Tabel Spesifik Schema

Beberapa tabel hanya ada untuk satu tipe tenant:

```sql
-- Schema tenant khusus
CREATE SCHEMA tenant_b;

-- Workspace entities (spesifik untuk tipe tenant ini)
CREATE TABLE tenant_b.workspace_entities (
  id UUID PRIMARY KEY,
  organization_id UUID,
  name TEXT,
  metadata JSONB
);

-- Data spesifik entity
CREATE TABLE tenant_b.entity_data (
  id UUID PRIMARY KEY,
  organization_id UUID,
  entity_id UUID REFERENCES tenant_b.workspace_entities(id),
  data_type TEXT,
  content JSONB
);
```

SME tidak pernah menyentuh tabel-tabel ini. Mereka tidak ada di schema `public`.

Pola 2: Database Router Function

Bagaimana kamu menulis ke schema yang benar? Router function.

Ini konsepnya (disederhanakan):

```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER  -- Jalankan dengan privilege yang ditinggikan
AS $$
BEGIN
  -- Langkah 1: Deteksi tipe organisasi
  SELECT type INTO org_type FROM organizations WHERE id = p_org_id;

  -- Langkah 2: Route ke schema yang benar berdasarkan tipe
  IF org_type = 'TENANT_B' THEN
    INSERT INTO tenant_b.entity_data (...) VALUES (...);
  ELSE
    INSERT INTO public.brand_guidelines (...) VALUES (...);
  END IF;

  RETURN result;
END;
$$;
```

Cara kerjanya:

1. Deteksi tipe org: Query tabel organizations untuk menentukan tipe tenant

2. Route ke schema yang benar: Tulis ke schema yang tepat berdasarkan tipe

3. Return result: Sertakan schema mana yang digunakan untuk debugging

Kode aplikasi (sama untuk semua tipe tenant):

```typescript
// Cukup panggil router function - tanpa logika spesifik tenant
const result = await supabase.rpc('save_resource_routed', {
  p_org_id: orgId,
  p_entity_id: entityId,  // null untuk tenant sederhana
  p_data: { ... }
});
```

Tidak ada if/else di kode aplikasi. Database yang melakukan routing.

Menulis router function pertama saya butuh 4 jam. Debugging kenapa tidak berfungsi? 6 jam lagi. Masalahnya? Saya lupa memberikan permission EXECUTE. Energi solo founder klasik: kebrilianan arsitektural, kelalaian permission. :P

Pola 3: Security-Invoker View untuk Read

Writing menggunakan router function. Reading menggunakan view.

```sql
-- View terpadu yang menggabungkan kedua schema
CREATE VIEW resources_unified
WITH (security_invoker = on)  -- Menghormati RLS policies
AS
  SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
  FROM public.brand_guidelines
UNION ALL
  SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
  FROM tenant_b.entity_data
  WHERE data_type = 'brand_guidelines';
```

Kode aplikasi (read terpadu):

```typescript
// Baca resource (bekerja untuk semua tipe tenant)
const { data } = await supabase
  .from('resources_unified')
  .select('*')
  .eq('organization_id', orgId);

// RLS policies memfilter dengan benar terlepas dari schema sumber
```

Detail kunci: `WITH (security_invoker = on)` memastikan RLS policies ditegakkan. Tanpa ini, view mem-bypass RLS (bencana keamanan).

---

Migrasi: 33 Migrasi dalam 48 Jam

Menambah schema routing bukan satu migrasi. Ini perjalanan.

Tahu apa yang seru? Menulis 33 migrasi database berturut-turut sambil tahu bahwa kalau satu saja punya typo, kamu akan merusak data production. Sebenarnya, "seru" bukan kata yang tepat. "Mengerikan" lebih akurat. Saya menjalankan setiap migrasi di staging tiga kali sebelum menyentuh production.

27-29 Oktober 2025: 33 migrasi berurutan untuk schema routing lengkap.

Fase migrasi:

1. Buat schema khusus - Setup schema `tenant_b` dengan permission yang proper

2. Buat tabel spesifik schema - Mirror tabel yang diperlukan di schema baru

3. Bangun router function - Satu untuk setiap tipe resource yang butuh routing

4. Buat security view - View terpadu dengan `UNION ALL` untuk read

5. Update RLS policies - Pastikan kedua schema punya isolasi yang proper

6. Migrasi data - Pindahkan data yang ada ke schema yang benar

7. Update aplikasi - Beralih dari query langsung ke router function/view

Total effort: 33 migrasi, 2 hari dengan Claude Code, 100% worth it.

---

Hasilnya: Isolasi Multi-Tenant Sejati

Sebelum (Tabel Bersama dengan org_id)

Data model:

```sql
public.brand_guidelines (organization_id, entity_id, guidelines)
```

Masalah:

- ❌ `entity_id` nullable untuk satu tipe tenant (kebingungan data model)

- ❌ Query kompleks dengan penanganan `NULL`

- ❌ Logika aplikasi: `if (tenantTypeA) { ... } else { ... }`

- ❌ Risiko kontaminasi silang

Sesudah (Schema Routing)

Data model:

```sql
public.brand_guidelines (organization_id, guidelines)    -- Tipe Tenant A
tenant_b.entity_data (organization_id, entity_id, data)  -- Tipe Tenant B
```

Benefit:

- ✅ Data model bersih (tidak ada foreign key nullable)

- ✅ Query sederhana tanpa penanganan `NULL` yang kompleks

- ✅ Tidak ada if/else di aplikasi (database yang menangani routing)

- ✅ Mustahil untuk kontaminasi lintas schema (terpisah secara fisik)

Peningkatan Keamanan

Sebelum: Risiko kontaminasi silang dengan kolom nullable dan tabel bersama

Sesudah: Router function secara otomatis mengarahkan write ke schema yang benar berdasarkan tipe organisasi. Pemisahan schema fisik membuat kontaminasi silang mustahil.

Level isolasi: Pemisahan yang ditegakkan database. Bukan pengecekan level aplikasi.

---

Kapan Menggunakan Schema Routing vs Filtering org_id

Tidak setiap aplikasi multi-tenant butuh schema routing. Gunakan decision tree ini:

Gunakan Filtering `org_id` (Lebih Sederhana) Jika:

Semua tenant punya data model yang sama (misalnya aplikasi todo)

Tidak ada tenancy hierarkis (tidak ada sub-entity dalam organisasi)

Query sederhana (`WHERE org_id = X` bekerja di mana-mana)

B2C atau B2B kecil (tidak ada penjualan enterprise, tidak ada kebutuhan compliance)

Kecepatan MVP penting (sampai ke market dalam minggu, bukan bulan)

Gunakan Schema Routing (Lebih Kompleks) Jika:

Tipe tenant berbeda butuh data model berbeda (Tipe A vs Tipe B vs Tipe C)

Tenancy hierarkis (organizations → workspace_entities → sub-entities)

Penjualan enterprise di roadmap (Fortune 500, healthcare, finance, pemerintah)

Compliance regulasi diperlukan (GDPR, HIPAA, SOC 2, FedRAMP)

Potensi white-label atau reseller (partner butuh isolasi data lengkap)

Ekspansi internasional direncanakan (kebutuhan data residency)

---

Pelajaran yang Dipetik

1. org_id Itu Perlu, Tapi Tidak Cukup

Menambah `org_id` ke setiap tabel memberimu filtering level baris. Tapi kalau tipe tenant berbeda butuh data model berbeda, kamu butuh schema routing.

2. Logika Aplikasi → Logika Database

Setiap `if (tenantType === 'TYPE_B')` di aplikasimu adalah code smell. Pindahkan logika tenant-aware ke database dengan router function.

3. View + RLS = Read Terpadu

Membaca dari multiple schema itu kompleks. View + `security_invoker = on` memberimu read terpadu dengan isolasi yang proper.

4. Security Definer Function Itu Powerful

`SECURITY DEFINER` memungkinkan function berjalan dengan privilege yang ditinggikan sambil tetap menghormati RLS policies. Esensial untuk router function.

5. Migrasi Worth It (Mungkin)

33 migrasi untuk schema routing terasa banyak. Tapi hasilnya? Arsitektur bersih, isolasi sejati, dan nol bug lintas tenant.

6. Kelemahan Arsitektural Lebih Menyakitkan dari Bug Kode

Menemukan null pointer exception jam 11:47 PM? Menjengkelkan. Menemukan bahwa seluruh arsitektur multi-tenantmu secara fundamental rusak? Itu jenis penemuan yang membuatmu terjaga di malam hari.

Tapi ini yang saya pelajari: kesalahan arsitektural itu bisa diperbaiki. Mahal, ya. Memakan waktu, tentu saja. Tapi saya bergerak dari "satu tipe tenant bisa secara tidak sengaja mencemari data yang lain" ke "isolasi yang ditegakkan database yang mustahil di-bypass."

Perbaiki lebih awal, perbaiki dengan benar, dan kamu akan tidur lebih nyenyak.

7. Arsitektur Mungkin Adalah Strategi (Atau Mungkin Hanya Over-Engineering)

Keputusan schema routing bukan hanya soal "kode bersih." Ini soal menjaga opsi masa depan tetap terbuka — partnership white-label, penjualan enterprise, ekspansi internasional.

Tapi ini kebenaran jujurnya: saya di private alpha dengan 15 pengguna. Saya tidak punya pelanggan enterprise yang mengetuk. Saya belum mendapat satu pun pertanyaan GDPR. Partnership yang saya bayangkan mungkin tidak pernah terwujud.

Saya pikir saya membuat keputusan teknis. Mungkin saya membuat keputusan strategi bisnis. Atau mungkin saya hanya over-engineering karena saya menganggap arsitektur database itu menarik. :)

Pernahkah kamu menemukan kelemahan arsitektural yang bukan bug tapi kesalahan desain? Bagaimana kamu menangani rebuild-nya — apakah kamu memperbaikinya secara incremental atau mencabut semuanya seperti yang saya lakukan?

Salam,

Chandler

Seri arsitektur STRAŦUM: Ini bagian 2 dari perjalanan multi-tenancy. Dimulai dengan membangun multi-tenancy di Hari 2. Setelah rebuild schema, saya menemukan 31 blank screen dari konteks navigasi yang hilang dan bahwa database saya benar tapi 296x terlalu lambat.

---

Membangun SaaS multi-tenant dengan kebutuhan isolasi kompleks? STRAŦUM menggunakan schema routing untuk melayani tipe tenant berbeda dengan isolasi data sejati. Minta akses alpha di https://stratum.chandlernguyen.com/request-invitation

---

Masih belajar bahwa "multi-tenant" punya banyak level isolasi. Masih debugging RLS policies di tengah malam. Masih mempertanyakan keputusan arsitektur Hari 2 saya (tapi berkurang sekarang). Lebih banyak petualangan database di https://www.chandlernguyen.com/ .

---