Membangun aplikasi yang melayani banyak organisasi: memodelkan tenancy (shared vs isolated), menerapkan row-level security dan RLS PostgreSQL, serta membangun authorization berbasis peran (RBAC) yang benar di level data.

Sebagian besar produk SaaS akhirnya melayani banyak organisasi dalam satu aplikasi: perusahaan A tidak boleh melihat data perusahaan B. Episode ini membahas multi-tenancy & authorization — model yang memisahkan data antar tenant, dan kontrol akses berbasis peran yang benar di level data.
Kesalahan paling mahal di sini bukan di UI — tombol yang disembunyikan bisa diklik. Kesalahan terjadi di query: ketika satu klien bisa membaca baris milik klien lain karena filter tenant terlewat di satu tempat.
Dua ujung spektrum, dengan kompromi di tengah:
| Model | Database per tenant | Shared DB, filter kolom |
|---|---|---|
| Isolated | 1 DB per organisasi | Privacy maksimal, biaya tinggi, migrasi massal |
| Shared | Semua dalam 1 DB | Hemat biaya, risiko bocor antar tenant |
| Hybrid | Shared untuk kecil, isolated untuk enterprise | Paling umum di SaaS |
Mayoritas produk memulai dengan shared (filter organizationId) lalu menawarkan isolated untuk pelanggan enterprise. Kuncinya: keputusan ini memengaruhi skema dari hari pertama — selalu bawa organizationId sejak awal, sulit ditambahkan belakangan.
model Organization {
id String @id @default(cuid())
name String
notes Note[]
}
model Note {
id String @id @default(cuid())
title String
content String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@index([organizationId])
}Note
Index pada organizationId bukan pilihan — ini bagian dari kinerja dan keamanan. Tanpa index, filter tenant memaksa full scan; dengan itu, setiap query langsung menyempit ke ruang data tenant yang bersangkutan. Tambahkan index di skema sejak awal.
Bahaya multi-tenancy adalah query yang "lupa" filter tenant. Dua pola bertahan:
1. Konteks tenant per request — tentukan organisasi aktif dari session, bukan dari input:
"use server"
export async function getNotes() {
const session = await auth()
const organizationId = session.user.organizationId // dari session, bukan input
return prisma.note.findMany({ where: { organizationId } })
}2. Membawa filter di mana-mana — jangan pernah memakai findMany tanpa filter tenant. Untuk menguatkan, service layer atau helper:
export function noteQuery(organizationId: string) {
return prisma.note.whereTenant(organizationId)
}Aturan emasnya: filter tenant selalu berasal dari session/source tepercaya, tidak pernah dari parameter request. Jika organizationId diambil dari URL body, penyerang tinggal mengubahnya.
Bahkan dengan disiplin, satu query yang terlewat sudah cukup. PostgreSQL RLS menambahkan jaring pengaman di level database: baris yang tidak berhak tidak akan pernah kembali, apa pun querynya.
ALTER TABLE "Note" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Note"
USING (organization_id = current_setting('app.organization_id'));Kemudian setiap koneksi menetapkan tenant-nya sebelum query:
await prisma.$executeRaw`
SET app.organization_id = ${organizationId}
`Jika tenant tidak ditetapkan, current_setting kosong dan query mengembalikan baris apa pun — justru tujuan RLS: default ke tidak ada data.
Important
RLS adalah jaring pengaman, bukan pengganti filter di aplikasi. Di development, RLS yang ketat bisa membuat hidup sulit (query admin ikut terpotong). Kombinasi ideal: filter di aplikasi sebagai praktik sehari-hari, RLS sebagai garansi terakhir. Ingat: dengan Prisma, tetap panggil RLS lewat $queryRaw/$executeRaw untuk set setting session.
Tenancy memisahkan perusahaan, RBAC memisahkan peran dalam perusahaan — admin, editor, viewer. Modelnya sederhana:
enum Role {
OWNER
ADMIN
EDITOR
VIEWER
}
model OrganizationMember {
userId String
organizationId String
role Role
@@id([userId, organizationId])
}Hierarki peran (OWNER > ADMIN > EDITOR > VIEWER) diwujudkan dalam helper:
import { Role } from "@prisma/client"
const ROLE_LEVEL: Record<Role, number> = {
OWNER: 4, ADMIN: 3, EDITOR: 2, VIEWER: 1,
}
export function can(memberRole: Role, required: Role) {
return ROLE_LEVEL[memberRole] >= ROLE_LEVEL[required]
}Dan dipakai di server action — di sisi server, bukan di UI:
"use server"
export async function deleteNote(noteId: string) {
const session = await auth()
if (!can(session.memberRole, Role.EDITOR)) {
throw new Error("Kamu tidak punya izin")
}
await prisma.note.delete({
where: { id: noteId, organizationId: session.organizationId },
})
revalidatePath("/notes")
}Perhatikan dua lapis di query delete: filter organizationId (tenancy) dan pengecekan peran (authorization). Keduanya selalu berpasangan.
Selesaikan dengan urutan berikut:
Organization dan OrganizationMember + kolom organizationId di Note.organizationId.curl -s http://localhost:3000/notes -H "Cookie: session=org-a-session"
# data org B tidak boleh muncul, apa pun yang dimintaInti yang harus dibawa pulang:
organizationId sejak hari pertama.Di episode 20 selanjutnya kita membahas privacy & compliance — GDPR, cookie consent, dan penanganan data pengguna yang benar. Sampai jumpa di episode 20!