Multi-tenant Postgres pro SaaS: row-level security s Prisma
Jak SME Financial OS izoluje data mezi firmami přes Postgres RLS + Prisma middleware. SET app.current_org_id per request, ~5 % overhead, demo mode přes read-only policy. SQL + TS kód.
Když jsi SaaS, dřív nebo později řešíš multi-tenancy: jak držet data tisíce klientů v jedné DB tak, aby si tenanti nikdy neviděli navzájem do dat. V SME Financial OS jsem volil cestu shared schema + row-level security na Postgresu. Po půl roce v produkci je to rozhodnutí, kterého nelituju - i když cesta nebyla úplně přímá.
Tady je celý setup, včetně Prisma integrace, demo modu a benchmarků.
Tři cesty multi-tenancy
| Strategie | Izolace | Operace | Náklady |
|---|---|---|---|
| Database per tenant | Maximální | Tisíce DB k migracím | Vysoké |
| Schema per tenant | Vysoká | Stovky schemat, alters bolí | Střední |
| Shared schema + RLS | Logická (DB-enforced) | Jedna DB, jeden migration set | Nízké |
Pro SME Financial OS - stovky firem, ne tisíce, sdílené metriky, sdílená pricing tier - je shared schema + RLS sweet spot. Database per tenant by stálo desetinásobek na infra a operations.
Krok 1: Schema s org_id všude
Každá tenanted tabulka má sloupec org_id jako foreign key na organizations. Žádné výjimky. Když přijde nová tabulka bez org_id, je to bug.
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
number TEXT NOT NULL,
amount_cents BIGINT NOT NULL,
customer_name TEXT,
issued_at DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_invoices_org ON invoices(org_id);ON DELETE CASCADE na org_id znamená: když smažeš organizaci, smažou se všechny její data. Pro GDPR cleanup je to single SQL command.
Krok 2: RLS policies
Tady se zlomí lehkomyslné implementace. Postgres má RLS od verze 9.5, ale policy musí být explicitní - žádné "default deny" bez setupu.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- enforcuje i pro table owner
CREATE POLICY tenant_isolation ON invoices
USING (org_id::text = current_setting('app.current_org_id', true));
CREATE POLICY demo_read_only ON invoices FOR SELECT
USING (org_id::text = 'demo-public-uuid');Co se tu děje:
ENABLE- RLS je aktivníFORCE- i superuser musí dodržet (jinak Prisma client ignoruje)current_setting('app.current_org_id', true)- čte session variable,trueznamená "vrať NULL když není nastavená" místo erroru- Druhá policy umožňuje demo módu kohokoli vidět read-only data za speciálním
org_id
Bez FORCE ti RLS funguje jen pro role, které nemají bypass. Když se Prisma připojuje jako vlastník schema, policy ti tiše ignoruje. Zlatý debug moment.
Krok 3: Prisma middleware nastaví session variable
Prisma sám neví, který tenant je aktivní. Musíš mu to říct přes session variable nastavenou před každým query. Best place: Prisma extension (Prisma 5+) nebo classic middleware.
// db.ts
import { PrismaClient } from '@prisma/client';
import { AsyncLocalStorage } from 'node:async_hooks';
export const orgContext = new AsyncLocalStorage<{ orgId: string }>();
const base = new PrismaClient();
export const prisma = base.$extends({
query: {
async $allOperations({ args, query }) {
const ctx = orgContext.getStore();
if (!ctx?.orgId) {
// explicitní opt-out pro admin/cron tasky
return query(args);
}
// Postgres session level - drží se po celou connection
await base.$executeRawUnsafe(
`SELECT set_config('app.current_org_id', $1, true)`,
ctx.orgId,
);
return query(args);
},
},
});A v request handleru:
// app/api/invoices/route.ts
import { auth } from '@/lib/auth';
import { orgContext, prisma } from '@/db';
export async function GET() {
const session = await auth();
if (!session?.orgId) return new Response('unauthorized', { status: 401 });
return orgContext.run({ orgId: session.orgId }, async () => {
const invoices = await prisma.invoice.findMany();
// policy automatically filters to org_id
return Response.json(invoices);
});
}AsyncLocalStorage přenáší tenant context skrze celý request strom - i přes async boundaries (např. await Promise.all([...])). Žádné prop drilling, žádné req.orgId argument všude.
Demo mode jako policy
Klienti chtěli veřejné demo bez signupu - pre-loaded faktury, dashboard, vše read-only. Mohl jsem postavit fake routy s mock daty. Místo toho: demo je tenant jako každý jiný, jen má dedicated org_id a read-only policy.
const DEMO_ORG_ID = 'demo-public-uuid-static';
export async function getDemoData() {
return orgContext.run({ orgId: DEMO_ORG_ID }, async () => {
return prisma.invoice.findMany({ take: 50 });
});
}Plus SQL policy zakazující INSERT/UPDATE/DELETE pro demo:
CREATE POLICY demo_no_writes ON invoices FOR INSERT
WITH CHECK (org_id::text != 'demo-public-uuid-static');Když demo user trefí "Vytvořit fakturu", check failne s RLS exception. UI to chytá a ukazuje "Demo režim - registruj se pro plnou funkcionalitu". Žádný separate code path.
Admin bypass: opt-out z RLS
Pro admin endpointy (cross-tenant analytics, support tools) RLS překáží. Nepoužívám "superuser bypass" (riziko). Místo toho explicit opt-out v Prisma extension - když nezavoláš orgContext.run, query běží bez session variable a RLS to zablokuje (resp. vrátí 0 rows).
Pro admin přihlášení použiju druhý Postgres role:
CREATE ROLE admin_role;
GRANT admin_role TO app_user;
ALTER POLICY tenant_isolation ON invoices
USING (
org_id::text = current_setting('app.current_org_id', true)
OR pg_has_role(current_user, 'admin_role', 'MEMBER')
);Connection string pro admin endpoint zapne SET ROLE admin_role na začátku transakce. Tím získá bypass pouze v rámci specifického handleru.
Performance: ~5 % overhead
Benchmark na produkční DB (~ 12 firem, 280 000 invoices, AWS RDS db.t3.medium):
| Query | Bez RLS | S RLS |
|---|---|---|
SELECT WHERE org_id = ? LIMIT 50 | 4.2 ms | 4.4 ms |
Aggregate COUNT(*) GROUP BY status | 28 ms | 29 ms |
| Insert single row | 2.1 ms | 2.2 ms |
| Cross-tenant analytics (admin) | 180 ms | 195 ms |
~5 % overhead v 95. percentilu. Pro peace-of-mind, který RLS dává - kompilátor (Postgres) tě nepustí proti policy bez explicitního admin role - je to levný kompromis.
Časté chyby
1. Zapomenutý FORCE. Bez něj policy neplatí pro Prisma migrations connection (která je owner). Demo: na staging vidíš data všech tenantů, na prod ne. Vždy FORCE.
2. set_config s false třetím argumentem. S false to nastaví na úrovni connection (drží se trvale na pooled connection). S true jen pro aktuální transakci. Vždy true pro multi-tenant - jinak další request na recyklované connection vidí cizí data.
3. Forgotten table. Přidáš novou tabulku, neaktivuješ RLS. Test passne, prod massive leak. Řešení: CI lint script, který grepne CREATE TABLE v migrations a checkne, jestli má ENABLE ROW LEVEL SECURITY.
# scripts/check-rls.sh
for f in migrations/*.sql; do
if grep -q "CREATE TABLE" "$f" && ! grep -q "ROW LEVEL SECURITY" "$f"; then
echo "MISSING RLS in $f" >&2
exit 1
fi
doneLessons
FORCE ROW LEVEL SECURITYvždy. Owner bypass je častější bug než feature.- AsyncLocalStorage > prop drilling pro tenant context. Žádný
orgIdv každé funkci. - Demo mode jako tenant ti šetří paralelní code path s mock daty. Žádný drift mezi prod a demo.
- CI lint pro RLS je cheap insurance proti forgotten tables.
- Per-request session variable je správný pattern, NIKDY hard-code
org_idv query - jeden bug a leak je hotový. - ~5 % perf overhead je za peace of mind levné. Customer trust > 5 ms.
- Admin bypass přes role je čistější než superuser nebo
bypassRowLevelSecurityflagy.
Co dál
- SME Financial OS case study → - produkt postavený na téhle architektuře
- WhatsApp Business API → - související backend integrace
- Stripe Checkout vs Subscriptions → - billing layer nad multi-tenant DB
Pokud řešíš multi-tenant SaaS architecture review nebo migraci ze schema-per-tenant na shared+RLS, napiš mi. Migrace existujících dat je největší riziko, ale plánovatelné.