Membangun aplikasi SaaS multi-tenant bukan sekadar menambahkan kolom tenant_id di setiap tabel. Tantangan utamanya adalah memastikan isolasi data, otorisasi yang konsisten di semua jalur akses, pengelolaan identitas pengguna lintas tenant, serta pengalaman pengguna yang tetap sederhana walau arsitekturnya kompleks. Dengan kombinasi Nuxt 3 di sisi aplikasi dan Supabase sebagai backend berbasis PostgreSQL, kita bisa membangun fondasi multi-tenant yang kuat, asalkan desain tenancy dan kebijakan keamanannya dipikirkan sejak awal.
Artikel ini fokus pada pola implementasi yang realistis untuk produk nyata: bagaimana memilih model isolasi tenant, menyusun struktur tabel, menerapkan Row Level Security (RLS), menyiapkan invitation flow, menggunakan custom claims, memetakan subdomain ke tenant context, membatasi akses asset, serta mencegah kebocoran data antar tenant akibat kesalahan query, cache, atau middleware.
Memilih Model Multi-Tenant: Single Database vs Schema per Tenant
Langkah arsitektural paling penting adalah memilih bagaimana tenant diisolasi pada level database. Secara umum, ada dua pendekatan yang paling relevan di ekosistem PostgreSQL dan Supabase.
1. Single database, shared schema, shared tables
Pada model ini, semua tenant berada dalam database dan tabel yang sama. Setiap record dikaitkan ke tenant melalui kolom seperti tenant_id. Isolasi dilakukan dengan RLS dan kebijakan query yang ketat.
- Kelebihan: sederhana dioperasikan, migrasi schema hanya sekali, cocok untuk mayoritas SaaS B2B tahap awal sampai menengah.
- Kekurangan: risiko kebocoran data lebih tinggi bila kebijakan RLS atau query salah, analisis performa antar tenant perlu perhatian lebih.
- Cocok untuk: banyak tenant kecil hingga menengah, kebutuhan operasional sederhana, tim engineering yang ingin bergerak cepat.
2. Single database, schema per tenant
Setiap tenant memiliki schema PostgreSQL terpisah. Secara teori, ini memberi isolasi yang lebih kuat karena objek database dipisahkan per schema.
- Kelebihan: isolasi lebih tegas, konflik nama objek lebih mudah dihindari, beberapa skenario compliance lebih mudah dijelaskan.
- Kekurangan: provisioning tenant lebih kompleks, migrasi schema menjadi jauh lebih berat, integrasi dengan fitur Supabase seperti REST auto-generated dan RLS tidak selalu sesederhana shared schema.
- Cocok untuk: tenant besar dengan kebutuhan isolasi tinggi, jumlah tenant relatif sedikit, tim DBA/infra yang matang.
Untuk sebagian besar produk SaaS yang dibangun dengan Nuxt 3 dan Supabase, shared tables + tenant_id + RLS adalah pilihan paling pragmatis. Anda mendapat kecepatan pengembangan, migrasi yang lebih mudah, dan integrasi yang natural dengan kemampuan Supabase. Namun, model ini hanya aman jika semua akses data diwajibkan melalui kebijakan RLS yang konsisten.
Desain Data yang Aman untuk Produk Nyata
Kesalahan umum adalah mencampur konsep pengguna global dengan keanggotaan tenant. Dalam sistem multi-tenant, satu pengguna bisa tergabung di lebih dari satu tenant dan memiliki role berbeda di tiap tenant. Karena itu, jangan menaruh role tenant langsung di profil pengguna global.
Struktur tabel inti
Berikut pola tabel yang umum dan aman:
create table public.tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
subdomain text unique not null,
created_at timestamptz not null default now()
);
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
created_at timestamptz not null default now()
);
create table public.tenant_memberships (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member', 'viewer')),
status text not null check (status in ('active', 'invited', 'suspended')),
created_at timestamptz not null default now(),
unique (tenant_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
Poin penting dari desain ini:
auth.userstetap menjadi sumber identitas global.profilesmenyimpan data profil lintas tenant.tenant_membershipsmenjadi sumber kebenaran untuk role dan status akses per tenant.- Setiap tabel bisnis seperti
projects,invoices,tasksharus memilikitenant_id.
Aturan desain yang sebaiknya konsisten
- Jangan pernah mengandalkan filter tenant hanya di frontend.
- Jangan membiarkan endpoint service role dipanggil langsung dari client.
- Gunakan foreign key ke
tenant_iduntuk semua data yang bersifat tenant-scoped. - Tambahkan index gabungan seperti
(tenant_id, created_at)atau(tenant_id, status)untuk query yang umum.
Row Level Security: Fondasi Isolasi Tenant
RLS adalah lapisan pertahanan utama di Supabase. Tanpa RLS yang benar, satu kesalahan query bisa membuka data tenant lain. Dengan RLS, meskipun aplikasi lupa menambahkan filter tenant_id, database tetap bisa menolak akses yang tidak sah.
Mengaktifkan RLS
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
Fungsi helper untuk membership aktif
Daripada menyalin logika yang sama ke banyak policy, buat fungsi SQL yang reusable.
create or replace function public.is_active_member(target_tenant uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.tenant_memberships tm
where tm.tenant_id = target_tenant
and tm.user_id = auth.uid()
and tm.status = 'active'
);
$$;
Policy select dan insert
create policy "members can read projects in their tenant"
on public.projects
for select
using (public.is_active_member(tenant_id));
create policy "members can insert projects in their tenant"
on public.projects
for insert
with check (public.is_active_member(tenant_id));
Mengapa using dan with check penting? using mengontrol baris mana yang boleh dibaca atau dimodifikasi, sedangkan with check memastikan data baru yang dimasukkan tetap berada dalam tenant yang sah. Banyak kebocoran data terjadi karena developer hanya menulis policy select, tetapi lupa mengamankan insert dan update.
Role berbasis tenant
Jika hanya admin tenant yang boleh menghapus project, buat helper kedua:
create or replace function public.has_tenant_role(target_tenant uuid, allowed_roles text[])
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.tenant_memberships tm
where tm.tenant_id = target_tenant
and tm.user_id = auth.uid()
and tm.status = 'active'
and tm.role = any(allowed_roles)
);
$$;
create policy "tenant admins can delete projects"
on public.projects
for delete
using (public.has_tenant_role(tenant_id, array['owner', 'admin']));
Catatan penting: jangan menganggap RLS sebagai pengganti validasi bisnis. RLS mengontrol siapa boleh mengakses baris tertentu, tetapi aturan domain seperti kuota paket, batas jumlah seat, atau status langganan tetap perlu dicek di layer aplikasi atau fungsi database.
Custom Claims dan Tenant Context
Dalam sistem multi-tenant, satu pengguna bisa menjadi admin di tenant A tetapi hanya viewer di tenant B. Karena itu, menyimpan satu role global di JWT sering tidak cukup. Pola yang lebih aman adalah menyimpan identity global di token dan menentukan tenant context aktif di aplikasi, lalu membiarkan RLS memverifikasi membership terhadap tenant yang sedang diakses.
Kapan custom claims berguna?
- Menandai tipe akun global, misalnya
super_admin. - Menyimpan daftar tenant yang kecil dan jarang berubah, walau ini perlu kehati-hatian karena token bisa stale.
- Menyimpan tenant aktif terakhir untuk optimasi UX, bukan sebagai satu-satunya mekanisme otorisasi.
Trade-off utama custom claims adalah sinkronisasi. Jika membership berubah, token lama mungkin masih membawa informasi usang sampai direfresh. Karena itu, jangan hanya bergantung pada custom claims untuk akses data sensitif; tetap gunakan RLS yang membaca state aktual dari database.
Middleware tenant context di Nuxt 3
Di Nuxt 3, tenant context idealnya ditentukan sejak awal request berdasarkan subdomain atau path. Middleware ini bukan pengaman utama, tetapi penting agar seluruh aplikasi konsisten membaca tenant yang sama.
// server/middleware/tenant-context.ts
export default defineEventHandler(async (event) => {
const host = getHeader(event, 'host') || ''
const hostname = host.split(':')[0]
// contoh: acme.app.com -> acme
const parts = hostname.split('.')
const subdomain = parts.length > 2 ? parts[0] : null
event.context.tenant = {
subdomain
}
})
Setelah itu, buat server API yang me-resolve tenant dari subdomain ke tabel tenants, lalu simpan hasilnya di context request. Bila tenant tidak ditemukan, kembalikan 404 lebih awal.
Menghubungkan tenant context dengan query
Pada halaman server-side atau API internal, Anda bisa membaca tenant dari context, mengambil tenant_id, lalu menggunakannya untuk query. Meski begitu, tetap anggap filter tenant_id sebagai optimasi dan kejelasan kode, bukan pengaman utama; pengaman tetap RLS.
Subdomain, Routing, dan Invitation Flow
Strategi subdomain
Model yang umum untuk SaaS B2B adalah tenant.example.com. Keuntungannya, tenant context mudah dikenali dari host dan UX terasa lebih natural. Tantangannya adalah konfigurasi DNS, wildcard TLS, serta pemisahan cookie dan callback URL autentikasi.
- Gunakan wildcard DNS untuk subdomain tenant.
- Pastikan redirect URL autentikasi Supabase mengizinkan domain yang relevan.
- Jika memakai SSR, validasi header
hostagar tidak ada spoofing pada environment tertentu di belakang proxy.
Desain invitation flow yang aman
Invitation flow sebaiknya tidak langsung membuat membership aktif hanya berdasarkan email. Pola yang lebih aman:
- Admin tenant membuat undangan ke email tertentu.
- Sistem menyimpan record undangan dengan token acak yang di-hash di database.
- Penerima membuka link undangan, login atau sign up terlebih dahulu.
- Server memverifikasi token, memastikan email cocok, lalu mengubah membership menjadi
active.
create table public.tenant_invitations (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
email text not null,
role text not null check (role in ('admin', 'member', 'viewer')),
token_hash text not null,
expires_at timestamptz not null,
accepted_at timestamptz,
invited_by uuid not null references auth.users(id),
unique (tenant_id, email)
);
Kenapa token perlu di-hash? Jika tabel bocor atau log internal terekspose, token undangan tidak bisa langsung dipakai. Ini prinsip yang sama seperti penyimpanan password reset token.
Pembatasan Akses Asset di Supabase Storage
Kebocoran data multi-tenant tidak hanya terjadi di tabel SQL. Asset seperti invoice PDF, avatar tim, atau ekspor CSV juga harus diisolasi. Kesalahan umum adalah menyimpan semua file di bucket publik dengan path bertingkat tenant, lalu mengandalkan path yang sulit ditebak. Ini tidak cukup aman.
Pola yang direkomendasikan
- Gunakan bucket private untuk dokumen tenant.
- Simpan metadata file di tabel SQL yang memiliki
tenant_id. - Buat endpoint server untuk menghasilkan signed URL hanya jika user masih anggota tenant yang sah.
Contoh path file:
tenant/{tenant_id}/documents/{file_id}.pdf
Walau path sudah mengandung tenant_id, jangan menganggap itu cukup. Akses file tetap harus melalui verifikasi membership. Dengan pola ini, asset mengikuti model keamanan yang sama seperti data SQL.
Risiko Kebocoran Data Antar Tenant dan Cara Mencegahnya
Pada sistem nyata, kebocoran data sering terjadi bukan karena database gagal, melainkan karena integrasi aplikasi yang longgar. Berikut sumber masalah yang sering muncul:
- Service role dipakai di request user biasa. Kunci service role melewati RLS. Gunakan hanya di server terpercaya dan hanya untuk pekerjaan administratif.
- Cache tidak dipartisi per tenant. Jika response SSR atau cache API tidak memasukkan tenant sebagai bagian dari key, data tenant A bisa tampil ke tenant B.
- Query agregasi lupa filter tenant. Dashboard metrik adalah area rawan. Pastikan fungsi SQL agregasi juga tenant-aware.
- Background job berjalan tanpa tenant context. Worker yang memproses email, ekspor, atau webhook harus menerima
tenant_idsecara eksplisit. - Join lintas tabel tanpa RLS yang lengkap. Semua tabel yang terlibat harus punya policy yang benar, bukan hanya tabel utama.
Tips debugging
- Uji semua endpoint dengan dua user dari tenant berbeda.
- Buat fixture data yang sengaja mirip antar tenant untuk mendeteksi kebocoran visual.
- Periksa query yang berjalan dengan account biasa, bukan hanya account admin lokal.
- Audit semua tempat yang menggunakan service role.
- Tulis tes integrasi untuk skenario negatif: user tenant A tidak boleh membaca, mengubah, atau menebak resource tenant B.
Pola Implementasi Nuxt 3 yang Layak untuk Production
Di sisi Nuxt 3, pisahkan dengan jelas area yang berjalan di browser dan area yang membutuhkan kredensial server. Client cukup memegang sesi user biasa. Semua operasi sensitif seperti accept invitation, generate signed URL, atau administrasi tenant sebaiknya lewat endpoint server internal.
Rekomendasi alur request
- Request masuk ke subdomain tenant.
- Middleware membaca host dan menentukan tenant context.
- Server me-resolve tenant dan menyimpannya di context.
- Komponen atau API server melakukan query Supabase dengan sesi user.
- RLS memvalidasi bahwa user memang anggota tenant tersebut.
Pola ini bekerja baik karena ada beberapa lapisan pertahanan: routing memberi konteks, aplikasi memberi struktur, dan database menegakkan keamanan terakhir.
Kesimpulan
Membangun sistem multi-tenant di Nuxt 3 dengan Supabase paling realistis dilakukan dengan single database + shared tables + tenant_id + RLS, selama Anda disiplin menerapkan isolasi di semua tabel, asset, cache, dan background job. Struktur data sebaiknya memisahkan identitas global pengguna dari membership per tenant, sehingga satu user dapat memiliki role berbeda di beberapa organisasi.
Gunakan subdomain untuk tenant context, tetapi jangan menganggap subdomain sebagai mekanisme keamanan. Keamanan sebenarnya berada pada kombinasi RLS, membership yang tervalidasi, endpoint server yang hati-hati, serta pengelolaan asset privat. Jika sejak awal Anda mendesain untuk mencegah kebocoran data antar tenant, maka fondasi aplikasi SaaS Anda akan jauh lebih siap untuk skala, audit, dan kebutuhan enterprise di masa depan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!