Mandantenfähigkeit ist nicht nur eine zusätzliche tenant_id-Spalte. Eine Anwendung muss verhindern, dass Daten, Aktionen, Dateien oder Hintergrundjobs aus einem Mandanten in einen anderen Kontext gelangen. Gleichzeitig braucht jedes Objekt eigene fachliche Berechtigungen. Wer beide Regeln vermischt, baut schwer prüfbare Sicherheitslogik.
Drei getrennte Fragen
Bei jedem Zugriff sind mindestens drei Entscheidungen nötig:
- Zu welchem Mandanten gehört die aktuelle Anfrage?
- Darf die angemeldete Person dieses konkrete Objekt verwenden?
- Ist die gewünschte Aktion im aktuellen fachlichen Zustand erlaubt?
Ein Benutzer kann zum richtigen Mandanten gehören und trotzdem nicht jede Rechnung freigeben. Eine Person mit Freigaberecht darf eine bereits stornierte Rechnung nicht erneut verbuchen. Diese Regeln benötigen getrennte Namen, Tests und Fehlerfälle.
Mandantenkontext nicht aus Nutzereingaben vertrauen
Eine Mandanten-ID aus Route, Header oder Formular ist zunächst untrusted input. Der Server muss sie gegen Sitzung, Token, Domain oder eine andere verlässliche Zuordnung prüfen. Danach wird ein expliziter Kontext für die Anfrage hergestellt.
Globale Eloquent Scopes können versehentliche Abfragen reduzieren. Sie sind jedoch kein vollständiges Sicherheitsmodell. Queries über DB::table() und bewusst mit withoutGlobalScope() ausgeführte Eloquent-Abfragen umgehen diese Hilfe. Administrative Prozesse und Queue-Jobs behalten einen Modell-Scope zwar grundsätzlich, besitzen aber häufig keinen automatisch gesetzten Request-Tenant; der Kontext muss dort explizit hergestellt werden. Die Grenze muss auch an Repository-, Policy- und Datenbankzugriffen erkennbar bleiben.
Objektberechtigungen in Policies ausdrücken
Laravel Policies geben Aktionen wie view, update, delete oder approve einen zentralen Ort. Eine Policy sollte den bereits bestimmten Mandantenkontext und die Beziehung des Nutzers zum Objekt prüfen. Controller und Oberfläche dürfen diese Entscheidung aufrufen, aber nicht in abweichenden Varianten nachbauen.
Ein unsicheres Muster ist die alleinige Prüfung einer globalen Rolle:
return $user->role === 'manager';
Die relevante Frage lautet eher: Ist diese Person Manager in genau diesem Mandanten, gehört das Objekt dorthin und erlaubt sein Zustand die Aktion? Je näher der Code diese Fachfrage abbildet, desto besser lässt er sich prüfen.
Versteckte Systemgrenzen beachten
Viele Datenlecks entstehen außerhalb normaler Controller:
- Queue-Jobs verlieren den Mandantenkontext oder laden ein Objekt nur per ID.
- signierte Download-Links prüfen die Signatur, aber nicht den aktuellen Zugriff.
- Exporte verwenden einen zu breiten Query ohne Tenant-Filter.
- Suchindizes enthalten Datensätze mehrerer Kunden und filtern erst in der UI.
- Cache-Keys unterscheiden Rolle oder Mandant nicht.
- Logs und Fehlerberichte enthalten fremde Fachdaten.
Für jeden asynchronen Prozess muss der Kontext explizit serialisiert und bei Ausführung erneut validiert werden. Ein Job sollte nicht darauf vertrauen, dass ein globaler Zustand aus dem ursprünglichen Request noch existiert.
Negative Tests als Sicherheitsnachweis
Der wichtigste Test lautet nicht nur „Mandant A kann seine Rechnung öffnen“. Ebenso nötig sind:
| Test | Erwartung |
|---|---|
| Nutzer A ruft ID aus Mandant B ab | 404 oder kontrollierte Ablehnung |
| Manager ohne Objektbezug gibt frei | verboten |
| Job erhält Objekt aus falschem Tenant | Abbruch und Alarmierung |
| signierter Link wird nach Rechteentzug genutzt | kein Zugriff |
| Suchergebnis enthält fremdes Objekt | Ergebnis wird nie ausgeliefert |
Factory-Zustände und Datensätze für mindestens zwei Mandanten machen solche Tests lesbar. Zufällig generierte IDs reichen nicht, wenn Beziehungen absichtlich gekreuzt werden müssen.
Datenbank als zusätzliche Grenze
Eindeutige Schlüssel sollten den Mandanten berücksichtigen, wenn Werte nur innerhalb eines Kunden eindeutig sein müssen. Fremdschlüssel und Constraints verhindern ungültige Beziehungen auch dann, wenn ein Codepfad einen Fehler hat. Bei sehr hohem Schutzbedarf kann Datenbank-Row-Level-Security eine weitere Schicht bilden, erhöht aber Betriebs- und Testaufwand.
Die Architekturentscheidung hängt von Schutzbedarf, Team und Plattform ab. Separate Datenbanken bieten starke Isolation, erschweren jedoch Migrationen, Reporting und Betrieb. Eine gemeinsame Datenbank ist gut beherrschbar, wenn Kontext und Tests konsequent sind.
Eine kleine Cross-Tenant-Testmatrix in Pest
Die Matrix sollte dieselbe fremde Objekt-ID über alle erreichbaren Wege prüfen, nicht nur über den Controller:
it('rejects cross-tenant access through every delivery path', function (string $path) {
$user = User::factory()->for($tenantA)->create();
$invoice = Invoice::factory()->for($tenantB)->create();
actingAs($user);
match ($path) {
'http' => getJson(route('invoices.show', $invoice))->assertForbidden(),
'download' => get(route('invoices.download', $invoice))->assertForbidden(),
'export' => postJson(route('exports.store'), ['invoice_id' => $invoice->id])
->assertForbidden(),
};
})->with(['http', 'download', 'export']);
Ein separater Queue-Test dispatcht den Job mit IDs aus zwei Mandanten und erwartet einen kontrollierten Abbruch vor dem Datenzugriff. Ob die Anwendung 403 oder 404 liefert, ist eine bewusste Produktentscheidung. Wichtig ist, dass alle Pfade dieselbe Invariante erzwingen und keine fremden Daten in Antwort, Log oder Export gelangen.
Die umfassendere Architektur dahinter beschreibt Laravel-Sicherheit beginnt vor dem Controller. Konkrete unsichere Implementierungsmuster stehen in 10 häufige Sicherheitsfehler in Laravel-Projekten.
Quellen und Vertiefung
- Laravel 13 Dokumentation: Authorization
- Laravel 13 Dokumentation: Eloquent Global Scopes
- OWASP ASVS: Access Control
- OWASP Authorization Cheat Sheet
Fazit
Sichere Mandantenfähigkeit entsteht durch mehrere sichtbare Grenzen. Mandantenkontext, Objektberechtigung und fachlicher Zustand werden getrennt entschieden und gemeinsam getestet. Besonders Queue-Jobs, Downloads, Suche und Caches verdienen dieselbe Sorgfalt wie der Controller.