Eine Datenbank pro Tenant: was sie mich gekostet hat
Mein SaaS gibt jedem Tenant eine eigene Datenbank. Diese Entscheidung kauft Isolation, die kein Code-Review erzwingen muss: Eine Produktabfrage ohne initialisierten Tenant-Kontext stirbt an einer fehlenden Tabelle, statt Zeilen eines anderen Tenants zurückzugeben. Das hier ist die Geschichte dessen, was dieselbe Entscheidung hinterher gefordert hat.
Was bringt eine Datenbank pro Tenant?#
Ich habe die schwerste Option im Menü genommen: stancl/tenancy im Multi-Database-Modus, eine Datenbank pro Tenant. Die zentrale Datenbank hält Tenants, ihre Domains, meine Plattform-User und das Billing-Ledger. Alles andere, User, Orders, Tickets, Sessions, liegt in der Datenbank des Tenants. 63 Migrationen laufen unter database/migrations/tenant, und es gibt nirgendwo im Projekt eine Spalte tenant_id, die man vergessen könnte.
Der Gewinn zeigt sich im Fehlermodus. Bei Spalten-Scoping ist der Fehler eines vergessenen where stumm: Zeilen kommen zurück, die falschen. Hier trifft eine Anfrage ohne initialisierten Tenant-Kontext auf eine Datenbank, die gar keine Produkttabellen hat. Die Abfrage stirbt mit einem Relation-Fehler. Der falsche Kontext leakt keine Daten, er verweigert sich, und ein Feature-Test hält dieses Verhalten fest, denn eine Regel, die sich nur eine Person merkt, ist eine Regel, die gebrochen wird.
Der Default, der das ganze Modell aushebelt#
Jede Tenant-Datenbank bekommt in Produktion eine eigene PostgreSQL-Login-Rolle, damit ein geleaktes Passwort genau eine Datenbank erreicht. Das war die Theorie. Die Datenbank hatte andere Pläne: PostgreSQL verteilt Privilegien standardmäßig an PUBLIC, und die Doku listet für Datenbanken als Default „CONNECT and TEMPORARY (create temporary tables) privileges“ (Privilegien-Kapitel). Die Rolle, die ich gerade für Tenant A angelegt hatte, konnte also ab Werk die Datenbank von Tenant B öffnen. Ohne einen Bug von mir. Die Isolation, von der ich dachte, ich hätte sie gekauft, war ein Vorschlag.
Drei Zeilen fixen das, und sie laufen für jeden neuen Tenant:
CREATE ROLE tenant_a9f3_app WITH LOGIN PASSWORD '…';
REVOKE CONNECT ON DATABASE tenant_a9f3 FROM PUBLIC;
GRANT CONNECT ON DATABASE tenant_a9f3 TO tenant_a9f3_app;Das REVOKE ist die Zeile, die das Modell trägt. Aus derselben Naht kamen zwei kleinere Lektionen: PostgreSQL lehnt CREATE DATABASE in einer Transaktion ab (SQLSTATE 25001, das Test-Setup produziert das auf Bestellung), und seit Version 15 bekommt das Schema public kein CREATE mehr per Default, deshalb braucht jede Tenant-Rolle ein GRANT USAGE, CREATE ON SCHEMA public, bevor ihre Migrationen laufen können.
Wenn der Scheduler als falscher Tenant aufwacht#
Das Tenant::run() des Vendors tauscht den Datenbank-Kontext für die Dauer eines Callbacks und stellt den alten wieder her, nachdem es erfolgreich war. Nur nachdem es erfolgreich war. schedule:run führt alle fälligen Tasks in einem Prozess aus, also musste ich mir ausmalen, was passiert, wenn Task eins im Kontext von Tenant A stirbt und Task zwei startet, ohne dass jemand zurückschaltet. Der Container zeigt weiter auf Tenant A. Task zwei liest Tenant As Tabellen, schreibt Tenant As Zeilen und meldet Erfolg. Genau dieses Szenario steht im Docblock des Traits, der den Vendor-Helfer ersetzt hat, als Risiko einer Cross-Tenant-Kontamination. Ein ruhiger Name für das Schlimmste, was meine Software anstellen kann.
Seitdem läuft jedes Tenant-berührende Command durch einen Trait: pro Tenant try, catch, report, weiter, und der Kontext endet in einem finally, egal wie die Iteration ausgeht. Die Exception eines Tenants kostet seinen Lauf, nie den der Flotte. Die Queue brauchte dieselbe Disziplin. Der Database-Queue-Driver musste explizit auf die zentrale Verbindung gepinnt werden, sonst landeten fehlgeschlagene Jobs in der Datenbank, in der der Worker gerade stand.
Nichts im Framework weiß von deinen Tenants#
Dann kam die Woche, in der das Framework selbst geschult wurde. Echo-Clients authentifizieren ihre Websocket-Subscriptions per POST an /broadcasting/auth, und Laravel registriert diese Route global mit nichts als der schlichten web-Gruppe. Bei einer Single-Database-App ist das fein. Bei meiner löste der Database-Session-Handler den Session-User gegen die zentrale Verbindung auf, die keine Users-Tabelle hat, und jede authentifizierte Seite auf jeder Tenant-Domain antwortete 500. Der Fix war klein, die Broadcasting-Routen sitzen jetzt hinter derselben Tenant-Identifikation wie alles andere, aber die Diagnose war ein langer Weg durch Session-Handler-Stacktraces.
Laravel Pennant, meine Feature-Flag-Schicht, blutete in die andere Richtung: gar keine Fehler. Sein In-Memory-Cache legt den Null-Scope in einen Eimer, den sich der ganze Prozess teilt, und eine Scheduler-Schleife, die Tenant für Tenant initialisiert, liest den gecachten Flag von Tenant A unter Tenant B. Nichts wirft. Die Antwort ist einfach für einen der beiden Tenants falsch. Die Gegenmaßnahme ist ein Event-Listener, der den Flag-Cache bei jedem Tenant-Wechsel leert, plus ein Test, der den Build fallen lässt, falls der Flush je verschwindet.
Sogar Assets brauchten zwei Commits in entgegengesetzte Richtungen. Der Tenancy-Asset-Helper schrieb asset() so lange um, bis meine gebaute SPA auf Tenant-Domains nie mountete, und später brauchte der Public-Storage-Disk ebendiese Vendor-Route, um Tenant-Dateien überhaupt auszuliefern. Multi-Tenancy multipliziert auch jede Cache-Entscheidung, ein Thema, das ich für den Single-App-Fall in CDN-Caching für eine Laravel-App durchgearbeitet habe.
Vertrauen am Payment-Webhook#
Dann erhöhte das Geld den Einsatz: Tenants verbinden die Payment-Provider, die ihr Markt tatsächlich nutzt, und die Credentials jedes Providers liegen verschlüsselt in der Datenbank des Tenants, write-only im UI, selbst für mich. Ihre Webhooks laufen auf einer gemeinsamen Route auf, und das Vertrauensmodell hat eine strenge Reihenfolge: erst den Tenant über die Domain auflösen, dann die Signatur gegen das gespeicherte Secret genau dieses Tenants prüfen. Eine Signatur, die für Tenant A perfekt gültig ist, ist gefälschter Müll, sobald sie auf der Domain von Tenant B auftaucht, und ein Test in der Suite behauptet genau das.
Die Fehlerantworten sind eine Leiter, die ich dem Support erklären kann: unbekannter Provider, 404. Konfiguriert, aber nie mit einem Secret bewaffnet, 503. Signatur passt nicht, 403. Replays sterben an einem Journal, das den SHA-256 jedes verifizierten Bodys speichert und byte-identische Redeliveries ignoriert, ohne sie doppelt zu verbuchen.
Dieselbe Ecke der Codebasis hat mein liebstes JSON-Detail produziert. Einen Tenant zu sperren, der nicht mehr zahlt, heißt, einen Maintenance-Mode-Key auf dem Tenant-Record zu setzen, und die Vendor-Doku schlägt zum Aufheben einen Null-Wert vor. Auf PostgreSQL hält das den Tenant für immer gesperrt: Ein JSON-null innerhalb der Data-Spalte ist kein SQL-NULL, also matcht jede Abfrage, die nach einem abwesenden Key sucht, weiterhin. Die Resume-Action entfernt den Key jetzt komplett, was sich auf SQLite im Test und auf PostgreSQL in Produktion gleich liest. Ein Treiber-Unterschied, gefunden an einer Dev-Datenbank statt von einem Kunden.
Was die Isolation vor dem Verrotten bewahrt#
Inzwischen leben die Regeln dieser Architektur eher in der Test-Suite als in meinem Kopf. Ein Test läuft über den ganzen Schedule und fällt aus, wenn ein Command Tenant-Tabellen anfassen kann ohne den Sweep-Trait oder einen Platz auf einer kurzen, expliziten Allowlist. Ein anderer erzeugt echte PostgreSQL-Datenbanken und versucht mit der Login-Rolle von Tenant A die Wand zu überqueren, in der Erwartung der Ablehnung an der Tür. Andere behaupten, dass eine Anfrage ohne Kontext nirgendwo ankommt und dass die Webhook-Signatur von Tenant A unter der Domain von Tenant B nichts wert ist. Die CI hält das Modell zusammen, gut so, denn mein Gedächtnis skaliert nicht auf die Randfälle jedes Tenants.
Würde ich dieselbe Entscheidung wieder treffen? Ja, und ich würde dieselbe Überraschung einplanen: Die Bibliothek gibt dir die Architektur an einem Nachmittag, und die Monate danach gehen dafür drauf, jedem Subsystem beizubringen, Scheduler, Queue, Cache, Broadcaster, Asset-Pipeline, dass die Datenbank unter dir nicht die ist, mit der der Prozess gestartet ist. Diese App läuft dabei auf Laravel 13, und das Release hat sich als wichtiger für meinen Alltag erwiesen, als ein flüchtiger Blick ins Changelog vermuten lässt (was Laravel 13 tatsächlich für mich geändert hat).