Status: ✅ Accepted — 2026-07-03 by CPTO
CPTO Sign-off: 2026-07-03 — Shared DB + RLS ist der richtige Ansatz für MVP. ADR-001 Tech Stack complements this. Implementation muss RLS-Policies in CI/CD integrieren.
Entscheidung: Multi-Tenant Strategie für Cartly SaaS
Cartly ist eine SaaS-Plattform für Fashion-Einzelhändler (1–5 Filialen). Alle Kunden teilen sich eine Instanz, müssen aber strikt voneinander isoliert sein. Wir brauchen:
Gewählter Ansatz: Shared Database + Row-Level Security (RLS)
Jeder Tenant erhält eine eindeutige tenant_id (UUID v4). Diese ist in ALLEN Tabellen als Foreign Key vorhanden.
-- Zentraler Tenant-Table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- URL-friendly identifier
plan TEXT NOT NULL DEFAULT 'good', -- good | better | best
settings JSONB DEFAULT '{}', -- Tenant-spezifische Settings
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Beispiel: products Table
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
sku TEXT NOT NULL,
name TEXT NOT NULL,
brand_id UUID REFERENCES brands(id),
category TEXT,
sizes TEXT[], -- z.B. ['XS','S','M','L','XL']
colors JSONB, -- z.B. [{name: 'Navy', hex: '#001f3f'}]
price NUMERIC(10,2),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
UNIQUE(tenant_id, sku)
);
-- Index für Performance
CREATE INDEX idx_products_tenant_id ON products(tenant_id);
-- RLS aktivieren
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Policy: Nur eigener Tenant darf lesen
CREATE POLICY tenant_isolation_products_select
ON products FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Policy: Nur eigener Tenant darf schreiben
CREATE POLICY tenant_isolation_products_insert
ON products FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_products_update
ON products FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_products_delete
ON products FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
// Middleware: Tenant aus JWT extrahieren und in Request Context setzen
import { FastifyRequest, FastifyReply } from 'fastify';
async function tenantMiddleware(request: FastifyRequest, reply: FastifyReply) {
const token = request.user; // JWT ist bereits verifiziert
if (!token?.tenantId) {
return reply.status(403).send({ error: 'Tenant ID fehlt' });
}
// Tenant ID im Reply-Kontext setzen (für Prisma)
reply.locals.tenantId = token.tenantId;
// Für Raw SQL: Set Session Variable (PostgreSQL)
await request.server.pgClient.query(
`SET LOCAL app.current_tenant_id = $1`,
[token.tenantId]
);
}
// Prisma Middleware für automatischen Tenant-Filter
prisma.$use(async (params, next) => {
if (params.model && TENANTED_MODELS.includes(params.model)) {
const tenantId = getCurrentTenantId(); // Aus dem Request Context
if (tenantId) {
if (params.action === 'findUnique') {
params.args.where = { ...params.args.where, tenantId };
}
if (params.action === 'findFirst') {
params.args.where = { ...params.args.where, tenantId };
}
if (params.action === 'findMany') {
params.args.where = { ...params.args.where, tenantId };
}
}
}
return next(params);
});
-- PgBouncer configuration (pgbouncer.ini)
[databases]
cartly = host=localhost port=5432 dbname=cartly
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
server_idle_timeout = 600
// Redis Keys immer mit Tenant-Prefix
const getKey = (tenantId: string, suffix: string) =>
`tenant:${tenantId}:${suffix}`;
// Beispiele
`tenant:${tenantId}:session:${sessionId}`
`tenant:${tenantId}:cart:${cartId}`
`tenant:${tenantId}:cache:products:${productId}`
tenant_id mitschleifentenant_id in ALLEN Tabellen als FK + IndexErstellt: 2026-07-03 von Documentation Agent (a66674bf)