TenantForge — ein Starter für mandantenfähige SaaS-Backends
Mandantentrennung, die PostgreSQL per Row-Level Security durchsetzt — statt Anwendungscode, der ans Filtern denken muss. Dazu rotierende Refresh-Tokens, RBAC je Mandant und ein Audit-Log.
Eine Behauptung und eine Testsuite, die sie zu widerlegen versucht: Eine Anfrage sieht nur den Workspace, den ihr Token nennt — garantiert von PostgreSQL, nicht vom Code.
Das Problem
In einem mandantenfähigen Backend lautet die übliche Antwort „immer nach
tenant_id filtern" — und die ist eine vergessene WHERE-Klausel von einem
Datenleck entfernt: in einem neuen Endpunkt, in einem schnell geschriebenen
JOIN, in einem COUNT(*) fürs Dashboard. Sie verlässt sich auf jede
Entwicklerin und jeden Entwickler, für immer, und sie versagt offen: Das
Symptom des Fehlers sind mehr Daten, nicht weniger. Nichts stürzt ab, und kein
Test schlägt fehl, solange niemand genau diesen Test geschrieben hat.
Eine Datenbank, ein Schema, eine tenant_id-Spalte auf jeder mandantenbezogenen
Tabelle — durchgesetzt von PostgreSQL Row-Level Security. Die eine Behauptung
des Repositorys lautet: Eine Anfrage sieht ausschließlich den Workspace, den ihr
Token nennt. Die Testsuite existiert, um das zu widerlegen.
Drei Dinge machen die RLS echt statt dekorativ. Die Anwendung verbindet sich
mit einer Rolle, die keiner Policy entkommen kann — die erste Migration legt
sie mit NOSUPERUSER und NOBYPASSRLS an, denn „wir haben RLS" ist nichts
wert, solange man nicht sagen kann, mit welcher Rolle verbunden wird. Jede
Policy ist FORCEd, weil der Eigentümer einer Tabelle sonst von ihren eigenen
Policies ausgenommen ist — und genau als Eigentümer laufen Migrationen und
Seeds. Der Mandant ist eine transaktionslokale Einstellung, kein
Query-Parameter: Die Request-Dependency ruft
set_config('app.current_tenant', …, true), der Wert stirbt also mit der
Transaktion und kann nicht auf die nächste Entnahme aus dem Pool durchsickern.
Jede Policy hat beide Hälften. USING ist der Grund, warum eine ID aus einem
fremden Workspace ins Leere läuft; WITH CHECK ist der Grund, warum ein
Service, der die falsche tenant_id berechnet hat, ein abgelehntes INSERT
bekommt statt still eine fremde Zeile zu schreiben. Und es versagt geschlossen:
Ohne gesetzte Einstellung ist das Prädikat NULL, eine ungebundene Session
liest also null Zeilen. Der Normalzustand einer Session, die vergessen hat,
sich auszuweisen, ist Blindheit.
Datenbank pro Mandant und Schema pro Mandant wurden beide geprüft und werden im
README begründet abgelehnt statt weggewischt — N Migrationsläufe pro Deploy,
eine Geschichte für Teilfehlschläge, search_path als tragende globale
Zustandsvariable. Auch der Preis des gewählten Wegs steht dort: Die Policies
sind im Python unsichtbar, wer neu dazukommt, liest eine Repository-Methode ohne
Mandantenfilter und muss wissen, dass die Datenbank einen hinzufügt. Deshalb
prüft die Suite die Rollenattribute, pg_class und pg_policies direkt.
Der Rest ist gewöhnliche Sicherheitsarbeit, ordentlich gemacht. Argon2id im
OWASP-Profil mit Rehash beim Login. Refresh-Tokens sind keine JWTs — sie
sind undurchsichtige Zufallsstrings, gespeichert als geschlüsselter
HMAC-Digest: Eine geleakte Tabelle ergibt keine brauchbare Anmeldung. Die
Rotation erkennt Wiederverwendung: Wer ein verbrauchtes Token vorlegt, hat eine
entwichene Kopie — also wird die ganze Familie widerrufen, und der rechtmäßige
Inhaber merkt es, statt still eine Sitzung mit einem Dieb zu teilen.
Access-Tokens bleiben zustandslos und trotzdem widerrufbar, über einen
token_version-Claim. Dazu UUID-Schlüssel, eine einzige Fehlermeldung für alle
Login-Ursachen samt Dummy-Verifikation auf dem Unbekannter-Nutzer-Pfad, und
404 statt 403 für fremde Daten — ein 403 beantwortet genau die Frage, die ein
Enumerator stellt.