Zum Inhalt springen

Supabase Multi-Tenant-RLS: Nutzer in zwei Orgs, Datenleck

Supabase

Als Markdown anzeigenIn Claude öffnenIn ChatGPT öffnenIn Perplexity öffnen

Die Frage wird ebenfalls kopiert. Falls der Chat leer öffnet, fügen Sie sie ein.

Kurz gesagt

Eine Policy wie auth_in_org(organization_id) fragt: “Ist der Aufrufer Mitglied der Organisation, der diese Zeile gehört?” Für einen Nutzer, der zu Org A und Org B gehört, lautet die Antwort in beiden Fällen ja. In meiner Probe gab RLS allein so einem Nutzer Lese- und Schreibzugriff auf ein Dokument der anderen Organisation. Nachdem ich die Organisation der aktuellen Anfrage in die Abfrage aufgenommen hatte, kamen 0 Zeilen zurück.

Gefunden habe ich das beim Härten der Plattform für Bauunterlagen, einer mandantenfähigen Supabase-App. Auf jeder Mandantentabelle war Row Level Security aktiviert und erzwungen, es sah also fertig aus.

Was sah sicher aus?

Jede Policy hatte dieselbe Form: Die organization_id der Zeile muss eine Organisation sein, in der der Aufrufer Mitglied ist. Deshalb schlugen ein paar Server Actions ein Dokument allein über die ID nach. Die Überlegung dahinter: “RLS filtert das ohnehin.”

const { data } = await supabase
  .from('documents')
  .select('*')
  .eq('document_id', id)
  .single()

Bei einem Nutzer mit nur einer Mitgliedschaft funktioniert das, und ein Test mit so einem Nutzer besteht.

Warum lässt RLS die andere Organisation durch?

Die Policy ist ein Mitgliedschaftstest. Postgres wertet sie für jede Zeile aus, wie die Dokumentation zu Row Security beschreibt, und weiß nicht, als welche Organisation die Anfrage gerade handelt, denn das lebt in der App: in der URL, in einem Cookie oder im Org-Umschalter. Für einen Nutzer, der in beiden Organisationen Mitglied ist, besteht jede Zeile beider Organisationen.

Die Org einer Anfrage steht nicht in der Policy. Ein Nutzer, Mitglied von Org A und Org B, arbeitet in Org A. Nur die RLS-Policy: Anfrage in Org A → Mitglied der Org? → Ja, A oder B → Org-B-Zeile kommt; RLS plus die Org dieser Anfrage: Anfrage in Org A → Mitglied der Org? → Zeile in Org A? → 0 Zeilen aus Org B.
Meine Probe an einem Testnutzer in zwei Orgs, September 2026: RLS allein gab Lese- und Schreibzugriff auf das Dokument von Org B; mit der Org der Anfrage kamen 0 Zeilen zurück.

Der organization_id-Filter in der App ist also keine zweite Schicht über RLS. Für diesen Nutzer ist er die einzige Kontrolle.

Wenn Sie schon in diesen Helfern sind, prüfen Sie, ob sie organizations.is_active beachten. Mitgliedschafts-Helfer tun das oft nicht, und dann behält eine deaktivierte Organisation auf RLS-Ebene Lese- und Schreibzugriff. Liegt die einzige is_active-Prüfung im Tenant-Resolver der App, umgeht jede Abfrage, die den Resolver auslässt, auch die Deaktivierung.

Was hat es behoben?

Die Organisation der Anfrage wurde zum Pflichtargument jeder Service-Funktion, die Mandantendaten liest oder schreibt. Eine Aufrufstelle kann sie nicht vergessen, weil die Funktion ohne sie nicht kompiliert.

export async function getDocument(orgId: string, id: string) {
  return supabase
    .from('documents')
    .select('*')
    .eq('organization_id', orgId)
    .eq('document_id', id)
    .single()
}

Aus dem Review kamen zwei weitere Regeln:

  • Nehmen Sie die Organisation nie aus der Zeile, die Sie gleich schreiben. update ... where id = $1 and organization_id = <that row's own organization_id> ist immer wahr und schützt damit nichts.
  • Ein zusammengesetzter Fremdschlüssel auf (id, organization_id) lässt die Datenbank eine Verknüpfung zwischen Zeilen verschiedener Organisationen ablehnen. Prüfen Sie jede Kindtabelle: Man baut ihn leicht in drei ein und vergisst die vierte.

Wie prüfen Sie Ihre eigene App?

Legen Sie einen Nutzer mit aktiver Mitgliedschaft in zwei Organisationen an. Handeln Sie dann im SQL-Editor einer lokalen oder Staging-Kopie als dieser Nutzer und fragen Sie nach einer Zeile der anderen Organisation:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';

-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';

-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
  and organization_id = '<org A id>';
rollback;

Wiederholen Sie es mit einem update und noch einmal, nachdem Sie die zweite Organisation auf inaktiv gesetzt haben. Liefert die erste Abfrage eine Zeile, sehen Sie sich jede Stelle an, die Mandantendaten allein über die ID abfragt.

Wie schlimm ist es?

Schätzen Sie es ehrlich ein. Der Angreifer braucht eine echte Mitgliedschaft in beiden Organisationen, es geht also um Integrität und Audit-Spuren: Jemand mit Zugang zu zwei Kunden bearbeitet den falschen, und das Log sagt, es war erlaubt. Es ist kein Weg für Fremde, alle Mandanten zu lesen.

Die allgemeine Lehre ist die aus meiner Art, Supabase RLS zu testen: Testen Sie, was ein echter Nutzer mit echten Datenformen tun kann, nicht nur, was jede Policy sagt. Drei verwandte Notizen: eine Rolle aus den Metadaten der Registrierung, ein NULL, das eine Berechtigungsprüfung übersprang und welche Schemas die Data API ausliefert. Der Zwei-Firmen-Test ist der erste Punkt meiner Sicherheits-Checkliste für Lovable-Apps. Für die Policies, Grants und Einstellungen drumherum prüft der kostenlose Supabase Sicherheitscheck 46 Regeln der OWASP Top 10:2025; ob eine Policy einen Nutzer aus zwei Organisationen durchlässt, ist eine Frage der Datenform, die nur die Abfrage oben beantwortet.

© 2026 Denys Zaichenko. Originalartikel: https://madsalt.tech/de/notes/supabase-rls-user-in-two-organizations/