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.

Ergebnis

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.