Row-Level-Isolation, Schema-per-Tenant und Database-per-Tenant im Vergleich, und wie man Cross-Tenant-Zugriffe strukturell verhindert
Der teuerste Fehler in einer Multi-Tenant-API ist nicht ein fehlendes Feature, sondern eine einzige vergessene WHERE-Klausel, die Daten eines Mandanten an einen anderen ausliefert. Dieser Artikel vergleicht die drei etablierten Isolationsmodelle, Row-Level-Isolation, Schema-per-Tenant und Database-per-Tenant, zeigt, ob die Tenant-ID besser im JWT, im Header oder in der URL steht, und beschreibt, wie sich Cross-Tenant-Zugriffe in einem Symfony-Projekt strukturell verhindern lassen, statt sich auf die Disziplin jedes einzelnen Entwicklers zu verlassen.
Inhaltsverzeichnis
- 1. Warum Mandanten-Isolation über Vertrauen in ein SaaS-Produkt entscheidet
- 2. Row-Level-Isolation, Schema-per-Tenant und Database-per-Tenant im Vergleich
- 3. Tenant-ID im JWT, im Header oder in der URL: Wo gehört sie hin?
- 4. Symfony-Doctrine-Filter für automatische Tenant-Scope-Injection
- 5. Praxisbeispiel: Ein vollständiger Tenant-Filter mit Aktivierung pro Request
- 6. Typische Schwachstellen, die auch ein Doctrine-Filter nicht automatisch schließt
- 7. Tenant-Isolation aktiv testen statt nur zu hoffen
- 8. Entscheidungshilfe: Welches Isolationsmodell für welches Projekt?
- 9. Checkliste für den Entwurf einer neuen mandantenfähigen Endpoint-Gruppe
- 10. Zusammenfassung
- 11. FAQ
1. Warum Mandanten-Isolation über Vertrauen in ein SaaS-Produkt entscheidet
In einer Multi-Tenant-Anwendung teilen sich mehrere Kunden dieselbe Infrastruktur, denselben Anwendungscode und häufig dieselbe Datenbank, obwohl jeder Kunde davon ausgeht, dass seine Daten für niemanden sonst sichtbar sind. Diese Erwartung ist keine Nebensächlichkeit, sondern der Kern des Vertrauens, das ein SaaS-Anbieter überhaupt erst verkauft: Sobald ein einziger dokumentierter Fall bekannt wird, in dem Kunde A Daten von Kunde B gesehen hat, ist der Reputationsschaden oft größer als ein klassischer Datenverlust, weil er grundsätzlich infrage stellt, ob die Architektur überhaupt für den produktiven Einsatz geeignet ist.
Das Tückische an Cross-Tenant-Datenlecks ist, dass sie in aller Regel nicht durch einen gezielten Angriff entstehen, sondern durch ganz gewöhnliche Programmierfehler: eine Query, die aus Zeitdruck ohne Tenant-Filter geschrieben wurde, ein neuer Endpoint, bei dem der Filter schlicht vergessen wurde, oder ein Caching-Layer, der Antworten über Tenant-Grenzen hinweg wiederverwendet. Genau deshalb reicht es nicht, Tenant-Isolation als Konvention zu dokumentieren, die jeder Entwickler bei jeder neuen Query von Hand beachten soll. Sie muss strukturell im Framework verankert sein, sodass ein vergessener Filter gar nicht erst zu einer ausgelieferten Antwort führen kann.
2. Row-Level-Isolation, Schema-per-Tenant und Database-per-Tenant im Vergleich
Bei der Row-Level-Isolation teilen sich alle Mandanten dieselbe Datenbank und dieselben Tabellen, jede Zeile trägt zusätzlich eine tenant_id-Spalte, und jede Query muss diese Spalte in der WHERE-Klausel berücksichtigen. Dieses Modell ist am günstigsten im Betrieb, weil ein einziges Datenbank-Schema für beliebig viele Mandanten reicht, Migrationen nur einmal ausgeführt werden müssen und Backups sich zentral verwalten lassen. Der Nachteil liegt genau in dieser Einfachheit: Die Isolation lebt ausschließlich in der Anwendungslogik, und ein einziger vergessener Filter führt sofort zu einem Datenleck, weil die Datenbank selbst keinerlei Barriere zwischen den Mandanten kennt, sofern man nicht zusätzlich Datenbank-native Mechanismen wie Row-Level-Security einsetzt.
Schema-per-Tenant legt für jeden Mandanten ein eigenes Datenbankschema innerhalb derselben physischen Datenbankinstanz an, was eine stärkere Isolationsgrenze schafft, weil ein SQL-Fehler in der Anwendungslogik im Idealfall gegen ein falsches Schema läuft und dadurch sichtbar fehlschlägt, statt still falsche Daten zu liefern. Der Preis dafür ist operative Komplexität: Migrationen müssen für jedes Schema einzeln ausgeführt werden, was bei hunderten Mandanten spürbar Zeit kostet, und Verbindungspools müssen pro Request dynamisch das richtige Schema auswählen. Database-per-Tenant geht noch einen Schritt weiter und vergibt jedem Mandanten eine komplett eigene Datenbank, teils sogar auf eigener Infrastruktur, was die stärkste Isolation und die einfachste Erfüllung regulatorischer Anforderungen bietet, aber Skalierung, Kosten und Betriebsaufwand deutlich erhöht, sobald man in den Bereich mehrerer tausend Mandanten kommt.
3. Tenant-ID im JWT, im Header oder in der URL: Wo gehört sie hin?
Die Frage, wo die Tenant-ID im Request steht, entscheidet maßgeblich darüber, wie leicht sie sich fälschen lässt. Steht die Tenant-ID als Claim in einem signierten JWT, das der Authentifizierungsserver beim Login ausstellt, kann ein Client sie nicht manipulieren, ohne die Signatur zu brechen, was diesen Weg zur sichersten Variante macht. Die Middleware oder ein Symfony-EventSubscriber liest den Claim direkt aus dem validierten Token und setzt daraus den aktuellen Tenant-Kontext, bevor irgendeine Business-Logik ausgeführt wird, sodass der Tenant-Kontext niemals aus unvalidierten Nutzereingaben stammt.
Eine Tenant-ID direkt in der URL, etwa als /api/tenants/{tenantId}/orders, ist zwar transparent und für Debugging angenehm, verlangt aber eine zusätzliche, konsequent durchgesetzte Prüfung, ob der authentifizierte Nutzer tatsächlich zu genau diesem Mandanten gehört, denn sonst kann ein Angreifer einfach die ID in der URL austauschen und auf fremde Daten zugreifen. Ein Header wie X-Tenant-ID hat dasselbe Problem, sofern er nicht zusätzlich gegen den authentifizierten Nutzer verifiziert wird. In der Praxis hat sich daher ein Kombinationsmodell bewährt: Die Tenant-ID im JWT ist die einzige vertrauenswürdige Quelle, während eine Tenant-ID in der URL, falls vorhanden, nur als zusätzliche, gegen das JWT abgeglichene Prüfung dient, niemals als alleinige Grundlage für die Datenfilterung.
4. Symfony-Doctrine-Filter für automatische Tenant-Scope-Injection
Der wirksamste strukturelle Schutz gegen vergessene WHERE-Klauseln in einem Symfony-Projekt ist ein Doctrine-SQL-Filter, der bei jeder generierten Query automatisch eine Tenant-Bedingung ergänzt, unabhängig davon, ob der Entwickler beim Schreiben der Query überhaupt an Multi-Tenancy gedacht hat. Doctrine bietet dafür die native Filter-API: Eine Filterklasse erweitert SQLFilter, implementiert die Methode addFilterConstraint und wird global aktiviert, sodass jede Query gegen eine mit dem Filter markierte Entity automatisch die zusätzliche SQL-Bedingung erhält, ganz ohne dass der Entwickler in jedem einzelnen Repository manuell einen Tenant-Parameter durchreichen muss.
Der entscheidende Vorteil dieses Ansatzes ist, dass die Isolation nicht mehr von der Disziplin jedes einzelnen Entwicklers abhängt, sondern von der Konfiguration einer einzigen, zentral getesteten Filterklasse. Ein neuer Endpoint, ein neues Repository oder eine neue Query, die ein Entwickler unter Zeitdruck schreibt, ist automatisch geschützt, solange die betroffene Entity korrekt mit dem Filter-Attribut markiert ist. Das folgende Beispiel zeigt eine solche Filterklasse zusammen mit einem EventSubscriber, der den Tenant-Kontext aus dem validierten JWT liest und den Filter für jeden Request mit dem korrekten Parameter aktiviert.
5. Praxisbeispiel: Ein vollständiger Tenant-Filter mit Aktivierung pro Request
In der Praxis besteht die Umsetzung aus drei Teilen: der Filterklasse selbst, die die SQL-Bedingung generiert, einem Attribut oder Interface, mit dem jede mandantenfähige Entity markiert wird, und einem EventSubscriber, der bei jedem Request den aktuellen Tenant aus dem authentifizierten JWT liest und den Filter entsprechend parametrisiert. Wichtig ist, den Filter so fruh wie möglich im Request-Lebenszyklus zu aktivieren, noch vor jedem Controller-Aufruf, damit selbst ein versehentlich in einem Service oder einer Konsolen-Command-Klasse aufgerufenes Repository geschützt ist.
Ebenso wichtig ist eine bewusste Fail-Closed-Strategie: Kann der Tenant-Kontext aus irgendeinem Grund nicht ermittelt werden, etwa weil ein interner Cronjob ohne authentifizierten Nutzer läuft, sollte die Anwendung den Zugriff auf mandantenfähige Daten verweigern, statt versehentlich ungefiltert alle Mandanten zurückzugeben. Der folgende Code zeigt diesen Aufbau in vereinfachter, aber produktionsnaher Form.
<?php
declare(strict_types=1);
namespace App\Doctrine\Filter;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* Ergänzt bei jeder Query gegen eine mandantenfähige Entity automatisch
* eine WHERE-Bedingung auf die aktuelle Tenant-ID, unabhängig davon, ob
* der aufrufende Code das Repository explizit filtert.
*/
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!in_array(TenantAwareInterface::class, $targetEntity->getReflectionClass()->getInterfaceNames(), true)) {
return '';
}
$tenantId = $this->getParameter('tenantId');
if ($tenantId === '') {
// Fail closed: ohne gültigen Tenant-Kontext keine Zeile ausliefern.
return sprintf('%s.tenant_id = -1', $targetTableAlias);
}
return sprintf('%s.tenant_id = %s', $targetTableAlias, $tenantId);
}
}
// EventSubscriber, der den Filter pro Request aktiviert:
namespace App\EventSubscriber;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Aktiviert den TenantFilter bei jedem Request mit der Tenant-ID aus dem
* validierten JWT.
*/
final class TenantFilterSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TenantContext $tenantContext,
) {
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => 'onKernelRequest'];
}
public function onKernelRequest(RequestEvent $event): void
{
$filter = $this->entityManager->getFilters()->enable('tenant_filter');
$filter->setParameter('tenantId', $this->tenantContext->getCurrentTenantId());
}
}
6. Typische Schwachstellen, die auch ein Doctrine-Filter nicht automatisch schließt
Ein globaler Doctrine-Filter schützt zuverlässig alle Standard-Queries, die über den Entity Manager laufen, aber nicht automatisch native SQL-Queries, direkte DBAL-Aufrufe oder Bulk-Operationen wie DELETE- oder UPDATE-Statements, die manche Teams aus Performancegründen am ORM vorbei schreiben. Jede dieser Ausnahmen muss manuell mit derselben Tenant-Bedingung versehen werden, weshalb es sich lohnt, native Queries im Code-Review gezielt zu markieren und in einem eigenen PHPStan-Rule-Set zu erfassen, das native SQL-Aufrufe ohne erkennbare Tenant-Bedingung als Fehler meldet.
Ein weiterer häufiger Schwachpunkt ist der Cache: Wenn Antworten auf HTTP-Ebene oder auf Query-Ebene gecacht werden, muss der Cache-Key zwingend die Tenant-ID enthalten, sonst liefert ein Reverse-Proxy oder ein Result-Cache die Antwort eines Mandanten versehentlich an einen anderen aus, selbst wenn die Datenbank-Query selbst korrekt gefiltert war. Ähnliches gilt für asynchrone Verarbeitung über Message-Queues: Eine Nachricht, die ohne Tenant-Kontext in eine Queue geschrieben wird, kann beim Verarbeiten in einem Worker-Prozess nicht mehr zuverlässig einem Mandanten zugeordnet werden, weshalb die Tenant-ID immer explizit als Teil des Nachrichten-Payloads mitgeführt werden sollte, statt implizit aus einem Request-Kontext abgeleitet zu werden, der zum Zeitpunkt der Verarbeitung längst nicht mehr existiert.
7. Tenant-Isolation aktiv testen statt nur zu hoffen
Weil ein Isolationsfehler erst bei tatsächlichem Datenzugriff sichtbar wird und in einer normalen Testsuite leicht übersehen werden kann, lohnt sich ein dedizierter Testansatz: Für jeden Endpoint, der mandantenfähige Daten zurückgibt, erzeugt ein automatisierter Test bewusst Datensätze für zwei unterschiedliche Mandanten und prüft anschließend, dass ein als Mandant A authentifizierter Request niemals auch nur eine ID des Mandanten B in der Antwort enthält. Dieser Test lässt sich generisch für alle mandantenfähigen Entities parametrisieren und automatisch bei jedem neuen Endpoint mitlaufen lassen, sofern die Entity korrekt mit dem entsprechenden Interface markiert ist.
Ergänzend hilft ein sogenannter Fuzzing-Ansatz auf API-Ebene: Ein Testskript iteriert systematisch über alle Pfadparameter, die wie eine ID aussehen (etwa /api/orders/{id}), ersetzt sie durch IDs aus einem fremden Mandanten und erwartet konsequent einen 403- oder 404-Statuscode statt eines 200 mit fremden Daten. Dieser Test fängt genau die Fälle ab, die ein reiner Unit-Test am Repository übersieht, nämlich Autorisierungslücken, die erst auf Controller-Ebene entstehen, etwa weil ein Entwickler eine Entity per ID lädt, ohne vorher zu prüfen, ob sie überhaupt zum aktuellen Mandanten gehört.
8. Entscheidungshilfe: Welches Isolationsmodell für welches Projekt?
Für die meisten SaaS-Produkte mit potenziell tausenden kleinen bis mittleren Mandanten ist Row-Level-Isolation kombiniert mit einem konsequent durchgesetzten Doctrine-Filter der pragmatischste Startpunkt, weil sie operativ am günstigsten skaliert und Migrationen sowie Backups zentral bleiben. Sobald einzelne Mandanten deutlich größer werden oder regulatorische Anforderungen wie eine physisch getrennte Datenhaltung ins Spiel kommen, etwa bei Kunden aus dem Finanz- oder Gesundheitssektor, lohnt sich der Umstieg auf Schema-per-Tenant oder sogar Database-per-Tenant für genau diese großen Kunden, während kleinere Mandanten weiterhin im Row-Level-Modell verbleiben können.
Diese hybride Strategie, bei der das Isolationsmodell nicht global für die gesamte Plattform, sondern pro Mandant gewählt wird, ist in der Praxis häufiger als ein reines Lehrbuchbeispiel vermuten lässt, denn sie erlaubt es, die operative Einfachheit für die Mehrheit der Mandanten zu behalten und gleichzeitig die stärkere Isolation dort einzusetzen, wo sie vertraglich oder regulatorisch tatsächlich gefordert ist. Wichtig ist, diese Entscheidung frühzeitig zu treffen, denn eine nachträgliche Migration von Row-Level-Isolation zu Schema-per-Tenant für einen bereits produktiv laufenden Großkunden ist ungleich aufwendiger, als das Modell von Anfang an flexibel genug zu entwerfen.
9. Checkliste für den Entwurf einer neuen mandantenfähigen Endpoint-Gruppe
Bevor ein neuer Endpoint für mandantenfähige Daten in Produktion geht, lohnt sich eine kurze, feste Prüfliste: Trägt die betroffene Entity das Tenant-Interface und ist sie damit vom globalen Filter erfasst? Stammt der Tenant-Kontext ausschließlich aus dem validierten JWT und nicht aus einem ungeprüften URL- oder Header-Wert? Sind native SQL-Queries oder Bulk-Operationen für diesen Endpoint vorhanden, und falls ja, wurden sie manuell mit der Tenant-Bedingung versehen? Enthält ein eventuell genutzter Cache-Key die Tenant-ID? Und schließlich: Existiert ein automatisierter Cross-Tenant-Test für genau diesen Endpoint, der bewusst versucht, mit einem falschen Mandanten auf die Daten zuzugreifen?
Diese Checkliste ersetzt keine tiefere Architekturentscheidung, aber sie fängt die häufigsten und teuersten Fehler ab, bevor sie in Produktion landen. In der Praxis lohnt es sich, diese Punkte direkt als Pull-Request-Template zu hinterlegen, sodass jeder Reviewer bei jedem neuen Endpoint explizit dazu Stellung nehmen muss, statt sich implizit auf die Sorgfalt des ursprünglichen Autors zu verlassen. Multi-Tenant-Isolation ist letztlich kein einmaliges Architekturprojekt, sondern eine kontinuierliche Disziplin, die bei jeder neuen Zeile Code erneut durchgesetzt werden muss.
| Isolationsmodell | Betriebskosten | Isolationsstärke | Typischer Einsatz |
|---|---|---|---|
| Row-Level-Isolation | Niedrig, ein Schema für alle Mandanten | Abhängig von konsequenter Filterung | SaaS mit vielen kleinen bis mittleren Mandanten |
| Schema-per-Tenant | Mittel, Migrationen pro Schema nötig | Hoch, SQL-Fehler schlagen sichtbar fehl | B2B-SaaS mit mittelgroßen Mandanten |
| Database-per-Tenant | Hoch, eigene Infrastruktur pro Mandant möglich | Sehr hoch, physische Trennung | Regulierte Branchen, sehr große Einzelkunden |
| Hybrid (Row-Level + Ausnahmen) | Niedrig für die Mehrheit, höher für Ausnahmen | Variabel je Mandant | Plattformen mit wenigen sehr großen Schlüsselkunden |
Mironsoft
OpenAPI-Design, Symfony-APIs und API-Sicherheit
APIs, die externe Teams ohne Rückfragen integrieren können?
Wir prüfen bestehende REST-APIs auf inkonsistente Fehlerformate, fehlende OpenAPI-Dokumentation und Sicherheitslücken und bauen daraus eine API, die klar dokumentiert, versioniert und gegen Missbrauch abgesichert ist.
API-Review
OpenAPI-Spezifikation, Fehlerformate und Statuscodes auf Konsistenz prüfen.
Symfony-Umsetzung
DTOs, Serializer und Validator für saubere, typsichere Request/Response-Modelle einsetzen.
Security-Audit
Rate-Limiting, Auth-Schemes und Input-Validierung gegen echte Angriffsflächen absichern.
10. Zusammenfassung
Multi-Tenant-REST-APIs: Das Wichtigste auf einen Blick
Row-Level-Isolation
Günstig im Betrieb, aber vollständig abhängig von konsequenter Filterung in jeder Query.
JWT als Tenant-Quelle
Die Tenant-ID gehört in ein signiertes JWT, niemals ungeprüft in URL oder Header.
Doctrine-Filter
Ein globaler SQLFilter injiziert die Tenant-Bedingung automatisch, unabhängig von der Disziplin einzelner Entwickler.
Cross-Tenant-Tests
Automatisierte Tests mit zwei Mandanten decken Isolationsfehler auf, bevor sie in Produktion landen.