7 Min

PostgreSQL Row-Level Security für SaaS: Richtlinien, die funktionieren

PostgreSQL Row-Level Security für SaaS hilft, Mandantenisolation in der Datenbank durchzusetzen. Erfahre, wann du sie einsetzen solltest, wie du Policies schreibst und welche Fehler du vermeiden musst.

PostgreSQL Row-Level Security für SaaS: Richtlinien, die funktionieren

Das eigentliche Problem, das RLS in SaaS-Apps löst

In einer SaaS-App ist der gefährlichste Sicherheitsfehler derjenige, der erst auftaucht, wenn du skaliert hast. Du startest mit einer einfachen Regel wie „Benutzer dürfen nur die Daten ihres Tenants sehen“, dann rollst du schnell einen neuen Endpunkt aus, fügst eine Reporting-Abfrage hinzu oder führst einen Join ein, der die Prüfung stillschweigend umgeht.

Nur in der App durchgeführte Autorisierung bricht unter Druck zusammen, weil die Regeln verstreut sind. Ein Controller prüft tenant_id, ein anderer prüft Mitgliedschaft, ein Background-Job vergisst es, und ein „Admin-Export“-Pfad bleibt monatelang „vorübergehend“. Selbst sorgfältige Teams übersehen Stellen.

PostgreSQL Row-Level Security (RLS) löst ein konkretes Problem: sie lässt die Datenbank durchsetzen, welche Zeilen für eine bestimmte Anfrage sichtbar sind. Das mentale Modell ist einfach: jedes SELECT, UPDATE und DELETE wird automatisch durch Policies gefiltert — so wie jede Anfrage durch Authentifizierungs-Middleware gefiltert wird.

Das „Zeilen“-Element ist wichtig. RLS schützt nicht alles:

  • Es verbirgt nicht automatisch einzelne Spalten (verwende dafür Views oder Spaltenberechtigungen).
  • Es macht unsichere Funktionen nicht sicher (eine Funktion kann Daten leaken, wenn sie mit erhöhten Rechten läuft).
  • Es validiert keine Geschäftsregeln (z. B. „nur Eigentümer dürfen Abrechnungseinstellungen ändern“).

Ein konkretes Beispiel: Du fügst einen Endpunkt hinzu, der Projekte mit einem Join zu Rechnungen für ein Dashboard auflistet. Bei reiner App-Authorisierung ist es leicht, projects nach Tenant zu filtern, aber invoices zu vergessen oder auf einen Schlüssel zu joinen, der Tenants kreuzt. Mit RLS können beide Tabellen Mandantenisolation durchsetzen, sodass die Abfrage sicher fehlschlägt, statt Daten zu leaken.

Der Trade-off ist real. Du schreibst weniger wiederholten Autorisierungscode und reduzierst die Anzahl der Orte, die leaken können. Aber du übernimmst auch neue Arbeit: Policies müssen sorgfältig entworfen, früh getestet werden, und du musst akzeptieren, dass eine Policy eine Abfrage blockieren kann, von der du dachtest, sie würde funktionieren.

Wann RLS Autorisierung vereinfacht (und wann sie Schmerzen bringt)

RLS kann sich wie zusätzliche Arbeit anfühlen, bis deine App mehr als ein paar Endpunkte hat. Wenn du strikte Tenant-Grenzen und viele Abfragepfade hast (Listen, Suche, Exporte, Admin-Tools), bedeutet die Regel in der Datenbank, dass du nicht überall denselben Filter hinzufügen musst.

RLS passt gut, wenn die Regel langweilig und universell ist: „Ein Benutzer darf nur Zeilen seines Tenants sehen“ oder „Ein Benutzer darf nur Projekte sehen, bei denen er Mitglied ist.“ In solchen Szenarien reduzieren Policies Fehler, weil jedes SELECT, UPDATE und DELETE durch dasselbe Tor geht, selbst wenn später eine Abfrage hinzugefügt wird.

Es hilft auch in leseintensiven Apps, in denen die Filterlogik konsistent bleibt. Wenn deine API 15 verschiedene Wege hat, Rechnungen zu laden (nach Status, Datum, Kunde, Suche), lässt dich RLS aufhören, die Mandantenfilter überall neu zu implementieren, und du kannst dich auf das Feature konzentrieren.

RLS macht Probleme, wenn die Regeln nicht zeilenbasiert sind. Feldbasierte Regeln wie „du darfst Gehalt sehen, aber nicht Bonus“ oder „maskiere diese Spalte, außer du bist HR“ führen oft zu umständlichem SQL und schwer wartbaren Ausnahmen.

Es passt auch schlecht zu schweren Reports, die wirklich breiten Zugriff brauchen. Teams schaffen dann oft Bypass-Rollen für „nur diesen Job“, und dort häufen sich Fehler.

Bevor du dich festlegst: Entscheide, ob die Datenbank das finale Gatekeeper sein soll. Wenn ja, plane für die Disziplin: teste Datenbankverhalten (nicht nur API-Antworten), behandle Migrationen als Sicherheitsänderungen, vermeide schnelle Ausnahmen, entscheide, wie Background-Jobs sich authentifizieren, und halte Policies klein und wiederholbar.

Wenn du Tools benutzt, die Backends generieren, beschleunigt das die Lieferung, aber es nimmt nicht die Notwendigkeit klarer Rollen, Tests und eines einfachen Tenant-Modells weg. (Zum Beispiel nutzt Koder.ai Go und PostgreSQL für generierte Backends; du solltest RLS bewusst entwerfen und nicht „später drüberstreuen“.)

Datenmodell-Grundlagen, die RLS-Policies handhabbar machen

RLS ist am einfachsten, wenn dein Schema bereits klar aussagt, wem was gehört. Wenn du mit einem vagen Modell startest und versuchst, es in Policies zu „reparieren“, bekommst du meist langsame Abfragen und verwirrende Bugs.

Setze einen Tenant-Key überall da, wo er hingehört

Wähle einen Tenant-Key (z. B. org_id) und verwende ihn konsistent. Die meisten tenant-eigenen Tabellen sollten ihn haben, selbst wenn sie eine andere Tabelle referenzieren, die ihn ebenfalls hat. Das vermeidet Joins in Policies und hält USING-Prüfungen einfach.

Eine praktische Regel: Wenn eine Zeile verschwinden sollte, wenn ein Kunde kündigt, braucht sie wahrscheinlich org_id.

Modeliere Mitgliedschaften explizit

RLS-Policies beantworten in der Regel eine Frage: „Ist dieser Benutzer Mitglied dieser Org und was darf er tun?“ Das lässt sich schwer aus Ad-hoc-Spalten ableiten.

Halte die Kerntabellen klein und übersichtlich:

  • users (eine Zeile pro Person)
  • orgs (eine Zeile pro Tenant)
  • org_memberships (user_id, org_id, role, status)
  • optional: project_memberships für per-Projekt-Zugriff

Damit können Policies die Mitgliedschaft mit einem indexierten Einzel-Lookup prüfen.

Trenne geteilte Referenzdaten von tenant-eigenen Daten

Nicht alles braucht org_id. Referenztabellen wie Länder, Produktkategorien oder Pläne werden oft von allen Tenants geteilt. Mach sie für die meisten Rollen schreibgeschützt und binde sie nicht an einen einzelnen Tenant.

Tenant-eigene Daten (Projekte, Rechnungen, Tickets) sollten vermeiden, tenant-spezifische Details über geteilte Tabellen hereinzuziehen. Halte geteilte Tabellen minimal und stabil.

FKs, Cascades und Indexierung

Fremdschlüssel funktionieren weiterhin mit RLS, aber Deletes können überraschen, wenn die löschende Rolle abhängige Zeilen nicht „sehen“ kann. Plane Cascades sorgfältig und teste reale Löschflows.

Indexiere die Spalten, nach denen deine Policies filtern, besonders org_id und Membership-Keys. Eine Policy wie WHERE org_id = ... sollte bei Millionen Zeilen nicht zu einem Full-Table-Scan werden.

Wie RLS-Policies funktionieren — ohne das Gruselige

RLS ist ein pro-Tabelle-Schalter. Sobald sie aktiviert ist, vertraut PostgreSQL nicht mehr darauf, dass dein App-Code den Tenant-Filter immer setzt. Jedes SELECT, UPDATE und DELETE wird durch Policies gefiltert, und INSERT/UPDATE werden durch WITH CHECK validiert.

Die größte gedankliche Umstellung: Mit eingeschalteter RLS können Abfragen, die früher Daten zurückgaben, plötzlich null Zeilen liefern, ohne Fehler. Das ist PostgreSQLs Zugriffskontrolle.

Was Policies tatsächlich tun

Policies sind kleine Regeln, die an eine Tabelle gehängt werden. Sie nutzen zwei Prüfungen:

  • USING ist der Lese-Filter. Wenn eine Zeile USING nicht erfüllt, ist sie für SELECT unsichtbar und kann nicht Ziel von UPDATE oder DELETE sein.
  • WITH CHECK ist das Schreib-Tor. Es entscheidet, welche neuen oder geänderten Zeilen bei INSERT oder UPDATE erlaubt sind.

Ein übliches SaaS-Muster: USING stellt sicher, dass du nur Zeilen deines Tenants siehst; WITH CHECK stellt sicher, dass du nicht durch Raten einer tenant_id Zeilen in einen anderen Tenant einfügst.

PERMISSIVE vs RESTRICTIVE in einem Satz

Wenn du später weitere Policies hinzufügst, ist das wichtig:

  • PERMISSIVE (Standard): Eine Zeile ist erlaubt, wenn irgendeine Policy sie erlaubt.
  • RESTRICTIVE: Eine Zeile ist nur erlaubt, wenn alle restriktiven Policies sie erlauben (zusätzlich zum permissiven Verhalten).

Wenn du Regeln schichtest wie Tenant-Match plus Rollenprüfungen plus Projektmitgliedschaft, können restrictive Policies die Absicht klarer machen, aber sie erleichtern auch das Ausschließen, wenn du eine Bedingung vergisst.

Wie Postgres weiß, wer der Benutzer ist

RLS braucht einen verlässlichen „Wer ruft an“-Wert. Gängige Optionen:

  • Eine Session-Variable pro Anfrage (z. B. app.user_id und app.tenant_id).
  • JWT-Claims, die von der API-Schicht in Session-Settings gemappt werden.
  • Rollenwechsel (SET ROLE ... pro Anfrage), das funktioniert, bringt aber operativen Aufwand mit sich.

Wähle einen Ansatz und wende ihn überall an. Das Mischen von Identitätsquellen über Services hinweg ist ein schneller Weg zu verwirrenden Bugs.

Policies benennen, damit du später debuggen kannst

Nutze eine vorhersehbare Konvention, damit Schema-Dumps und Logs lesbar bleiben. Zum Beispiel: {table}__{action}__{rule}, wie projects__select__tenant_match.

Schritt für Schritt: RLS an einer Tabelle hinzufügen und beweisen, dass es funktioniert

RLS auf typische SaaS-Daten anwenden
Baue ein mandantenbasiertes CRM und wende dieselben RLS-Muster auf alle Tabellen an.

Wenn du neu bei RLS bist, fange mit einer Tabelle und einem kleinen Proof an. Das Ziel ist nicht perfekte Abdeckung, sondern dass die Datenbank bei einem App-Bug Cross-Tenant-Zugriffe verweigert.

Eine kleine Tabelle zum Üben

Nehmen wir eine einfache projects-Tabelle. Füge zuerst tenant_id so hinzu, dass Schreibvorgänge nicht brechen.

ALTER TABLE projects ADD COLUMN tenant_id uuid;

-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;

ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;

Trenne als Nächstes Besitz von Zugriff. Ein übliches Muster ist: eine Rolle besitzt die Tabellen (app_owner), eine andere Rolle wird von der API genutzt (app_user). Die API-Rolle sollte nicht Tabellen-Owner sein, sonst kann sie Policies umgehen.

ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;

Entscheide nun, wie die Anfrage Postgres mitteilt, welchen Tenant sie bedient. Ein einfacher Ansatz ist ein request-scoped Setting. Deine App setzt es direkt nach dem Öffnen einer Transaktion.

-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);

Aktiviere RLS und starte mit Lesezugriff.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);

Beweise, dass es funktioniert, indem du zwei verschiedene Tenants ausprobierst und überprüfst, dass sich die Zeilenanzahl ändert.

Schreibregeln hinzufügen (WITH CHECK)

Lese-Policies schützen nicht vor Schreibzugriffen. Füge WITH CHECK hinzu, damit Inserts und Updates nicht heimlich Zeilen in den falschen Tenant schmuggeln.

CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);

Eine schnelle Methode, Verhalten (inklusive Fehlern) zu verifizieren, ist ein kleines SQL-Skript, das du nach jeder Migration neu laufen kannst:

  • BEGIN; SET LOCAL ROLE app_user;
  • SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;
  • INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (sollte fehlschlagen)
  • UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (sollte fehlschlagen)
  • ROLLBACK;

Wenn du dieses Skript reproduzierbar laufen lassen kannst und immer dieselben Ergebnisse bekommst, hast du eine verlässliche Basis, bevor du RLS auf andere Tabellen ausweitest.

Policy-Muster, die du in den meisten SaaS-Apps wiederverwenden wirst

Die meisten Teams setzen RLS ein, nachdem sie es satt haben, dieselben Autorisierungsprüfungen in jeder Abfrage zu wiederholen. Die gute Nachricht: die nötigen Policy-Formen sind meist konsistent.

Owner-Zeilen vs Membership-Zeilen

Manche Tabellen gehören natürlich einer einzelnen Person (Notizen, API-Tokens). Andere gehören einem Tenant, wobei der Zugriff von der Mitgliedschaft abhängt. Behandle diese Muster unterschiedlich.

Bei owner-only Daten prüft eine Policy oft created_by = app_user_id(). Bei Tenant-Daten prüft die Policy meist, ob der Benutzer eine Membership-Zeile für die Org hat.

Eine praktische Methode, Policies lesbar zu halten, ist Identity in kleinen SQL-Helpern zu zentralisieren und wiederzuverwenden:

-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
  select current_setting('app.user_id', true)::uuid
$$;

create function app_is_admin() returns boolean
language sql stable as $$
  select current_setting('app.is_admin', true) = 'true'
$$;

Lese- und Schreibrichtlinien trennen

Lesen ist oft breiter als Schreiben. Zum Beispiel kann jeder Org-Member SELECT auf Projekte, aber nur Editoren UPDATE und nur Owner DELETE.

Halte es explizit: eine Policy für SELECT (Mitgliedschaft), eine Policy für INSERT/UPDATE mit WITH CHECK (Rolle) und eine für DELETE (oft strenger als Update).

Admin-Override ohne RLS zu deaktivieren

Vermeide „RLS für Admins ausschalten“. Füge stattdessen eine Ausstiegsbedingung in Policies ein, z. B. app_is_admin(), damit du nicht versehentlich einem geteilten Service-Account Vollzugriff gibst.

Soft Deletes und Statusflags

Wenn du deleted_at oder status nutzt, bau das in die SELECT-Policy ein (deleted_at is null). Sonst kann jemand Zeilen „wiederbeleben“, die die App als endgültig angesehen hat.

UPSERT: WITH CHECK freundlich halten

INSERT ... ON CONFLICT DO UPDATE muss WITH CHECK für die Zeile nach dem Schreibvorgang erfüllen. Wenn deine Policy created_by = app_user_id() verlangt, stelle sicher, dass dein Upsert created_by beim Insert setzt und es beim Update nicht überschreibt.

Wenn du Backend-Code generierst, sind diese Muster gute Templates, damit neue Tabellen mit sicheren Defaults starten und nicht mit einer leeren Seite.

Häufige RLS-Fußangeln, die Debugging zur Qual machen

RLS ist großartig, bis ein kleines Detail es so aussehen lässt, als würde PostgreSQL „zufällig“ Daten verbergen oder zeigen. Die untenstehenden Fehler kosten am meisten Zeit.

Fußangeln, die stille Tenant-Leaks verursachen

Die erste Falle ist, WITH CHECK bei Insert/Update zu vergessen. USING steuert, was du sehen kannst, nicht was du anlegen darfst. Ohne WITH CHECK kann ein App-Bug eine Zeile in den falschen Tenant schreiben, und du merkst es vielleicht nicht, weil derselbe Benutzer die falsche Zeile nicht lesen kann.

Ein weiterer häufiger Leak sind „leaky joins“. Du filterst projects korrekt, joinst dann aber zu invoices, notes oder files, die nicht gleich geschützt sind. Die Lösung ist streng, aber simpel: Jede Tabelle, die Tenant-Daten offenbaren könnte, braucht ihre eigene Policy, und Views sollten nicht darauf vertrauen, dass nur eine Tabelle sicher ist.

Häufige Fehlerbilder zeigen sich früh:

  • Eine Lese-Policy existiert, aber eine Schreib-Policy fehlt WITH CHECK.
  • Eine Policy-Bedingung nutzt einen Join zu einer Tabelle, die nicht geschützt ist.
  • Zugriff wird in einer View erzwungen, aber die zugrundeliegende Tabelle ist noch offen.
  • Du verlässt dich darauf, dass „die App immer tenant_id setzt“, und ein Background-Job vergisst es.
  • Du testest mit einer Superuser-Rolle und siehst nie das reale Verhalten.

Fußangeln, die Verhalten verwirrend machen

Policies, die dieselbe Tabelle referenzieren (direkt oder über eine View), können Rekursionsüberraschungen erzeugen. Eine Policy könnte Mitgliedschaft prüfen, indem sie eine View abfragt, die wiederum die geschützte Tabelle liest — das führt zu Fehlern, langsamen Abfragen oder einer Policy, die nie matcht.

Die Rollen-Konfiguration ist eine weitere Verwirrungsquelle. Tabellen-Owner und erhöhte Rollen können RLS umgehen, sodass deine Tests passen, während echte Nutzer scheitern (oder andersherum). Teste immer mit der gleichen Low-Privilege-Rolle, die deine App nutzt.

Sei vorsichtig mit SECURITY DEFINER-Funktionen. Sie laufen mit den Rechten des Funktions-Owners, daher kann ein Helper wie current_tenant_id() in Ordnung sein, aber eine „convenience“-Funktion, die Daten liest, kann unbeabsichtigt tenant-übergreifend lesen, sofern sie nicht so entworfen ist, dass sie RLS respektiert.

Setze außerdem einen sicheren search_path innerhalb von security-definer-Funktionen. Ansonsten kann die Funktion ein Objekt mit demselben Namen aus einem anderen Schema nutzen, und deine Policy-Logik zeigt abhängig vom Session-State auf das falsche Objekt.

RLS debuggen: praktische Wege, um zu sehen, was passiert

Mit verdienten Credits weiterbauen
Erstelle Inhalte über deinen Build und verdiene Credits, um weiter an deiner App zu arbeiten.

RLS-Bugs entstehen meist durch fehlenden Kontext, nicht durch „schlechtes SQL“. Eine Policy kann auf dem Papier korrekt sein und trotzdem fehlschlagen, weil die Session-Rolle anders ist als gedacht oder weil die Anfrage nie die Tenant- und User-Werte gesetzt hat, auf die sich die Policy stützt.

Ein verlässlicher Weg, einen Produktions-Report zu reproduzieren, ist, die gleiche Session-Konfiguration lokal zu spiegeln und die exakte Abfrage laufen zu lassen. Das bedeutet in der Regel:

  • SET ROLE app_user; (oder die echte API-Rolle)
  • SELECT set_config('app.tenant_id', 't_123', true); und SELECT set_config('app.user_id', 'u_456', true);
  • Führe dieselbe SQL aus, die deine App ausgeführt hat (inkl. Parameter)
  • Bestätige, was Postgres sieht: SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);

Wenn du unsicher bist, welche Policy angewendet wird, sieh im Katalog nach statt zu raten. pg_policies zeigt jede Policy, den Befehl und die USING- und WITH CHECK-Ausdrücke. Kombiniere das mit pg_class, um zu bestätigen, dass RLS auf der Tabelle aktiviert ist und nicht umgangen wird.

Performance-Probleme können wie Auth-Probleme aussehen. Eine Policy, die eine Membership-Tabelle joinet oder eine Funktion aufruft, kann korrekt, aber langsam werden, sobald die Tabelle wächst. Nutze EXPLAIN (ANALYZE, BUFFERS) auf der reproduzierten Abfrage und achte auf sequenzielle Scans, unerwartete Nested Loops oder Filter, die spät angewendet werden. Fehlende Indexe auf (tenant_id, user_id) und Membership-Tabellen sind häufige Ursachen.

Es hilft außerdem, drei Werte pro Request auf App-Ebene zu loggen: Tenant-ID, User-ID und die Datenbankrolle, die für die Anfrage benutzt wurde. Wenn diese nicht mit dem übereinstimmen, was du zu setzen glaubtest, verhält sich RLS „falsch“, weil die Eingaben falsch sind.

Für Tests behalte ein paar Seed-Tenants und mache Fehler explizit. Eine kleine Test-Suite enthält üblicherweise: „Tenant A kann Tenant B nicht lesen“, „Benutzer ohne Membership sieht das Projekt nicht“, „Owner kann updaten, Viewer nicht“, „Insert wird geblockt, wenn tenant_id nicht zum Kontext passt“ und „Admin-Override gilt nur, wo beabsichtigt“.

Schnelle Pre-Release-Checkliste für RLS in Produktion

Behandle RLS wie einen Sicherheitsgurt, nicht wie einen Feature-Schalter. Kleine Versäumnisse führen zu „alle sehen alle Daten“ oder „alles liefert null Zeilen".

Datenmodell und Policy-Form

Stelle sicher, dass dein Tabellendesign und die Policy-Regeln zu deinem Tenant-Modell passen.

  • Jede tenant-eigene Tabelle sollte einen klaren Tenant-Key haben (normalerweise tenant_id). Wenn nicht, notiere warum (z. B. globale Referenztabellen).
  • Aktiviere RLS auf jeder tenant-eigenen Tabelle, nicht nur auf den „Haupt“-Tabellen. Wenn manche Pfade nie umgehen dürfen, ziehe FORCE ROW LEVEL SECURITY für diese Tabellen in Betracht.
  • Trenne Lese- und Schreibregeln. Lesen verwendet USING. Schreiben muss WITH CHECK enthalten, damit Inserts und Updates keine Zeile in einen anderen Tenant verschieben können.
  • Halte Policy-Prädikate indexfreundlich. Wenn Policies nach tenant_id filtern oder über Membership-Tabellen joinen, füge passende Indexe hinzu.

Ein einfaches Sanity-Szenario: Ein Benutzer aus Tenant A kann seine eigenen Rechnungen lesen, kann eine Rechnung nur für Tenant A einfügen und kann eine Rechnung nicht so updaten, dass tenant_id geändert wird.

Rollen, Performance und Tests

RLS ist nur so stark wie die Rollen, die deine App nutzt.

  • Stelle sicher, dass die App nie als Superuser, Tabellen-Owner oder irgendeine Rolle mit bypassrls verbindet.
  • Führe ein paar reale Abfragen mit produktionsähnlichem Datenvolumen aus und prüfe die Query-Pläne.
  • Füge automatisierte Negative-Tests hinzu, die belegen, dass Cross-Tenant-Zugriff fehlschlägt.

Beispiel: Multi-Tenant-Projekt-App mit mitgliedschaftsbasierendem Zugriff

Frühe undichte Joins verhindern
Erstelle ein Reporting und validiere, dass Joins mit RLS mandantensicher bleiben.

Stell dir eine B2B-App vor, in der Firmen (Orgs) Projekte haben und Projekte Tasks. Benutzer können mehreren Orgs angehören, und ein Benutzer kann Mitglied einiger Projekte, aber nicht aller sein. Das ist ein guter Anwendungsfall für RLS, weil die Datenbank Mandantenisolation erzwingen kann, selbst wenn ein API-Endpunkt einen Filter vergisst.

Ein einfaches Modell ist: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Das org_id auf tasks ist absichtlich da. Es hält Policies einfach und reduziert Überraschungen bei Joins.

Ein klassischer Leak passiert, wenn Tasks nur project_id haben und deine Policy Zugriff über einen Join zu projects prüft. Ein Fehler (eine zu permissive Policy auf projects, ein Join, der eine Bedingung fallen lässt, oder eine View, die den Kontext ändert) kann Tasks eines anderen Orgs offenlegen.

Ein sicherer Migrationspfad vermeidet Produktionsausfälle:

  • Deploye zuerst Schema-Änderungen (füge org_id zu tasks hinzu, ergänze Membership-Tabellen).
  • Backfille tasks.org_id aus projects.org_id, dann NOT NULL setzen.
  • Füge Policies hinzu, aber lass RLS in Staging deaktiviert, während du testest.
  • Aktiviere RLS, setzte es durch (FORCE ROW LEVEL SECURITY) und entferne erst dann alte App-seitige Filter.

Support-Zugriff lässt sich meist am besten über eine enge Break-Glass-Rolle lösen, nicht dadurch, RLS zu deaktivieren. Halte sie getrennt von normalen Support-Accounts und mache ihre Nutzung explizit.

Dokumentiere die Regeln, damit Policies nicht verwehen: welche Session-Variablen gesetzt sein müssen (user_id, org_id), welche Tabellen org_id tragen müssen, was „Member“ bedeutet und einige SQL-Beispiele, die 0 Zeilen zurückgeben sollten, wenn sie als falscher Org ausgeführt werden.

Nächste Schritte: RLS sicher ausrollen und wartbar halten

RLS ist am einfachsten zu leben, wenn du sie wie eine Produktänderung behandelst. Rolle sie in kleinen Schritten aus, beweise das Verhalten mit Tests und halte fest, warum jede Policy existiert.

Ein Rollout-Plan, der oft funktioniert:

  • Starte mit einer Tabelle, die klare Tenant-Eigentümerschaft hat (z. B. projects) und schließe sie ab.
  • Füge Tests hinzu, die erlaubte und blockierte Lese- und Schreiboperationen für einige Rollen abdecken (Owner, Member, Outsider).
  • Erweitere in Chargen (ein Feature-Bereich nach dem anderen), die du in einer einzelnen Session debuggen kannst.
  • Überwache Berechtigungsfehler während des Rollouts und deploye in einem risikoarmen Zeitfenster.

Nachdem die erste Tabelle stabil ist, mache Policy-Änderungen deliberate. Füge einen Policy-Review-Schritt zu Migrationen hinzu und schreibe eine kurze Notiz zur Absicht (wer sollte was und warum zugreifen) plus den passenden Test. Das verhindert „füge einfach noch ein OR hinzu“-Policies, die sich langsam zu einem Loch aufweiten.

Wenn du schnell vorankommst, können Tools wie Koder.ai (koder.ai) dir helfen, per Chat einen Go + PostgreSQL Startpunkt zu generieren; layer dann RLS-Policies und Tests mit derselben Disziplin wie bei einem handgebauten Backend oben drauf.

Zum Schluss: Halte Sicherheitsnetze während des Rollouts. Mach Snapshots vor Policy-Migrationen, übe Rollbacks, bis es langweilig ist, und behalte einen kleinen Break-Glass-Pfad für den Support, der RLS nicht global deaktiviert.

FAQ

Welches Sicherheitsproblem löst RLS in einer SaaS-App tatsächlich?

RLS lässt PostgreSQL durchsetzen, welche Zeilen für eine Anfrage sichtbar oder beschreibbar sind. So hängt die Mandantenisolation nicht davon ab, dass jeder Endpunkt korrekt WHERE tenant_id = ... setzt. Der Hauptvorteil ist die Verringerung von „ein vergessener Filter“-Bugs, wenn die App wächst und Abfragen sich vermehren.

Wann lohnt sich RLS trotz zusätzlicher Komplexität?

Es lohnt sich, wenn Zugriffsregeln konsistent und zeilenbasiert sind — etwa Mandantenisolation oder zugriffsbasierte Mitgliedschaft — und wenn viele Abfragepfade existieren (Suche, Exporte, Admin-Oberflächen, Hintergrundjobs). Es ist meist nicht sinnvoll, wenn die Regeln überwiegend feldbasiert, sehr ausnahmegetrieben oder von großflächigen Reports dominiert sind, die Tenant-übergreifend lesen müssen.

Wovor schützt mich RLS NICHT?

RLS schützt vor allem Zeilensichtbarkeit und grundlegende Schreibregeln. Spaltenprivatsphäre braucht meist Views und Spaltenrechte, und komplexe Geschäftsregeln (z. B. wer Rechnungen freigeben darf) gehören weiterhin in die Anwendung oder in sorgfältig entworfene Datenbankconstraints.

Wie starte ich am sichersten mit RLS, wenn ich neu darin bin?

Lege eine niederprivilegierte Rolle für die API an (nicht der Tabellen-Owner), aktiviere RLS und füge zunächst eine SELECT-Policy sowie eine INSERT/UPDATE-Policy mit WITH CHECK hinzu. Setze einen request-gebundenen Session-Wert (z. B. app.current_tenant) und prüfe, dass ein Wechsel dieses Werts verändert, welche Zeilen sichtbar und beschreibbar sind.

Wie sollte meine App Postgres mitteilen, welcher Tenant und welcher Benutzer die Anfrage macht?

Ein übliches Muster ist, pro Anfrage Session-Variablen zu setzen, z. B. app.tenant_id und app.user_id, direkt zu Beginn der Transaktion. Entscheidend ist Konsistenz: alle Codepfade (Web-Requests, Jobs, Skripte) müssen dieselben Werte setzen, sonst erhältst du das verwirrende Ergebnis „0 Zeilen“.

Was ist der Unterschied zwischen USING und WITH CHECK in einer RLS-Policy?

USING steuert, welche bestehenden Zeilen sichtbar und zielbar sind (für SELECT, UPDATE, DELETE). WITH CHECK steuert, welche neuen oder geänderten Zeilen bei INSERT und UPDATE erlaubt sind — so verhinderst du, dass jemand durch eine fehlerhafte Anfrage eine Zeile in einen anderen Tenant schreibt.

Warum sagen alle immer „vergiss WITH CHECK nicht“?

Weil USING nur die Sichtbarkeit regelt: ohne WITH CHECK kann ein fehlerhafter Endpunkt trotzdem Zeilen in den falschen Tenant schreiben, und derselbe Benutzer kann die falsche Zeile dann nicht lesen — das fällt leicht durch. Kombiniere Lese-Regeln immer mit passenden WITH CHECK-Regeln für Schreibvorgänge.

Wie sollte ich mein Schema strukturieren, damit RLS-Policies einfach und performant bleiben?

Vermeide Abfragen und Views, die Joins in Policies erzwingen. Setze den Tenant-Key (z. B. org_id) direkt auf tenant-eigene Tabellen und füge explizite Membership-Tabellen (org_memberships, optional project_memberships) hinzu, damit Policies mit einem indexierten Lookup auskommen.

Wie debugge ich „RLS versteckt meine Daten“ ohne zu raten?

Repliziere dieselbe Session-Umgebung wie die App: setze dieselbe Rolle und dieselben Session-Settings und führe die exakte SQL-Abfrage aus. Prüfe dann, ob RLS aktiviert ist und sieh dir pg_policies an, um zu sehen, welche USING- und WITH CHECK-Ausdrücke angewendet werden. Häufig liegt das Problem eher in fehlender Identitätskontext-Setzung als in schlechtem SQL.

Wenn ich mein Backend generiere (z. B. mit Koder.ai), muss ich RLS dann trotzdem sorgfältig planen?

Ja — generierter Code ist ein guter Start, ersetzt aber nicht die Sicherheitsplanung. Wenn du z. B. mit Koder.ai ein Go + PostgreSQL Backend generierst, musst du trotzdem dein Mandantenmodell definieren, Session-Identität konsistent setzen und Policies sowie Tests bewusst hinzufügen, damit neue Tabellen nicht ohne Schutz live gehen.

Related posts