Multi-Tenancy ist der Standard für SaaS-Apps: eine Codebase und eine Datenbank-Instanz dienen viele Kunden („Tenants"). Der zentrale Sicherheits-Anspruch: kein Tenant darf Daten eines anderen Tenants sehen oder modifizieren. Bei jeder Query muss der Tenant-Kontext berücksichtigt werden. Ein einziger vergessener Filter führt zu Cross-Tenant-Leaks. Dieser Artikel zeigt die Isolations-Strategien — von App-Layer-Filterung bis Postgres-RLS — und die Test-Patterns gegen Cross-Tenant-Bugs.
Isolations-Strategien
Es gibt drei dominante Ansätze:
| Modell | Isolation | Komplexität | Skalierung |
|---|---|---|---|
| Shared DB, Shared Schema | Tenant-Spalte in jeder Tabelle | Niedrig | Sehr gut |
| Shared DB, Schema pro Tenant | Postgres-Schema, MySQL-Datenbank pro Tenant | Mittel | Gut bis ~tausend Tenants |
| Datenbank pro Tenant | Kompletter DB-Instance pro Tenant | Hoch | Begrenzt |
Shared DB, Shared Schema ist die häufigste Wahl — alle Tenants teilen Tabellen, jede Zeile hat eine tenant_id-Spalte. Pro Query: WHERE-Filter auf Tenant.
Schema pro Tenant ist Mittelweg — physische Trennung via Postgres-Schema oder MySQL-Datenbank, gemeinsame Engine. Vorteil: pro-Tenant-Backups, klare Datentrennung. Nachteil: Schema-Migrationen über N Schemas.
DB pro Tenant ist maximale Isolation — eigene Postgres-Instance, eigene Backups. Für Enterprise-Compliance (HIPAA, Banking) manchmal Pflicht. Skaliert schlecht über ~100 Tenants.
Empfehlung für die meisten SaaS: Shared DB, Shared Schema mit Postgres RLS (siehe weiter unten).
Tenant-Scoping auf App-Layer
Naive Implementierung: Tenant-Filter manuell in jeder Query.
app.get('/api/projects', requireAuth, async (req, res) => {
const projects = await db.projects.find({
tenantId: req.user.tenantId, // manuell überall
});
res.json(projects);
});
app.get('/api/projects/:id', requireAuth, async (req, res) => {
const project = await db.projects.findOne({
id: req.params.id,
tenantId: req.user.tenantId, // wieder manuell
});
// ...
});Problem: wenn auch nur eine Query den Filter vergisst → Cross-Tenant-Leak. Skaliert schlecht.
Besser — Tenant-aware Repository / Proxy:
// Repository-Wrapper, der tenantId automatisch hinzufügt
class TenantScopedRepo {
constructor(model, tenantId) {
this.model = model;
this.tenantId = tenantId;
}
find(query = {}) {
return this.model.find({ ...query, tenantId: this.tenantId });
}
findOne(query = {}) {
return this.model.findOne({ ...query, tenantId: this.tenantId });
}
create(data) {
return this.model.create({ ...data, tenantId: this.tenantId });
}
update(query, data) {
return this.model.update(
{ ...query, tenantId: this.tenantId },
data
);
}
delete(query) {
return this.model.delete({ ...query, tenantId: this.tenantId });
}
}
// Middleware: setzt req.db auf tenant-scoped
app.use((req, res, next) => {
if (!req.user) return next();
req.db = {
projects: new TenantScopedRepo(db.projects, req.user.tenantId),
tasks: new TenantScopedRepo(db.tasks, req.user.tenantId),
// ...
};
next();
});
// Endpoint kann nicht „vergessen", den Filter zu setzen
app.get('/api/projects/:id', async (req, res) => {
const project = await req.db.projects.findOne({ id: req.params.id });
if (!project) return res.status(404).end();
res.json(project);
});Strukturell besser: Der Endpoint-Code kann gar nicht mehr ungefiltert auf andere Tenants zugreifen — req.db.projects filtert immer.
Aber: auch hier kann der Raw-DB-Zugriff (db.projects direkt statt req.db.projects) übersehen werden. Code-Review-Pflicht: niemals db.X direkt nutzen im Endpoint-Code, sondern immer req.db.X.
Postgres Row-Level Security (RLS)
Die strukturell sicherste Isolation für Shared-Schema-Setups: RLS auf DB-Ebene. Postgres erzwingt den Tenant-Filter — selbst wenn der App-Code ihn vergisst.
Setup:
-- Tabelle mit tenant_id
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name TEXT,
-- ...
);
-- RLS aktivieren
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policy: Zeile nur sichtbar, wenn tenant_id zum Session-Setting passt
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Force RLS auch für Tabellen-Owner (sonst umgeht der Admin-User RLS)
ALTER TABLE projects FORCE ROW LEVEL SECURITY;App-Code setzt Tenant pro Connection:
async function withTenant(tenantId, callback) {
const client = await pool.connect();
try {
await client.query(`SET app.current_tenant = '${tenantId}'`);
return await callback(client);
} finally {
await client.query('RESET app.current_tenant');
client.release();
}
}
// Endpoint
app.get('/api/projects', requireAuth, async (req, res) => {
const projects = await withTenant(req.user.tenantId, client =>
client.query('SELECT * FROM projects')
);
// Postgres filtert automatisch — kein WHERE nötig
res.json(projects.rows);
});Vorteile:
- Strukturelle Garantie — Postgres erzwingt es, App kann es nicht umgehen (außer mit Super-User-Connection).
- Funktioniert für alle Tabellen mit einer einzigen Policy.
- Audit-Log-freundlich — Tenant-Kontext in jeder Query.
Nachteile:
- Per-Connection-Setup — bei Connection-Pooling muss vor jeder Query gesetzt werden.
- Performance-Overhead durch Policy-Eval (gering bei einfachen Policies).
- Migration ist Arbeit — alle Tabellen einzeln umstellen.
Konsens: Für neue Postgres-basierte SaaS-Apps ist RLS State-of-the-Art. Existing-Apps lohnen sich oft die Migration.
Schema pro Tenant
Bei diesem Modell hat jeder Tenant sein eigenes Postgres-Schema (tenant_acme.projects, tenant_globex.projects).
Vorteile:
- Strukturelle Trennung — kein versehentliches Cross-Tenant via gemeinsamer Tabelle.
- Pro-Tenant-Backups / Restore einfach.
- Compliance-Argumentation klarer.
Nachteile:
- Schema-Migrations über alle Tenant-Schemas — Pattern wie Flyway/Liquibase brauchen Multi-Schema-Support.
- Connection-Pool muss richtiges Schema vor jeder Query setzen.
- Skaliert begrenzt — Postgres handhabt ~1000 Schemas okay, darüber wird's eng.
-- Pro Neuer Tenant
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);
CREATE TABLE tenant_acme.tasks (...);async function withTenantSchema(tenantSlug, callback) {
const client = await pool.connect();
try {
await client.query(`SET search_path = tenant_${tenantSlug}, public`);
return await callback(client);
} finally {
await client.query('RESET search_path');
client.release();
}
}Tenant-Erkennung — woher kommt die Tenant-ID?
Optionen:
- Aus der Session (nach Login) — User-Record hat Tenant-ID, wird in Session geschrieben.
- Aus der URL (Subdomain, Path) —
acme.app.example.comoderapp.example.com/t/acme. - Aus dem JWT-Claim —
tenant-Claim im Token. - Aus dem Custom-Header —
X-Tenant: acme(selten, eher für interne APIs).
Sicherheits-Punkte:
- Tenant aus URL muss mit Tenant aus Session abgeglichen werden — sonst kann User-A mit Login von Tenant-A die URL für Tenant-B aufrufen.
- Tenant aus User-Eingabe niemals trusten — immer aus authentifizierten Quellen.
- Multi-Tenant-User (z. B. Consultant arbeitet für mehrere Tenants) — explizite Tenant-Wahl beim Login, Tenant-Switch-Endpoint mit Re-Auth-Check.
// Subdomain → Tenant
function getTenantFromHostname(hostname) {
const match = hostname.match(/^([a-z0-9-]+)\.app\.example\.com$/);
return match ? match[1] : null;
}
// Middleware
app.use(async (req, res, next) => {
const urlTenant = getTenantFromHostname(req.hostname);
const sessionTenant = req.user?.tenantSlug;
// Beide müssen übereinstimmen
if (urlTenant && sessionTenant && urlTenant !== sessionTenant) {
return res.status(403).send('Tenant mismatch');
}
req.tenantId = sessionTenant;
next();
});Cache und Tenant-Isolation
Caches sind häufige Quelle für Cross-Tenant-Leaks. Wenn der Cache-Key keinen Tenant enthält, kann Tenant-A Daten aus Tenant-B-Cache bekommen.
Anti-Pattern:
// Schadhaft
async function getProject(id) {
const cached = await redis.get(`project:${id}`);
if (cached) return JSON.parse(cached);
const project = await db.projects.findOne({ id });
await redis.set(`project:${id}`, JSON.stringify(project), { EX: 60 });
return project;
}Wenn zwei Tenants beide eine Project-ID xyz haben (separater Wertebereich pro Tenant), liefert der Cache fälschlich Daten aus dem falschen Tenant.
Schutz: Tenant in Cache-Key:
async function getProject(tenantId, id) {
const key = `tenant:${tenantId}:project:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const project = await db.projects.findOne({ tenantId, id });
await redis.set(key, JSON.stringify(project), { EX: 60 });
return project;
}Gleiches gilt für:
- HTTP-Cache-Layer (CDN, Reverse-Proxy) —
Vary: X-Tenant-Header oder Tenant in URL. - Memoization in Code — Memoization-Key muss Tenant enthalten.
- Search-Index (Elasticsearch, MeiliSearch) — separate Indizes pro Tenant oder Tenant-Filter Pflicht.
Cross-Tenant-Read-Tests
Die wichtigste Test-Klasse für Multi-Tenant-Apps: pro Endpoint einen Test, der mit User aus Tenant-A versucht, Resource aus Tenant-B zu lesen.
describe('Cross-Tenant Isolation', () => {
let tenantA, tenantB, userA, userB, projectA;
beforeEach(async () => {
tenantA = await createTenant('acme');
tenantB = await createTenant('globex');
userA = await createUser(tenantA);
userB = await createUser(tenantB);
projectA = await createProject(tenantA, { name: 'A Project' });
});
test('User in Tenant B cannot read Project in Tenant A', async () => {
const session = await login(userB);
const response = await api.get(`/api/projects/${projectA.id}`, session);
expect(response.status).toBe(404);
});
test('User in Tenant B cannot modify Project in Tenant A', async () => {
const session = await login(userB);
const response = await api.patch(
`/api/projects/${projectA.id}`,
{ name: 'Hijacked' },
session
);
expect(response.status).toBe(404);
// Verify nothing changed
const project = await db.projects.findOne({ id: projectA.id });
expect(project.name).toBe('A Project');
});
test('Bulk-list does not include other tenants projects', async () => {
const session = await login(userB);
const response = await api.get('/api/projects', session);
expect(response.body.find(p => p.id === projectA.id)).toBeUndefined();
});
});Skalierung: pro Endpoint mit Resource-IDs ein Cross-Tenant-Test. Bei vielen Endpoints: Test-Generator über die OpenAPI-Spec.
Beobachtbarkeit: Tenant in Logs, Metrics, Traces
Damit Cross-Tenant-Issues nach dem Deploy schnell sichtbar werden, sollten alle Beobachtungs-Tools den Tenant-Kontext kennen:
- Logs:
tenant_idin jedem Log-Eintrag. - Metrics:
tenant-Label in Prometheus/Datadog-Metriken (Vorsicht vor Kardinalitäts-Explosion bei vielen Tenants). - Traces (OpenTelemetry):
tenant.idals Trace-Attribute. - Error-Reports (Sentry, Rollbar):
tenantals Tag.
Pattern für strukturiertes Logging:
// Express-Middleware: Request-Context mit Tenant
const { AsyncLocalStorage } = require('async_hooks');
const requestContext = new AsyncLocalStorage();
app.use((req, res, next) => {
requestContext.run({ tenantId: req.user?.tenantId, userId: req.user?.id }, next);
});
// Logger mit automatischem Context
function log(level, message, extra = {}) {
const ctx = requestContext.getStore() || {};
console.log(JSON.stringify({ level, message, ...ctx, ...extra, ts: Date.now() }));
}Bei jedem späteren Incident-Investigation oder Customer-Support-Ticket weiß man sofort, welcher Tenant betroffen ist.
Besonderheiten
Postgres RLS mit Connection-Pooling
RLS setzt einen Session-State pro Connection (app.current_tenant). Bei Connection-Pooling muss vor jeder Query der Tenant gesetzt werden — sonst kann eine recycelte Connection mit altem Tenant-State Daten leaken. Patterns: SET LOCAL (transaktions-scoped), Pre-Query-Hook, oder dedizierter Connection-Pool pro Tenant (bei wenigen Tenants).
Migration-Skripte sind eine Backdoor zur Cross-Tenant
Wenn Migration-Skripte mit Admin-Permissions laufen, umgehen sie RLS. Pattern: in Migrationen explizit auf Konsistenz prüfen (z. B. „kein Project ohne Tenant"), nicht blind Daten zwischen Schemas/Tabellen kopieren. Migration-Scripts wie produktiver Code reviewen.
Backups als Cross-Tenant-Risiko
Wenn der DBA einen Backup-Restore für Tenant A macht, aber Restore-Plan überschreibt Daten anderer Tenants — Datenverlust und Cross-Tenant. Bei Shared-DB-Setups: Backup-Restore-Strategie pro Tenant ist nicht trivial; bei Schema-pro-Tenant oder DB-pro-Tenant einfacher.
Tenant-übergreifende Features (Cross-Tenant-Sharing)
„Tenant A teilt Resource X mit Tenant B" — bricht das einfache Isolations-Modell. ReBAC (siehe rbac-und-abac) ist hier die saubere Lösung. Falls mit RBAC + Custom-Code gemacht: extreme Test-Disziplin, dokumentierte Audit-Trails.
Public-Resources als Spezial-Fall
Manche Resources sind absichtlich öffentlich (Landingpage-Inhalte, Public-API-Docs). Tenant-Scoping passt nicht — separate Tabelle/Namespace ohne Tenant-Filter. Wichtig: klar trennen, was öffentlich ist und was Tenant-Scoped — sonst landen Public-Daten in Tenant-Tabellen und brauchen ungeprüfte Bypasses.
Tenant-Kardinalität in Metrics
http_requests_total{tenant="..."} mit 10.000 Tenants explodiert Prometheus-Series-Anzahl. Aggregierte Metriken (top-N pro Tenant, plus „other") sind oft sinnvoller. Vor allem in DataDog/Datadog/New Relic ist Tenant-Kardinalität als Cost-Faktor zu beachten.
"Service-Accounts" als Cross-Tenant-Vector
Background-Worker, die für viele Tenants Aufgaben verarbeiten, brauchen einen Tenant-Kontext-Wechsel pro Job. Wenn der Worker mit Admin-Permissions startet und vergisst, den Kontext pro Job zu setzen — alle Queries operieren ohne Tenant-Filter. Pattern: Job-Definition enthält tenantId, Worker setzt es zwingend vor Job-Body.
Weiterführende Ressourcen
Externe Quellen
- Postgres — Row Security Policies
- AWS — Multi-Tenant Data Isolation with Postgres RLS
- Microsoft — Multi-Tenant Data Isolation Patterns
- OWASP Authorization Cheat Sheet
- OWASP API Top 10 — BOLA