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.

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:
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.
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“.)
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.
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.
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)project_memberships für per-Projekt-ZugriffDamit können Policies die Mitgliedschaft mit einem indexierten Einzel-Lookup prüfen.
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.
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.
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.
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.
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.
RLS braucht einen verlässlichen „Wer ruft an“-Wert. Gängige Optionen:
app.user_id und app.tenant_id).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.
Nutze eine vorhersehbare Konvention, damit Schema-Dumps und Logs lesbar bleiben. Zum Beispiel: {table}__{action}__{rule}, wie projects__select__tenant_match.
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.
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.
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.
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.
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'
$$;
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).
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.
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.
WITH CHECK freundlich haltenINSERT ... 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.
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.
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:
WITH CHECK.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-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);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“.
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".
Stelle sicher, dass dein Tabellendesign und die Policy-Regeln zu deinem Tenant-Modell passen.
tenant_id). Wenn nicht, notiere warum (z. B. globale Referenztabellen).FORCE ROW LEVEL SECURITY für diese Tabellen in Betracht.USING. Schreiben muss WITH CHECK enthalten, damit Inserts und Updates keine Zeile in einen anderen Tenant verschieben können.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.
RLS ist nur so stark wie die Rollen, die deine App nutzt.
bypassrls verbindet.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:
org_id zu tasks hinzu, ergänze Membership-Tabellen).tasks.org_id aus projects.org_id, dann NOT NULL setzen.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.
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:
projects) und schließe sie ab.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.
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.
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.
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.
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.
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“.
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.
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.
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.
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.
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.