· 9 min čtení

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.

DatabaseArchitectureSaaS

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

StrategieIzolaceOperaceNáklady
Database per tenantMaximálníTisíce DB k migracímVysoké
Schema per tenantVysokáStovky schemat, alters bolíStřední
Shared schema + RLSLogická (DB-enforced)Jedna DB, jeden migration setNí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, true znamená "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):

QueryBez RLSS RLS
SELECT WHERE org_id = ? LIMIT 504.2 ms4.4 ms
Aggregate COUNT(*) GROUP BY status28 ms29 ms
Insert single row2.1 ms2.2 ms
Cross-tenant analytics (admin)180 ms195 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
done

Lessons

  • FORCE ROW LEVEL SECURITY vždy. Owner bypass je častější bug než feature.
  • AsyncLocalStorage > prop drilling pro tenant context. Žádný orgId v 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_id v 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 bypassRowLevelSecurity flagy.

Co dál

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é.