Membangun SaaS multi-tenant aman dengan Claude Code: RLS, auth, billing, jobs, dan logs
Bangun SaaS multi-tenant dengan Claude Code: tenant_id, RLS, auth, limit plan, jobs, logs, dan leak tests.
SaaS multi-tenant berarti satu aplikasi melayani banyak organisasi pelanggan. Tenant adalah batas data pelanggan: perusahaan, workspace, portal klien, atau akun. Claude Code bisa mengubah route, helper database, background job, dan UI dengan cepat. Kecepatan itu berguna hanya kalau batas tenant sudah ditulis jelas sebelum implementasi.
Insiden yang harus dicegah sangat konkret: user dari Tenant A bisa membaca project, invoice, file, audit event, atau prompt AI milik Tenant B. Mencariwhere tenantIddi code review tidak cukup. Desain yang aman membawatenant_idke authentication, session, database, billing, jobs, search, dan logs, lalu menjadikan database sebagai pagar terakhir saat query aplikasi salah.
Artikel ini memakai Claude Code sebagai partner implementasi dan review, bukan jalan pintas untuk melewati security. RLS adalah Row Level Security, yaitu policy PostgreSQL yang menentukan row mana yang terlihat atau boleh ditulis dalam context saat ini. Pakai dokumentasi resmi PostgreSQL Row Security Policies, CREATE POLICY, dancurrent_setting / set_config sebagai rujukan utama.
Untuk fondasi lain, baca juga authentication dengan Claude Code, RBAC implementation, dan database migration. Multi-tenancy menyatukan tiga area itu.
Pilih model isolasi lebih dulu
Ada tiga model isolasi tenant yang umum. Pilih sebelum meminta Claude Code menulis handler atau schema.
| Model | Cocok untuk | Kekuatan | Risiko |
|---|---|---|---|
| Shared database, shared schema | SaaS tahap awal dan menengah dengan banyak pelanggan kecil | Operasional ringan, analytics mudah | Satu filter tenant terlewat bisa bocor |
| Shared database, schema per tenant | Beberapa pelanggan besar butuh pemisahan lebih kuat | Permission dan migration bisa dipisah sebagian | Banyak schema membuat release berat |
| Database per tenant | Industri regulated atau kontrak ketat | Backup, key, dan blast radius bisa dipisah | Cost dan deployment lebih kompleks |
Pertanyaan praktisnya: apakah model ini tetap bisa dioperasikan saat ada 100 atau 1000 pelanggan? Sebagian besar SaaS kecil dan menengah bisa mulai dari shared database dan shared schema, selamatenant_id, RLS, audit log, dan restore per tenant dibuat kuat. Kalau customer enterprise tertentu butuh physical isolation, siapkan jalur untuk memindahkan tenant tertentu ke database terpisah.
Tiga use case membantu memetakan desain. Pertama, B2B CRM: account, contact, opportunity, dan note semuanya milik tenant. Kedua, portal agensi: staf agensi bisa bergabung ke beberapa tenant, tetapi user klien hanya melihat portalnya sendiri. Ketiga, SaaS dengan AI: prompt run, token, uploaded file, dan storage harus dihitung per tenant agar billing dan limit plan benar.
Mulai dengan prompt review desain.
I want to make this SaaS codebase multi-tenant.
Assume a shared PostgreSQL database and shared schema.
Classify tables into tenant-owned tables, global reference tables, and tables that need PostgreSQL RLS.
Dangerous shortcuts are not allowed: no "application WHERE clauses are enough", no full request-body logging, and no background jobs that infer tenant_id later.
Return changed areas, database constraints, test cases, and migration risks.
Propagasi tenant_id dari sumber tepercaya
Jangan percaya headerx-tenant-iddari browser atau parameter URL sebagai kebenaran. Flow aman adalah: resolve kandidat tenant dari host atau path, verifikasi di server bahwa user login adalah member tenant itu, lalu pass hanyatenantIdyang sudah diverifikasi ke service internal.
Auth.js menjelaskan cara memperluas session diExtending the Session. Namun session sebaiknya tetap kecil. Session mengidentifikasi user; membership dan role tenant tetap dicek dari server-side data karena bisa berubah.
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
Detail pentingnya adalah penolakan: kode ini tidak percaya tenant dari client. Internal API dan worker juga harus memakai aturan yang sama. Tenant_id dari luar trust boundary hanyalah klaim; membership, API key yang terikat tenant, atau signed job payload harus membuktikannya.
Jadikan PostgreSQL RLS pagar terakhir
Filter aplikasi tetap berguna, tetapi satu filter yang lupa tidak boleh menjadi breach. Dalam shared schema, setiap table milik tenant perlutenant_id, foreign key, index berbasis tenant, dan policy RLS. Di PostgreSQL, owner table dan role denganBYPASSRLSbisa bypass RLS, jadi role aplikasi tidak boleh menjadi owner table yang dilindungi. Untuk table kritis, gunakanFORCE ROW LEVEL SECURITY.
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Dengan connection pool, context tenant harus transaction-local. Nilai session bisa tertinggal di connection yang dipakai ulang;set_config(..., true)membuatnya hilang saat transaction selesai.
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
Query contoh sengaja tidak memakaiWHERE tenant_id = $1. Jika RLS aktif, PostgreSQL akan memfilter row yang tidak visible. Di production kamu boleh menambah filter aplikasi untuk performance, tetapi itu bukan pengganti RLS.
Samakan boundary auth, billing, dan jobs
Authentication bukan hanya “user sudah login”, tetapi “apa yang boleh dilakukan user ini di tenant ini”. Jika consultant, agensi, atau support bisa berada di banyak tenant, query berdasarkanuser_idsaja berbahaya. BawatenantId, userId, role, danrequestIdmelewati service boundary.
Billing memakai boundary yang sama. Stripe menjelaskanusage-based billing, tetapi aplikasi harus lebih dulu menghitung usage tenant yang benar. Limit plan harus dicek di server sebelum write atau AI run, bukan hanya ditampilkan di UI.
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
Background job sering menjadi titik bocor. Jangan enqueue hanyaprojectIdlalu worker melakukan pencarian global. MasukkantenantIdyang sudah diverifikasi ke payload, lalu worker juga masuk melaluiwithTenant.
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
Log yang cukup, bukan semuanya
Logs bisa berubah menjadi database kedua dengan kontrol lebih lemah. OWASP Logging Cheat Sheet menekankan sanitization dan penghapusan data sensitif. Dalam SaaS multi-tenant, kamu biasanya butuhtenantId, requestId, nama event, actor ID, dan target ID. Jangan log cookie, Authorization, API key, prompt penuh, request body penuh, atau alamat billing.
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
Pisahkan audit logs dari operational logs. Audit harus menjawab siapa melakukan apa, di tenant mana, terhadap target apa, dan kapan. Jangan menyalin seluruh isi record kecuali memang ada kebutuhan produk atau regulasi.
Tulis test yang mencoba membocorkan data
Happy-path test tidak cukup. Test Tenant A memakai ID Tenant B, worker menerima payload yang salah,app.tenant_idhilang, limit plan terlampaui, dan logger menerima field sensitif.
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Beri Claude Code acceptance criteria yang adversarial.
Add multi-tenant leak tests with these cases:
1. A Tenant A session using a Tenant B projectId returns 403 or 404.
2. A DB query without app.tenant_id returns zero rows or fails closed.
3. A background job without tenantId is rejected.
4. A plan-limit breach returns 402 before the write.
5. Logs never include authorization, cookie, prompt, token, or apiKey.
Explain the implementation that would fail each test, then show the commands and results after the fix.
Hindari jebakan migration
Saat retrofit SaaS yang sudah ada, kesalahan umum adalah menambahtenant_idnullable, release cepat, lalu berjanji membersihkan nanti. Nullable sementara boleh dipakai saat backfill, tetapi release belum selesai tanpaNOT NULL, foreign keys, unique constraints berbasis tenant, indexes, RLS, dan tests.
Ada lima jebakan. Pertama, mencampur global reference data dan tenant data. Country codes bisa global; customer settings tidak. Kedua, storage key file tidak punya tenant context. Ketiga, search index lupatenant_iduntuk Algolia, Meilisearch, atau OpenSearch. Keempat, mematikan RLS untuk admin panel; support access harus explicit dan audited. Kelima, tidak mengetes restore satu tenant.
Create a migration plan for converting existing tables to multi-tenancy.
Phase 1: add tenant_id columns and create a mapping for existing records.
Phase 2: write backfill SQL and validation SQL for remaining NULL rows.
Phase 3: add NOT NULL, foreign keys, tenant-aware unique constraints, and indexes.
Phase 4: enable RLS and propose where FORCE ROW LEVEL SECURITY is needed.
Phase 5: test APIs, jobs, logs, and search indexes for tenant leaks.
Include rollback criteria and verification SQL for each phase.
Prompt aman untuk Claude Code
Prompt juga harus menyebut batas tenant. Hindari “buat multi-tenant cepat” atau “admin boleh lihat semua”. Prompt aman mencantumkan scope, larangan, test, dan laporan.
You are implementing safe multi-tenant SaaS behavior.
Allowed areas: src/app, src/lib, db/migrations, and tests.
Requirements:
- Resolve tenant_id on the server; never trust client-provided tenant IDs.
- Use PostgreSQL RLS for every tenant-owned table.
- In pooled connections, call set_config('app.tenant_id', value, true) inside a transaction.
- Propagate tenantId through auth, billing, jobs, logs, and search indexing.
- Add tests that intentionally attempt cross-tenant reads and writes.
Forbidden:
- Disabling RLS.
- Trusting x-tenant-id.
- Logging Authorization, Cookie, API keys, or full prompts.
- Marking the task done without tests.
Final report must include changed files, commands run, failed cases found, and residual risk.
DokumentasiClaude Code Security dari Anthropic membahas permissions, prompt injection, dan tanggung jawab review. Untuk repo SaaS sensitif, command yang diizinkan, koneksi MCP, dan project rules sebaiknya bisa direview di version control.
CTA dan handoff praktis
Multi-tenancy bukan feature paling mencolok, tetapi menjual trust di B2B SaaS. Jika kamu bisa menjelaskan isolasi data tenant, audit logs, plan limits, support access, dan restore per tenant, security review akan lebih mudah.
Untuk tim yang memakai Claude Code, masukkan aturan tenant keCLAUDE.mddan paksa dengan tests. Di setiap review tanyakan: apakahtenant_idterpropagasi, apakah RLS memblokir kesalahan, apakah jobs scoped, dan apakah logs aman? Tim bisa mulai dariClaude Code training dan konsultasi. Developer individu bisa memakaicheatsheet gratis untuk mengubah checklist ini menjadi prompt berulang.
Hasil saat dicoba
Saat Masa mencoba pola ini pada CRM kecil, leak pertama bukan di API route, tetapi di worker daily digest. UI sudah mengirimtenantId, tetapi queue payload hanya berisiprojectId, sehingga worker bisa mencari di tenant yang salah. Setelah worker juga dipaksa lewatwithTenantdan SQL test untuk missingapp.tenant_idditambahkan, bug bisa direproduksi dan diperbaiki. Instruksi Claude Code yang paling efektif bukan “tambahkan happy-path tests”, tetapi “tulis dulu test saat Tenant A mencoba membaca Tenant B”.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.