Multi-Tenant-REST-APIs: Mandanten-Isolation sauber entwerfen
AI generated
{ }
GET
REST-API · Multi-Tenancy · Sicherheit
Multi-Tenant-REST-APIs: Mandanten-Isolation sauber entwerfen
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.

16 Min. Lesezeit Tenant-Isolation · Sicherheit Symfony · Doctrine-Filter

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.

11. FAQ: Multi-Tenant-REST-APIs: Das Wichtigste auf einen Blick

1Ist Row-Level-Isolation grundsätzlich unsicherer als Schema-per-Tenant?
Nicht grundsätzlich, aber sie verlagert die gesamte Verantwortung für die Isolation in die Anwendungslogik. Mit einem konsequent durchgesetzten globalen Filter und automatisierten Cross-Tenant-Tests lässt sich ein sehr hohes Sicherheitsniveau erreichen, es erfordert aber mehr Disziplin als eine physisch getrennte Datenbank.
2Warum sollte die Tenant-ID nicht einfach als Query-Parameter übergeben werden?
Weil ein Query-Parameter vom Client frei gesetzt werden kann. Ohne zusätzliche Prüfung gegen den authentifizierten Nutzer könnte ein Angreifer einfach eine fremde Tenant-ID eintragen und so auf fremde Daten zugreifen.
3Reicht ein Doctrine-Filter aus, um Cross-Tenant-Zugriffe vollständig zu verhindern?
Er deckt alle Standard-Queries über den Entity Manager ab, aber nicht native SQL-Queries, Bulk-Operationen oder Caching-Layer. Diese Ausnahmen müssen zusätzlich manuell abgesichert werden.
4Wie testet man Tenant-Isolation zuverlässig?
Am besten mit automatisierten Tests, die bewusst Daten für zwei unterschiedliche Mandanten anlegen und prüfen, dass ein als Mandant A authentifizierter Request niemals Daten von Mandant B in der Antwort enthält, kombiniert mit Fuzzing über Pfadparameter, die wie IDs aussehen.
5Kann man verschiedene Isolationsmodelle innerhalb derselben Plattform mischen?
Ja, das ist in der Praxis ein häufiges hybrides Modell: Die Mehrheit der Mandanten läuft im günstigeren Row-Level-Modell, während besonders große oder regulatorisch anspruchsvolle Mandanten ein eigenes Schema oder eine eigene Datenbank erhalten.
6Was passiert, wenn der Tenant-Kontext bei einem internen Cronjob fehlt?
Ein sauber entworfener Filter sollte in diesem Fall fail-closed reagieren, also gar keine Daten zurückliefern, statt versehentlich ungefiltert alle Mandanten auszuliefern. Interne Batch-Prozesse sollten den Tenant-Kontext explizit und bewusst setzen, statt sich auf einen impliziten Request-Kontext zu verlassen.
7Muss der Cache-Key wirklich die Tenant-ID enthalten?
Ja, unbedingt. Ein Cache-Key ohne Tenant-ID kann dazu führen, dass ein Reverse-Proxy oder ein Result-Cache die zwischengespeicherte Antwort eines Mandanten an einen anderen ausliefert, selbst wenn die ursprüngliche Datenbank-Query korrekt gefiltert war.
8Wie führt man asynchrone Verarbeitung über Message-Queues sicher für mehrere Mandanten durch?
Die Tenant-ID muss explizit Teil des Nachrichten-Payloads sein, denn beim Verarbeiten in einem Worker existiert der ursprüngliche Request-Kontext nicht mehr und kann daher nicht implizit für die Filterung genutzt werden.
9Ab wann lohnt sich der Umstieg von Row-Level-Isolation zu Schema-per-Tenant?
Meist dann, wenn einzelne Mandanten stark wachsen, hohe Lastspitzen einzelner Kunden andere Mandanten beeinträchtigen könnten, oder wenn regulatorische Anforderungen eine physisch nachvollziehbare Trennung der Daten verlangen.
10Sollte man native SQL-Queries in einem Multi-Tenant-Projekt grundsätzlich vermeiden?
Nicht grundsätzlich, aber jede native Query sollte im Code-Review gezielt markiert und manuell auf eine korrekte Tenant-Bedingung geprüft werden, idealerweise zusätzlich durch eine automatisierte Analyse-Regel abgesichert.