Multi-Tenant-Systeme designen, die bei Skalierung nicht brechen
Wie du Multi-Tenant-Architekturen mit echter Isolation designst. Drei Enforcement-Schichten, hierarchisches Scoping, RBAC-Muster und Erfahrungen aus drei verschiedenen Multi-Tenant-Systemen.
Multi-Tenancy ist keine Datenbankentscheidung
Die meisten Artikel über Multi-Tenancy starten mit der Datenbankfrage: gemeinsame Datenbank, gemeinsames Schema oder dedizierte Datenbank pro Tenant? Das ist der falsche Ausgangspunkt. Das Datenbankmodell ist ein Implementierungsdetail. Die Architekturfrage lautet: Wie erzwingst du Isolation auf jeder Schicht des Systems, damit Tenant A niemals Daten von Tenant B sehen, verändern oder beeinflussen kann?
Wir haben drei verschiedene Multi-Tenant-Systeme gebaut. Jedes hat Isolation anders gelöst, weil jedes andere Anforderungen hatte. Eines nutzt hierarchisches Scoping mit fünf Identitätsebenen. Ein anderes nutzt flaches RBAC mit vier Rollen. Ein drittes nutzt organisationsbasiertes Scoping mit Channel-Isolation. Das Datenbankmodell war bei allen dreien die uninteressanteste Entscheidung.
Dieser Artikel behandelt die architektonischen Muster, die Multi-Tenancy im Betrieb sicher machen. Für den breiteren Kontext, wie wir Systemarchitektur angehen, beschreibt dieser Guide unsere Methodik. Für konkrete Beispiele von Multi-Tenant-KI-Systemen, schau dir unsere Guides zu Agentic Commerce und AI Governance an.
Das Drei-Schichten-Enforcement-Modell
Tenant-Isolation muss auf drei Schichten durchgesetzt werden. Fehlt eine Schicht, gibt es Datenlecks.
┌─────────────────────────────────────────────────┐
│ Schicht 1: API MIDDLEWARE │
│ Jeder Request authentifiziert und gescopt │
│ tenant_id aus JWT/API-Key extrahiert │
│ In den Request-Kontext injiziert │
│ │
├─────────────────────────────────────────────────┤
│ Schicht 2: QUERY FILTER │
│ Jede Datenbankabfrage enthält tenant_id │
│ Jede Suchabfrage nach Tenant gefiltert │
│ Keine Query läuft ohne Tenant-Kontext │
│ │
├─────────────────────────────────────────────────┤
│ Schicht 3: POLICY ENFORCEMENT │
│ Tool-Aufrufe gegen Tenant-Policies geprüft │
│ Agent Memory pro Tenant + Session gescopt │
│ Output nach Tenant-Sichtbarkeitsregeln gefiltert│
│ │
└─────────────────────────────────────────────────┘
Schicht 1: API Middleware
Jeder eingehende Request muss authentifiziert und einem Tenant zugeordnet sein, bevor er irgendeine Business-Logik erreicht.
// Middleware extrahiert Tenant-Kontext aus jedem Request
async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token provided' });
const decoded = await verifyJwt(token);
const tenant = await tenantStore.getById(decoded.tenant_id);
if (!tenant || tenant.status !== 'active') {
return res.status(403).json({ error: 'Tenant not found or suspended' });
}
// Tenant-Kontext in den Request injizieren
req.tenantContext = {
tenantId: tenant.id,
channelId: decoded.channel_id,
role: decoded.role,
permissions: decoded.permissions,
};
next();
}
Der Tenant-Kontext ist nicht optional. Jeder Route-Handler, jede Service-Methode, jede Datenbankabfrage bekommt ihn. Wenn eine Funktion keinen Tenant-Kontext hat, kann sie nicht auf tenant-bezogene Daten zugreifen.
Schicht 2: Query Filter
Jede Datenbankabfrage muss den Tenant-Scope enthalten. Das wird nicht durch Konvention ("denk dran, die WHERE-Klausel hinzuzufügen") erzwungen. Es wird durch die Architektur erzwungen.
// Repository-Basisklasse, die Tenant-Scoping erzwingt
class TenantScopedRepository<T> {
async findMany(tenantId: string, filters: Partial<T>): Promise<T[]> {
return this.db.query({
TableName: this.tableName,
KeyConditionExpression: 'tenant_id = :tid',
FilterExpression: this.buildFilterExpression(filters),
ExpressionAttributeValues: {
':tid': tenantId,
...this.buildFilterValues(filters),
},
});
}
async findById(tenantId: string, id: string): Promise<T | null> {
const result = await this.db.get({
TableName: this.tableName,
Key: { tenant_id: tenantId, id },
});
return result.Item || null;
}
// Keine Methode existiert, die ohne tenant_id abfragt
// Cross-Tenant-Abfragen sind architektonisch unmöglich
}
Für Suchmaschinen (OpenSearch, MeiliSearch, Elasticsearch) enthält jede Abfrage einen Tenant-Filter:
async function searchProducts(tenantId: string, channelId: string, query: string) {
return opensearch.search({
index: 'products',
body: {
query: {
bool: {
must: [{ match: { searchText: query } }],
filter: [
{ term: { tenant_id: tenantId } },
{ term: { channel_ids: channelId } },
],
},
},
},
});
}
Schicht 3: Policy Enforcement
Über den Datenzugriff hinaus haben Tenants unterschiedliche Berechtigungen, welche Aktionen sie ausführen dürfen. Die Policy-Schicht prüft das, bevor irgendeine Aktion ausgeführt wird.
interface TenantPolicy {
tenant_id: string;
rules: PolicyRule[];
}
interface PolicyRule {
action: string; // "create_order", "export_data", "use_ai_agent"
effect: "allow" | "deny";
conditions?: {
max_value?: number;
allowed_channels?: string[];
require_approval?: boolean;
};
}
// Policy-Prüfung vor jeder Aktion
async function checkPolicy(tenantId: string, action: string, params: any): Promise<boolean> {
const policy = await policyStore.getForTenant(tenantId);
const matchingRules = policy.rules.filter(r => r.action === action);
// Deny-Regeln haben Vorrang
if (matchingRules.some(r => r.effect === 'deny')) return false;
// Keine passende Allow-Regel = abgelehnt (Default-Deny)
const allowRule = matchingRules.find(r => r.effect === 'allow');
if (!allowRule) return false;
// Bedingungen prüfen
if (allowRule.conditions?.max_value && params.value > allowRule.conditions.max_value) {
return false;
}
return true;
}
Hierarchisches vs. flaches Scoping
Flaches Scoping (einfaches SaaS)
Jede Ressource gehört zu genau einem Tenant. Keine Unterebenen.
Tenant A
├── Users (Owner, Admin, Member, Viewer)
├── Products
├── Orders
└── Settings
Tenant B
├── Users
├── Products
├── Orders
└── Settings
Gut geeignet für: SaaS-Produkte, bei denen jeder Kunde ein isolierter Workspace ist. Projektmanagement-Tools, CRM-Systeme, interne Dashboards.
Flaches Scoping braucht vier Rollenstufen:
| Rolle | Berechtigungen |
|---|---|
| Owner | Vollzugriff, Abrechnung, Tenant löschen |
| Admin | Benutzer verwalten, Einstellungen, alle Daten |
| Member | Eigene Daten erstellen und bearbeiten, geteilte Daten ansehen |
| Viewer | Nur-Lesen-Zugriff auf geteilte Daten |
Hierarchisches Scoping (Enterprise-Plattformen)
Ressourcen werden über mehrere Ebenen gescopt. Jede Ebene schränkt die Sichtbarkeit ein.
Tenant (Händlerorganisation)
└── Channel (Storefront, API, Widget)
└── Supplier Binding (welche Lieferanten pro Channel sichtbar)
└── Customer (Endbenutzer innerhalb eines Channels)
└── Session (Browser-/Gerätesitzung)
└── Agent Thread (einzelne KI-Konversation)
Gut geeignet für: Marktplatz-Plattformen, Multi-Brand-Commerce, Enterprise-Systeme, bei denen eine Organisation mehrere Storefronts, Vertriebskanäle oder Tochtermarken hat.
Jede Ebene fügt einen Filter hinzu. Ein Produkt, das in Channel A sichtbar ist, ist nicht zwingend in Channel B sichtbar, selbst innerhalb desselben Tenants. Ein Kunde in Channel A hat keinen Zugriff auf Daten von Channel B. Ein Agent-Thread in einer Session kann Konversationen aus einer anderen Session nicht sehen.
// Hierarchischer Kontext wird durch jede Operation durchgereicht
interface TenantContext {
tenantId: string; // Organisation
channelId: string; // Storefront oder Vertriebskanal
customerId?: string; // Endbenutzer (falls authentifiziert)
sessionId?: string; // Browser-Session
threadId?: string; // KI-Konversations-Thread
}
// Query auf die volle Hierarchie gescopt
async function getVisibleProducts(ctx: TenantContext) {
const channel = await channelStore.get(ctx.tenantId, ctx.channelId);
return productStore.findMany({
tenant_id: ctx.tenantId,
supplier_id: { $in: channel.visibleSupplierIds },
status: 'active',
});
}
Hybrides Scoping
Manche Systeme brauchen flaches Scoping für die meisten Ressourcen, aber hierarchisches Scoping für bestimmte Features. Zum Beispiel könnte eine Vendure-Commerce-Installation flaches Scoping (ein Tenant pro Shop) nutzen, aber Channel-basiertes Scoping für Produktsichtbarkeit und Preisgestaltung.
// Vendures Channel-Scoping
async findByCustomer(ctx: RequestContext, customerId: number) {
return this.connection.getRepository(ctx, CiWishlist).find({
where: {
customerId,
channelId: ctx.channelId, // Channel-Scoping innerhalb des Tenants
},
});
}
Wie wir Channel-Scoping in Vendure umsetzen, beschreiben wir in unserem Vendure-Produktionsarchitektur-Guide.
Auth- und RBAC-Muster
JWT-basiertes Tenant-Scoping
Das JWT-Token trägt die Tenant-Identität. Jeder API-Request enthält es.
// JWT-Payload-Struktur
interface TenantJwtPayload {
sub: string; // Benutzer-ID
tenant_id: string; // welcher Tenant
channel_id?: string; // welcher Channel (falls zutreffend)
role: string; // owner | admin | member | viewer
permissions: string[]; // feingranulare Berechtigungen
iat: number;
exp: number;
}
Die tenant_id im JWT ist der primäre Scoping-Mechanismus. Sie wird beim Login gesetzt und kann ohne erneute Authentifizierung nicht geändert werden. Das Backend extrahiert sie aus jedem Request und nutzt sie, um den gesamten Datenzugriff zu scopen.
API-Key-Authentifizierung
Für Machine-to-Machine-Kommunikation (ERP-Integrationen, externe Services, Webhooks) werden API-Keys auf Tenants gemappt:
async function apiKeyMiddleware(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return next(); // Weiter zur JWT-Authentifizierung
const keyRecord = await apiKeyStore.findByKey(apiKey);
if (!keyRecord || keyRecord.status !== 'active') {
return res.status(401).json({ error: 'Invalid API key' });
}
req.tenantContext = {
tenantId: keyRecord.tenantId,
channelId: keyRecord.channelId,
role: keyRecord.role,
permissions: keyRecord.permissions,
};
next();
}
API-Keys sind tenant-gescopt. Key-Rotation ändert nicht das Tenant-Binding. Rate Limits und Permission Scopes gelten pro Key, nicht pro Tenant.
Berechtigungsgranularität
Rollen definieren breite Zugriffsebenen. Berechtigungen definieren spezifische Fähigkeiten:
const PERMISSIONS = {
// Produktverwaltung
PRODUCT_READ: 'product:read',
PRODUCT_CREATE: 'product:create',
PRODUCT_UPDATE: 'product:update',
PRODUCT_DELETE: 'product:delete',
// Bestellverwaltung
ORDER_READ: 'order:read',
ORDER_CREATE: 'order:create',
ORDER_CANCEL: 'order:cancel',
ORDER_REFUND: 'order:refund',
// KI-Features
AI_AGENT_USE: 'ai:agent:use',
AI_AGENT_CONFIGURE: 'ai:agent:configure',
AI_EXPORT: 'ai:export',
// Administration
USER_MANAGE: 'user:manage',
SETTINGS_MANAGE: 'settings:manage',
BILLING_MANAGE: 'billing:manage',
};
// Rollen-Berechtigungs-Zuordnung
const ROLE_PERMISSIONS = {
owner: Object.values(PERMISSIONS),
admin: Object.values(PERMISSIONS).filter(p => p !== 'billing:manage'),
member: ['product:read', 'product:create', 'product:update', 'order:read', 'order:create', 'ai:agent:use'],
viewer: ['product:read', 'order:read'],
};
Was passiert, wenn Scoping versagt
Der lehrreichste Weg zu verstehen, warum Drei-Schichten-Enforcement wichtig ist, ist zu sehen, was schiefgeht, wenn eine Schicht fehlt.
| Fehlende Schicht | Was passiert | Reales Beispiel |
|---|---|---|
| Keine API Middleware | Jeder Request mit gültigem JWT kann auf Daten jedes Tenants zugreifen, indem er Tenant-IDs errät | Wettbewerber scrapt den Produktkatalog deines Kunden |
| Keine Query Filter | Ein Entwickler vergisst die WHERE-Klausel in einem neuen Endpoint, Cross-Tenant-Daten leaken | Admin-Dashboard zeigt alle Kunden aller Tenants an |
| Kein Policy Enforcement | Ein Tenant mit "Starter"-Plan greift über direkte API-Aufrufe auf "Enterprise"-Features zu | Free-Tier-Tenant exportiert unbegrenzt Daten und umgeht Plan-Limits |
Die gruseligste Variante: Alle drei Schichten funktionieren für Reads, aber nicht für Writes. Tenant A kann Daten von Tenant B nicht sehen, aber ein Bug im Update-Endpoint lässt Tenant A die Produktpreise von Tenant B überschreiben. Wir haben das beim Testen entdeckt. In der Produktion wäre es katastrophal gewesen.
Multi-Tenant-Isolation testen
Das Testen von Multi-Tenancy erfordert spezifische Test-Patterns, die die meisten Test-Suites nicht abdecken.
Der Cross-Tenant-Access-Test
Für jeden Endpoint testen, dass Tenant A nicht auf Daten von Tenant B zugreifen kann:
describe('Tenant isolation', () => {
it('tenant A cannot read tenant B products', async () => {
// Produkt als Tenant B erstellen
const product = await createProduct(tenantB.token, { name: 'Secret Product' });
// Versuchen, es als Tenant A zu lesen
const response = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(response.status).toBe(404); // Nicht 403, nicht 200 mit leeren Daten
});
it('tenant A cannot update tenant B products', async () => {
const product = await createProduct(tenantB.token, { name: 'Original' });
const response = await api.patch(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
body: { name: 'Hacked' },
});
expect(response.status).toBe(404);
// Verifizieren, dass das Produkt nicht geändert wurde
const check = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantB.token}` },
});
expect(check.body.name).toBe('Original');
});
});
Gib 404 zurück (nicht 403) bei Cross-Tenant-Zugriffsversuchen. Ein 403 bestätigt, dass die Ressource existiert, was selbst ein Informationsleck ist.
Der Search-Isolation-Test
Verifiziere, dass Suchergebnisse tenant-gescopt sind:
it('search results are tenant-scoped', async () => {
await createProduct(tenantA.token, { name: 'Widget Alpha' });
await createProduct(tenantB.token, { name: 'Widget Beta' });
await waitForSearchIndex();
const results = await api.get('/search?q=Widget', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(results.body.items).toHaveLength(1);
expect(results.body.items[0].name).toBe('Widget Alpha');
// Widget Beta darf nicht auftauchen
});
Der Bulk-Operation-Test
Verifiziere, dass Massenoperationen (Exporte, Importe, Batch-Updates) Tenant-Grenzen respektieren:
it('export only includes own tenant data', async () => {
const exportResult = await api.post('/export/products', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
const exportedIds = exportResult.body.products.map(p => p.id);
const tenantBProducts = await getAllProducts(tenantB.token);
const tenantBIds = tenantBProducts.map(p => p.id);
// Keine Tenant-B-Produkt-IDs im Export von Tenant A
const overlap = exportedIds.filter(id => tenantBIds.includes(id));
expect(overlap).toHaveLength(0);
});
Wie wir Tests generell angehen, beschreiben wir in unserem Software-Engineering-Guide.
Shared vs. dedizierte Infrastruktur
| Modell | Wann einsetzen | Trade-offs |
|---|---|---|
| Shared Everything (eine DB, ein Schema) | SaaS mit vielen kleinen Tenants | Am günstigsten. Am schwierigsten zu isolieren. Noisy-Neighbor-Risiko. |
| Shared DB, separate Schemas | Mittlere Tenants mit Bedarf an logischer Isolation | Gute Isolation. Mehr Migrations-Komplexität. |
| Dedizierte Datenbanken | Enterprise-Tenants mit Compliance-Anforderungen | Beste Isolation. Am teuersten. Am schwierigsten zu verwalten. |
| Dedizierte Cluster | Regulierte Branchen (Gesundheitswesen, Finanzen) | Vollständige Isolation. Höchste Kosten. Separater Betrieb pro Tenant. |
Für die meisten SaaS-Anwendungen ist Shared Everything mit Application-Level-Enforcement (das Drei-Schichten-Modell oben) die richtige Wahl. Es ist einfacher zu betreiben, günstiger im Betrieb, und wenn die Enforcement-Schichten korrekt sind, genauso sicher.
Dedizierte Infrastruktur wird nötig, wenn Tenants regulatorische Anforderungen haben, die physische Isolation vorschreiben (z.B. Daten müssen in einem bestimmten Land liegen), oder wenn die Arbeitslast eines Tenants so groß ist, dass sie andere beeinträchtigt (das Noisy-Neighbor-Problem).
Häufige Fehler
-
Multi-Tenancy als Datenbankproblem behandeln. Das Datenbankmodell (shared vs. dediziert) ist die unwichtigste Entscheidung. Das Enforcement-Modell (drei Schichten) ist die wichtigste.
-
Isolation per Konvention erzwingen. "Entwickler sollten immer tenant_id in Queries einbauen" ist keine Strategie. Mach es architektonisch unmöglich, ohne Tenant-Kontext abzufragen.
-
403 für Cross-Tenant-Zugriff zurückgeben. Gib 404 zurück. Ein 403 bestätigt, dass die Ressource existiert, was Informationen zwischen Tenants leakt.
-
Keine Cross-Tenant-Access-Tests. Jeder Endpoint braucht einen Test, der verifiziert, dass Tenant A nicht auf Daten von Tenant B zugreifen kann. Für Reads und Writes.
-
Suchindex-Isolation vergessen. Datenbankabfragen sind vielleicht gescopt, aber wenn der Suchindex nicht nach Tenant gefiltert ist, leaken Suchergebnisse über Tenant-Grenzen.
-
Shared Caches ohne Tenant-Key-Prefix. Wenn dein Redis-Cache-Key
product:123ist, wird er über Tenants geteilt. Nutzetenant_abc:product:123. -
Background-Jobs ohne Tenant-Kontext. Ein Scheduled Job, der "alle offenen Bestellungen" ohne Tenant-Scoping verarbeitet, verarbeitet die Bestellungen aller Tenants in einem Batch. Übergib Tenant-Kontext über Job-Payloads.
-
Kein Rate Limiting pro Tenant. Der Massenimport eines Tenants sollte nicht die Performance für alle anderen Tenants verschlechtern. Rate-Limit pro Tenant, nicht nur pro IP.
Kernaussagen
-
Drei-Schichten-Enforcement ist nicht verhandelbar. API Middleware, Query Filter und Policy Enforcement. Alle drei. Jeder Request, jede Query, jede Aktion.
-
Hierarchisches Scoping bewältigt Enterprise-Komplexität. Flaches Scoping funktioniert für einfaches SaaS. Enterprise-Plattformen brauchen Scoping auf Tenant-, Channel-, Customer-, Session- und Thread-Ebene.
-
Mach Cross-Tenant-Zugriff architektonisch unmöglich. Verlass dich nicht darauf, dass Entwickler WHERE-Klauseln einbauen. Repository-Basisklassen, die tenant_id als Pflichtparameter verlangen.
-
Teste Isolation explizit. Jeder Endpoint braucht einen Cross-Tenant-Access-Test. Für Reads, Writes, Suchen, Exporte und Massenoperationen.
-
Gib 404 zurück, nicht 403. Cross-Tenant-Zugriffsversuche sollten so aussehen, als ob die Ressource nicht existiert, nicht als ob der Benutzer keine Berechtigung hat.
-
Shared Infrastructure mit Application-Level-Isolation funktioniert für die meisten Fälle. Dedizierte Infrastruktur ist für regulatorische Anforderungen oder Noisy-Neighbor-Probleme, nicht für Sicherheit.
Wir wenden diese Muster in unseren KI-Services, Custom-Software-Projekten und Commerce-Plattformen an. Wenn du ein Multi-Tenant-System designst, sprich mit unserem Team oder frag ein Angebot an. Du kannst auch unsere Solutions-Seite und unseren Ansatz für Trust und Compliance erkunden, um zu sehen, wie wir Tenant-Isolation-Garantien umsetzen.
Behandelte Themen
Verwandte Guides
Unternehmenshandbuch zu Agentischen KI-Systemen
Technischer Leitfaden zu agentischen KI-Systemen in Unternehmen. Erfahre mehr ueber Architektur, Faehigkeiten und Anwendungen autonomer KI-Agenten.
Guide lesenAgentic Commerce: Wie du KI-Agenten sicher einkaufen lässt
Wie du gesteuerten, KI-initiierten Handel designst. Policy Engines, HITL-Freigabe-Gates, HMAC-Quittungen, Idempotenz, Tenant-Scoping und das vollständige Agentic Checkout Protocol.
Guide lesenDie 9 Stellen, an denen dein KI-System Daten verliert (und wie du jede einzelne abdichtest)
Eine systematische Übersicht aller Stellen, an denen KI-Systeme Daten preisgeben. Prompts, Embeddings, Logs, Tool Calls, Agent Memory, Fehlermeldungen, Cache, Fine-Tuning-Daten und Agent Handoffs.
Guide lesenBereit, produktionsreife KI-Systeme zu bauen?
Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.
Gespräch starten