Postgres Row-Level Security für Multi-Tenant-SaaS: Wo es ausreicht und wo es still versagt

#Postgres Row-Level Security Multi-Tenant
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Postgres Row-Level Security ist eines jener Features, das wie die Antwort auf eine Frage klingt, die SaaS-Teams seit Jahren stellen: Was wäre, wenn die Datenbank selbst die Mandantentrennung erzwingt, sodass eine fehlende WHERE-Klausel niemals die Daten eines Kunden an einen anderen durchsickern lassen könnte?

Die Anziehungskraft ist real. Postgres Row-Level Security für Multi-Tenant-Architekturen verlagert die Isolation eine Schicht tiefer -- vom Anwendungscode, den Entwickler vergessen können zu aktualisieren, zur Datenbank-Engine, die nicht vergisst. Aber Teams, die RLS einsetzen, ohne seine Grenzen zu verstehen, entdecken die Lücken oft in der Produktion. Und die Lücken sind subtil genug, dass Standard-Test-Suites sie selten erkennen.

Dieser Beitrag zeigt, wo RLS standhält, wo es still versagt, und wie man eine mandantenfähige Symfony/Doctrine-Anwendung darauf aufbaut, ohne sich zu verbrennen.

Was Postgres RLS tatsächlich tut

Row-Level Security ermöglicht es dir, Richtlinien an Tabellen anzuhängen, die Postgres auswertet, bevor es Zeilen zurückgibt oder ändert. Eine Richtlinie ist ein Prädikat -- ein SQL-Ausdruck -- das für eine gegebene Zeile und eine gegebene Datenbanksitzung wahr sein muss. Wenn keine Richtlinie passt, ist die Zeile unsichtbar.

Eine grundlegende Mandantentrennungsrichtlinie sieht so aus:

-- RLS auf der Tabelle aktivieren
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Richtlinie für Mandantentrennung erstellen
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Jede Abfrage gegen orders filtert jetzt implizit nach tenant_id = current_setting('app.tenant_id'). Ein Entwickler, der ein unvorsichtiges SELECT * FROM orders schreibt, bekommt nur die Zeilen seines Mandanten, nicht die aller anderen. Wenn die Sitzungsvariable app.tenant_id nicht gesetzt ist, gibt Postgres nichts zurück -- was sicherer ist als alles zurückzugeben.

Der Mechanismus, der das ermöglicht, ist SET LOCAL oder SET SESSION, um den aktuellen Mandanten vor jeder Anfrage in die Sitzungskonfiguration zu schreiben:

SET LOCAL app.tenant_id = '3f7d2a19-cb12-4e8b-9f00-a1b2c3d4e5f6';

Von der Anwendungsseite muss das einmal pro Anfrage passieren, bevor die erste Abfrage ausgeführt wird.

RLS in eine Symfony/Doctrine-Anwendung einbinden

Der sauberste Integrationspunkt in Symfony ist ein Doctrine-Event-Listener, der bei postConnect und bei der ersten Abfrage jeder Anfrage feuert. Ein RequestStack-bewusster Listener kann den Mandanten für jede neue Verbindung setzen:

// src/Doctrine/TenantConnectionSubscriber.php
use Doctrine\DBAL\Event\ConnectionEventArgs;
use Doctrine\DBAL\Events;
use Doctrine\Common\EventSubscriber;
use Symfony\Component\HttpFoundation\RequestStack;

class TenantConnectionSubscriber implements EventSubscriber
{
    public function __construct(
        private readonly RequestStack $requestStack,
        private readonly TenantContext $tenantContext,
    ) {}

    public function getSubscribedEvents(): array
    {
        return [Events::postConnect];
    }

    public function postConnect(ConnectionEventArgs $args): void
    {
        $tenantId = $this->tenantContext->getId();
        if ($tenantId === null) {
            return;
        }
        $args->getConnection()->executeStatement(
            "SET LOCAL app.tenant_id = :id",
            ['id' => $tenantId]
        );
    }
}

Dieser Ansatz wirkt elegant, aber postConnect feuert einmal pro physischer Verbindung, nicht einmal pro Anfrage. Wenn Verbindungen wiederverwendet werden -- was in der Produktion immer der Fall ist -- ist die Mandantenvariable aus der vorherigen Anfrage auf dieser Verbindung noch gesetzt. Der Subscriber muss vor der ersten Abfrage jeder Anfrage laufen, nicht nur nach dem Verbindungsaufbau.

Die praktische Lösung ist ein Symfony-Event-Listener auf kernel.request, der SET LOCAL explizit aufruft:

// src/EventListener/TenantSessionListener.php
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpKernel\Event\RequestEvent;

class TenantSessionListener
{
    public function __construct(
        private readonly Connection $connection,
        private readonly TenantContext $tenantContext,
    ) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }
        $tenantId = $this->tenantContext->getId();
        if ($tenantId === null) {
            return;
        }
        $this->connection->executeStatement(
            "SET LOCAL app.tenant_id = :id",
            ['id' => $tenantId]
        );
    }
}

SET LOCAL begrenzt die Einstellung auf die aktuelle Transaktion. Wenn du SET SESSION verwendest, bleibt der Wert für die Dauer der Verbindung erhalten -- was genau das falsche Verhalten ist, wenn Verbindungen geteilt werden. Verwende immer SET LOCAL innerhalb einer Transaktion oder SET (Sitzungsebene) nur, wenn du garantieren kannst, dass die Verbindung vor der nächsten Anfrage nicht wiederverwendet wird, bevor sie erneut gesetzt wird.

Das Connection-Pooler-Problem

Hier scheitern die meisten RLS-Implementierungen in der Produktion.

PgBouncer im transaction-Modus -- dem empfohlenen Standardmodus für die meisten SaaS-Anwendungen, da er eine bessere Verbindungsauslastung bietet -- garantiert nicht, dass sitzungsweite Variablen erhalten bleiben. Im transaction-Modus kann jede Transaktion auf einer anderen Backend-Verbindung ausgeführt werden. SET LOCAL bleibt nur für die Dauer einer einzelnen Transaktion erhalten, daher kannst du im Transaction-Pooling-Modus SET LOCAL nur sicher innerhalb einer expliziten Transaktion verwenden.

Das Fehlermuster: Wenn deine Anwendung eine Abfrage außerhalb einer Transaktion ausführt -- ein einfaches SELECT ohne umschließendes BEGIN/COMMIT -- und SET SESSION zur Konfiguration des Mandanten verwendet, ist diese Einstellung möglicherweise nicht vorhanden, wenn PgBouncer der nächsten Abfrage in derselben Anfrage eine Backend-Verbindung zuweist. Das Ergebnis ist eine RLS-Richtlinie, die mit einer NULL- oder falschen Mandanten-ID ausgewertet wird.

Die Lösung: Die Mandantenkonfigurations-SET und die nachfolgende Abfrage immer in einer einzigen Transaktion zusammenfassen:

BEGIN;
SET LOCAL app.tenant_id = '3f7d2a19-cb12-4e8b-9f00-a1b2c3d4e5f6';
SELECT * FROM orders; -- RLS-Richtlinie wird korrekt ausgewertet
COMMIT;

In Doctrine bedeutet das, dass dein kernel.request-Listener den SET LOCAL-Aufruf innerhalb von $connection->beginTransaction() einschließen und nach der Antwort committen sollte -- oder praktischer: Du stellst sicher, dass alle Datenzugriffe innerhalb des Transaktionsmanagements von Doctrine stattfinden. Wenn du PgBouncer im session-Modus verwendest (geringere Verbindungsauslastung, aber einfachere Semantik), bleiben Sitzungsvariablen über Abfragen hinweg erhalten. Der Kompromiss ist, dass du mehr Backend-Verbindungen benötigst, was mehr kostet.

Die allgemeine Regel: Wenn du einen Connection Pooler verwendest, teste deine RLS-Richtlinien unter dem Pooler, nicht mit direkten Verbindungen. Sie verhalten sich unterschiedlich.

SECURITY DEFINER-Funktionen: Die unsichtbare Hintertür

Postgres-Funktionen, die mit SECURITY DEFINER definiert sind, laufen mit den Privilegien des Eigentümers der Funktion, nicht des Aufrufers. Das wird häufig für Hilfsfunktionen, Migrationen und Hintergrundjobs verwendet, bei denen du erweiterte Privilegien für eine bestimmte Operation möchtest.

Das Problem ist, dass RLS für den Funktionseigentümer umgangen wird, wenn dieser Eigentümer ein Superuser oder der Tabelleneigentümer ist. Eine SECURITY DEFINER-Funktion, die eine Mandantentabelle abfragt, umgeht RLS vollständig -- unabhängig davon, was app.tenant_id in der aufrufenden Sitzung gesetzt ist.

-- Diese Funktion umgeht RLS -- gefährlich, wenn der Eigentümer der Tabelleneigentümer ist
CREATE OR REPLACE FUNCTION get_tenant_orders(p_tenant_id uuid)
RETURNS SETOF orders
SECURITY DEFINER
AS $$
  SELECT * FROM orders WHERE tenant_id = p_tenant_id;
$$ LANGUAGE sql;

Die obige Funktion sieht so aus, als würde sie den Mandantenfilter manuell anwenden, also könnte man annehmen, dass sie sicher ist. Aber weil sie als Tabelleneigentümer läuft und RLS umgeht, kann jeder Aufrufer jede p_tenant_id übergeben -- auch eine, die ihm nicht gehört. Die manuelle WHERE-Klausel wird zum einzigen Schutz, und du bist zurück bei der Isolierung auf Anwendungsebene.

Die Absicherung: Jede SECURITY DEFINER-Funktion im Schema auditieren. Wo möglich, durch SECURITY INVOKER-Funktionen ersetzen, die als Aufrufer laufen und RLS unterliegen. Wo du wirklich erweiterte Privilegien benötigst, stelle sicher, dass die Funktion die Mandantenisolierung manuell erzwingt und füge einen Kommentar hinzu, der erklärt, warum RLS hier nicht der Schutzmechanismus ist.

Wo RLS nicht ausreicht

RLS ist eine starke Verteidigung, aber es gibt Kategorien von Operationen, bei denen es nicht greift oder nur unvollständig greift.

Aggregatanfragen über Mandanten hinweg. Wenn deine Anwendung Abfragen wie SELECT COUNT(*) FROM users für interne Analytics oder Abrechnung ausführt, wird RLS diese korrekt auf den aktuellen Mandanten begrenzen. Aber wenn deine Analytics-Pipeline mit einer Datenbankrolle verbindet, die RLS umgeht -- eine Rolle mit BYPASSRLS oder Superuser-Privilegien -- sehen diese Abfragen alle Mandanten. Abrechnungs- und Analytics-Pipelines, die direkt mit der Datenbank verbinden, benötigen ihre eigene Isolierungsstrategie.

Hintergrundjobs. Worker, die Queue-Items verarbeiten, verbinden oft mit der Datenbank, bevor sie wissen, welchem Mandanten sie dienen. Wenn ein Job-Prozessor den Mandanten am Anfang eines Jobs setzt, ist das korrekt. Wenn er es vergisst -- oder wenn der Mandantenkontext unklar ist, weil der Job ohne Mandantenmetadaten eingereiht wurde -- filtert die RLS-Richtlinie möglicherweise alle Zeilen heraus oder arbeitet stillschweigend mit den Daten des falschen Mandanten.

Mandantenübergreifendes Reporting. Einige SaaS-Produkte bieten mandantenübergreifende Aggregate für Operatoren oder Wiederverkäufer -- "alle Konten unter deiner Organisation". Das erfordert RLS-Bypass, was bedeutet, dass die Anwendungsschicht den korrekten Bereich erzwingen muss. RLS kann "zeige Zeilen für alle Mandanten in diesem Set" nicht ohne eine ausgefeiltere Richtlinie ausdrücken, die mit einer Autorisierungstabelle verknüpft -- machbar, aber komplex.

Schema-Migrationen. Das Ausführen von ALTER TABLE oder CREATE INDEX auf einer großen mandantengebundenen Tabelle erfordert eine Datenbankrolle, die RLS umgeht, oder es kommt zu unerwartetem Verhalten. Migrations-Tooling sollte sich als die Migrationsrolle verbinden und explizit dokumentieren, welche Tabellen RLS aktiviert haben, damit Operatoren wissen, was sie umgehen.

RLS mit dem Doctrine-Filter kombinieren

Für Teams, die Symfony mit Doctrine verwenden, ist der resilienteste Ansatz, sowohl RLS als auch den Doctrine-Mandantenfilter zu nutzen. RLS bietet Durchsetzung auf Datenbankebene, die Rogue-Abfragen, ORM-Umgehungen oder direkte psql-Verbindungen von Entwicklern übersteht. Der Doctrine-Filter bietet eine zweite Schicht, die ORM-seitige Versäumnisse abfängt und den Mandantenbereich im PHP-Code sichtbar macht.

Der Doctrine-Filter schützt nicht vor rohen SQL-Abfragen, die mit $connection->executeQuery() ausgeführt werden, aber er deckt alle Doctrine-Entity-Abfragen ab. RLS deckt alles auf Datenbankebene ab. Zusammen reduzieren sie den Schadensradius bei einem einzelnen Fehler.

// Beide Schichten in deinem Symfony-Bootstrap aktivieren
// 1. Doctrine-Filter (ORM-Schicht)
$em->getFilters()->enable('tenant_filter')->setParameter('tenantId', $tenantId, 'uuid');

// 2. RLS-Sitzungsvariable (Datenbankschicht)
$connection->executeStatement("SET LOCAL app.tenant_id = :id", ['id' => $tenantId]);

Die Kombination ist ausführlich, aber explizit. Jeder Entwickler, der den Anfrage-Bootstrap liest, versteht, dass Mandantenisolierung auf zwei Ebenen stattfindet -- und dass beide korrekt sein müssen.

Eine praktische Checkliste vor dem Produktions-Rollout

Das Durcharbeiten dieser Checkliste vor deinem ersten Produktions-Deployment erspart dir eine schmerzhafte Post-Incident-Review:

Unter dem tatsächlichen Connection-Pooler-Modus testen. Einen Integrationstest schreiben, der sich durch PgBouncer im Transaction-Modus verbindet, keine explizite Transaktion umschließt und überprüft, dass RLS korrekt angewendet wird. Dieser Test wird fehlschlagen, wenn deine Konfiguration falsch ist.

SECURITY DEFINER-Funktionen auditieren. SELECT proname, prosecdef FROM pg_proc WHERE prosecdef = true; ausführen und jedes Ergebnis gegen mandantengebundene Tabellen prüfen.

Überprüfen, dass dein Hintergrundjob-Worker den Mandantenkontext vor der ersten Abfrage setzt. Jede Job-Klasse auf Initialisierung des TenantContext überprüfen.

Den "kein Mandant gesetzt"-Pfad testen. Was macht deine Anwendung, wenn app.tenant_id NULL ist? Die richtige Antwort: keine Zeilen zurückgeben und eine Warnung protokollieren. Die falsche Antwort: alle Zeilen zurückgeben (was passieren kann, wenn deine RLS-Richtlinie current_setting('app.tenant_id', true) mit dem missing_ok-Flag verwendet und den leeren String-Fall nicht behandelt).

Die Migrationsrolle prüfen. Bestätigen, dass die Datenbankrolle deines Migrations-Toolings BYPASSRLS hat und dass dies beabsichtigt, dokumentiert und die Rolle keine anderen erhöhten Privilegien hat.

Wann RLS gegenüber Schema-per-Mandant wählen

RLS ist die richtige Standardwahl für die meisten SaaS-Produkte im Startup- und Scale-up-Stadium. Schema-per-Mandant-Isolierung bietet stärkere Garantien, aber mit erheblichen Betriebskosten: komplexere Migrationen, höherer Postgres-Speicherbedarf pro Schema und schwierigere mandantenübergreifende Analytics. RLS bietet starke Isolierung auf Datenbankebene ohne diesen Overhead.

Die Ausnahmen, wo Schema-per-Mandant die Komplexität wert wird: Enterprise-Kunden mit vertraglichen Datensouveränitätsanforderungen, regulierte Branchen, in denen ein Prüfer die Datentrennung auf Schema-Ebene verifizieren muss, und Produkte, bei denen Backup und Restore pro Mandant ein Kernfeature ist, das Kunden tatsächlich nutzen.

Für den Shared-Schema-Pfad bei Wolf-Techs SaaS-Entwicklungsservices empfehlen wir, mit RLS-gestütztem Shared Schema zu beginnen und die Schema-per-Mandant-Migration aufzuschieben, bis eine spezifische Enterprise-Anforderung sie erzwingt. Die meisten SaaS-Unternehmen müssen diese Migration nie durchführen, wenn sie die RLS-Implementierung von Anfang an richtig machen.

Zusammenfassung

Postgres Row-Level Security ist eine echte Verbesserung gegenüber reiner Mandantenisolierung auf Anwendungsebene. Sie übersteht Entwicklerversäumnisse, ORM-Umgehungen und direkte Datenbankverbindungen -- solange du verstehst, wo sie nicht greift. Die drei Stellen, an denen sie Teams zuverlässig im Stich lässt, sind fehlerhafte Connection-Pooler-Konfiguration, SECURITY DEFINER-Funktions-Blindstellen und Hintergrundjobs, die ohne Mandantenkontext verbinden.

Die Muster in diesem Beitrag -- SET LOCAL innerhalb von Transaktionen verwenden, SECURITY DEFINER-Funktionen auditieren, RLS mit dem Doctrine-Filter kombinieren und unter der tatsächlichen Pooler-Konfiguration testen -- schließen die meisten dieser Lücken, ohne einen Schema-per-Mandant-Umbau zu erfordern.

Wenn du Mandantenisolierung für ein neues SaaS-Produkt entwirfst oder eine bestehende Shared-Schema-Anwendung auf Risiken von Mandantendatenlecks auditierst, melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io, um zu besprechen, wie die richtige Architektur für deine spezifische Situation aussieht.