# Supabase RLS testen: lokales CRM-Beispiel

Stand: 28. September 2026. Copyright 2026 AKAU.Solutions UG (haftungsbeschränkt).
Code und Testmaterial: [MIT-Lizenz](LICENSE.txt).

Zum [Leitfaden](https://obhut.io/blog/supabase-rls-testen.html).

## Was tatsächlich getestet wird

Eine isolierte PostgreSQL-Datenbank und ein echter PostgREST-Prozess. Die Prüfungen senden
HTTP-Anfragen mit signierten, nur für diesen Lauf erzeugten Test-JWTs. PostgREST prüft die
Signatur und wechselt in die Datenbankrolle `authenticated` beziehungsweise `anon`.
Die CRM-Rollen `employee` und `partner` stammen aus einer schreibgeschützten Mitgliedschaftstabelle.

**Kein gehostetes Supabase-Projekt und kein vollständiger Supabase-Stack.** Supabase Auth,
Anmeldung, MFA, Token-Erneuerung, API-Key-Gateway, Storage, Realtime, Edge Functions und die echte
Funktion `auth.uid()` werden nicht getestet. `private.subject()` ist eine ausdrücklich lokale
Hilfsfunktion, die den von PostgREST geprüften `sub`-Claim liest. Sie ersetzt keine Anmeldung.

Die Dateien sind eine Lern- und Testvorlage, keine Migration für ein vorhandenes Projekt.
Führen Sie `fixture.sql` niemals gegen eine bestehende Datenbank aus. Das Programm akzeptiert
bewusst keine externe Datenbank- oder API-Adresse.

## Dateien

- [fixture.sql](fixture.sql): Tabellen, Grants, RLS-Regeln, synthetische Daten und Diagnosefunktion.
- [run.py](run.py): Aufbau, 43 HTTP-Prüfungen, Ergebnisbericht und Aufräumen.
- [results-2026-09-28.json](results-2026-09-28.json): tatsächlich ausgeführter Prüflauf mit Versionen,
  Antworten und SHA-256-Werten der beiden Quelldateien. Keine Tokens oder Passwörter.
- [LICENSE.txt](LICENSE.txt): Nutzung und Anpassung unter MIT.

## Ausführen

Voraussetzungen: Python 3.10 oder neuer mit Standardbibliothek, Docker mit laufender Linux-Engine,
Zugriff auf deren Socket. Keine Python-Pakete, Supabase-Konten oder Produktabhängigkeiten nötig.
Der erste Download der beiden Images benötigt Netzwerkzugriff. Die Digests fixieren exakt die
getesteten Images; sie sind keine Empfehlung für die jeweils neueste Produktionsversion.

Speichern Sie `fixture.sql` und `run.py` in demselben neuen Verzeichnis. Lesen Sie beide Dateien
vor dem Ausführen. Laden Sie dann die öffentlichen Images:

```sh
docker pull postgres:18.6-trixie@sha256:86c951e05bf56c93d95d397747fb8820ac76cc3bedb78f43abd83eedbe3666ae
docker pull postgrest/postgrest:v14.14@sha256:d2009b5c9deffc210c8a5592698472fede14fd9f6ca89823c8474ca54d58c012
python3 run.py > results.json
```

Erfolg: Exit-Code 0 und `"passed": 43` in `results.json`. Jede Prüfung wird einmal ausgeführt;
fehlgeschlagene Assertions werden nicht wiederholt. Die Bereitschaft der zwei frisch gestarteten
Dienste wird vor den Tests anhand ihrer Antworten abgewartet. Die 60-Sekunden-Grenze ist lediglich
ein Startabbruch bei einer defekten Umgebung, kein Kriterium für die RLS-Ergebnisse.

Der Runner erzeugt einen eigenen Docker-Netzwerknamen und zwei Container mit dem Präfix
`obhut-rls-example-`. Die Datenbank erhält keinen veröffentlichten Host-Port und speichert
flüchtig im Container. Ausschließlich die API wird auf einem zufälligen Port an `127.0.0.1`
veröffentlicht. Zugangsdaten werden pro Lauf erzeugt. Bestehende Container, Datenbanken und Rollen
werden nicht verändert. Der Runner entfernt seine Container und sein Netzwerk im `finally`-Block.
Nach einem harten Prozessabbruch prüfen Sie in Docker die verbliebenen Ressourcen dieses Laufs,
bevor Sie genau diese entfernen.

## Modell und erwartete Rechte

Organisation Alpha hat Mitarbeiter `employee_a` und Partner `partner_a`; Beta entsprechend
`employee_b` und `partner_b`. Alpha besitzt `a1` und `a2`, Beta `b1`. Nur `a1` ist dem Alpha-Partner
zugeordnet. `b1` ist dem Beta-Partner zugeordnet. Ein weiterer angemeldeter Nutzer hat keine
Mitgliedschaft. Sämtliche Datensätze sind künstlich.

Mitarbeiter dürfen Kontakte ihrer Organisation lesen, anlegen, umbenennen und löschen.
Partner dürfen nur ihre zugeordneten Kontakte lesen. Niemand darf über diesen REST-Endpunkt
`org_id`, `partner_id` oder Mitgliedschaften ändern. `anon` hat keine Tabellenrechte.

`SELECT`, `INSERT`, `UPDATE` und `DELETE` besitzen getrennte Policies. Die `UPDATE`-Grants gelten
nur für `name`: Ein versuchter Organisationswechsel scheitert daher bereits am Spaltenrecht.
Beim Anlegen kann ein Mitarbeiter `partner_id` mitgeben; die Zugehörigkeit dieses Partners zur
Organisation wird dabei nicht geprüft. Die Leseregel verlangt weiterhin eine passende
Mitgliedschaft. Die Fixture enthält keinen vollständigen Ablauf für Partnerzuordnungen.
Ein `INSERT` in eine fremde Organisation scheitert dagegen an `WITH CHECK`. Diese beiden
Ablehnungen dürfen im Bericht nicht als derselbe Mechanismus ausgegeben werden.

Der Diagnose-RPC verwendet `SECURITY INVOKER`. Er belegt für die vier Mitgliedschaftsidentitäten und für `anon` die wirksame
Datenbankrolle, den Benutzerbezug, aktive RLS und das Fehlen von Superuser- und `BYPASSRLS`-Rechten.
Der Tabellenbesitzer wird nur für das Anlegen der Fixture und einen gezielten Mitgliedschaftsentzug
verwendet, niemals als erfolgreiche HTTP-Kontrolle.

## Wie die Gegenproben ausgewertet werden

- Fremde oder nicht zugewiesene Zeilen: HTTP 200 mit `[]` ist im Modell korrekt.
- Verbotene Änderung/Löschung einer unsichtbaren Zeile: ebenfalls 200 mit `[]`; danach bestätigt
  ein berechtigter Nutzer den unveränderten Datensatz.
- Verbotenes Anlegen: 403 mit PostgreSQL-Code `42501`; ein anschließender Abruf prüft, dass kein
  Datensatz entstanden ist. Ohne Anmeldung liefert die fehlende Berechtigung hier 401.
- Versuchter Wechsel von `org_id` oder `partner_id`: 403 durch Spaltengrants und Prüfung des
  unveränderten Zustands.
- Ungültige/abgelaufene JWTs: 401. Ein gültig signiertes Test-Token mit Rolle `postgres`: 403,
  weil der Authenticator diese Rolle nicht annehmen darf.
- Ein fremder Organisationsheader und zusätzliche `user_metadata` erweitern keine Rechte.
- Nach Entzug der Partner-Mitgliedschaft ergibt dasselbe noch gültige JWT keine Kontakte mehr.
  Das belegt die dynamische Mitgliedschaftsprüfung, keine allgemeine Sitzungssperre.

## Grenzen beim Übertragen auf Supabase

Nutzen Sie in einem getrennten Supabase-Testprojekt dessen echten Auth-Pfad, Testnutzer und
`auth.uid()`. Ersetzen Sie keine mitgelieferten Supabase-Rollen oder Auth-Funktionen mit dieser
Fixture. Prüfen Sie die tatsächlichen Schemafreigaben, Grants und Postgres-/PostgREST-Versionen.
Die Pfade in dieser lokalen API beginnen mit `/contacts`; ein Supabase-Data-API-Aufruf verwendet
typischerweise `/rest/v1/contacts` plus die zum Projekt gehörenden Schlüssel und Nutzertokens.

Ergänzen Sie projektspezifische Fälle für Views, privilegierte Funktionen, zusammengesetzte
Mandantenbeziehungen, Uploads, Storage, Exporte, Jobs und parallele Änderungen. Das kleine Beispiel
implementiert weder einen vollständigen Prüf- und Verwaltungsablauf für Partnerzuordnungen
noch einen vollständigen CRM-Betrieb.
Der lokale Nachweis ist keine Sicherheitsfreigabe für eine reale Anwendung.

## Primärquellen

- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Supabase: Securing your API](https://supabase.com/docs/guides/api/securing-your-api)
- [Supabase: Testing your database](https://supabase.com/docs/guides/database/testing)
- [PostgreSQL 18: Row Security Policies](https://www.postgresql.org/docs/18/ddl-rowsecurity.html)
- [PostgreSQL 18: CREATE POLICY](https://www.postgresql.org/docs/18/sql-createpolicy.html)
- [PostgREST 14: Authentication](https://docs.postgrest.org/en/v14/references/auth.html)
- [PostgREST 14: Transactions](https://docs.postgrest.org/en/v14/references/transactions.html)
- [PostgREST 14: Configuration](https://docs.postgrest.org/en/v14/references/configuration.html)
