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:

ModellIsolationKomplexitätSkalierung
Shared DB, Shared SchemaTenant-Spalte in jeder TabelleNiedrigSehr gut
Shared DB, Schema pro TenantPostgres-Schema, MySQL-Datenbank pro TenantMittelGut bis ~tausend Tenants
Datenbank pro TenantKompletter DB-Instance pro TenantHochBegrenzt

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.

JavaScript naive-tenant-filter.js
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:

JavaScript tenant-aware-repository.js
// 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:

SQL postgres-rls-setup.sql
-- 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:

JavaScript rls-tenant-set.js
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.
SQL schema-per-tenant.sql
-- Pro Neuer Tenant
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);
CREATE TABLE tenant_acme.tasks (...);
JavaScript schema-per-tenant-set.js
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:

  1. Aus der Session (nach Login) — User-Record hat Tenant-ID, wird in Session geschrieben.
  2. Aus der URL (Subdomain, Path) — acme.app.example.com oder app.example.com/t/acme.
  3. Aus dem JWT-Claimtenant-Claim im Token.
  4. Aus dem Custom-HeaderX-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.
JavaScript tenant-resolution.js
// 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:

JavaScript cache-without-tenant-antipattern.js
// 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:

JavaScript cache-with-tenant.js
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.

JavaScript cross-tenant-tests.js
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_id in jedem Log-Eintrag.
  • Metrics: tenant-Label in Prometheus/Datadog-Metriken (Vorsicht vor Kardinalitäts-Explosion bei vielen Tenants).
  • Traces (OpenTelemetry): tenant.id als Trace-Attribute.
  • Error-Reports (Sentry, Rollbar): tenant als Tag.

Pattern für strukturiertes Logging:

JavaScript tenant-in-logs.js
// 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

/ Weiter

Zurück zu Autorisierung & Access Control

Zur Übersicht