# Tessera – Mandantentrennung auf Datenbankebene (WINDOWS #18) Diese Anleitung richtet sich an dieselben Kolleg:innen wie das [Betriebshandbuch](./anleitung-betrieb.md) und behandelt nur einen einzigen Punkt: die Datenbankrolle, mit der Tessera verbindet, und warum die bestehenden Sicherheitsregeln (Row-Level Security) heute keine Wirkung haben. ## Inhaltsverzeichnis 1. [Der Befund](#1-der-befund) 2. [Was bereits vorbereitet ist](#2-was-bereits-vorbereitet-ist) 3. [Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist](#3-der-sperrgrund--warum-die-umstellung-noch-nicht-erfolgt-ist) 4. [Die Umstellung, Schritt fuer Schritt](#4-die-umstellung-schritt-fuer-schritt) 5. [Die Vorher-Pruefung](#5-die-vorher-pruefung) 6. [Der Rueckweg](#6-der-rueckweg) --- ## 1. Der Befund Tessera trennt die Daten mehrerer Mandanten auf zwei Ebenen: im Anwendungscode (`where: { tenantId }`) und zusaetzlich auf Datenbankebene ueber PostgreSQL Row-Level Security (RLS) — ein „zweites Sicherheitsnetz" fuer den Fall, dass im Code einmal ein `tenantId`-Filter vergessen wird. Dieses zweite Netz existiert seit Juni 2026 auf sieben Tabellen und wurde am 2026-09-09 mit einer wichtigen Einschraenkung nachgemessen: **es greift derzeit nicht.** Die API verbindet laut `docker-compose.yml` als Rolle `tessera`. Diese Rolle entsteht aus der Umgebungsvariable `POSTGRES_USER` und ist damit automatisch Superuser des PostgreSQL-Clusters. PostgreSQL wendet Row-Level Security auf Superuser-Rollen grundsaetzlich nicht an — auch nicht auf Rollen mit dem Recht `BYPASSRLS`, das Superuser automatisch mitbringen. Der Zusatz `FORCE ROW LEVEL SECURITY` in den bestehenden Migrationen aendert daran nichts: er erzwingt Policies nur gegenueber dem *Tabelleneigentuemer*, nicht gegenueber Rollen mit Umgehungsrecht — und die Rolle `tessera` ist beides zugleich. Praktisch nachgemessen am 2026-09-09: ein `SELECT count(*) FROM "Group"` ohne gesetzten Mandantenkontext liefert zwei Zeilen, obwohl die vorhandene Policy `USING ("tenantId" = current_tenant_id())` bei fehlendem Kontext null Zeilen liefern muesste. Die Isolation zwischen Mandanten haengt heute vollstaendig am `where`-Filter im Anwendungscode — nicht an der Datenbank. Dieser Befund ist als [WINDOWS #18](../.planning/WINDOWS.md) im Fehler-Ledger festgehalten. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft — aber zwingend zu beheben, bevor Tessera an einen zweiten, externen Mandanten geht. ## 2. Was bereits vorbereitet ist Diese Aenderung liefert die vier Bausteine, die fuer eine wirksame Trennung noetig sind: - **Eine eigene Datenbankrolle** `tessera_app` ohne Superuser- und ohne BYPASSRLS-Recht (Migration `20260909130000_rls_app_role`). - **Getrennte Verbindungen fuer Migration und Laufzeit.** `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers, die Anwendung soll sie nicht haben — `apps/api/scripts/migrate-and-start.sh` trennt beide Schritte ueber die neue Variable `TESSERA_MIGRATE_DATABASE_URL`. - **Ein Pruefwerkzeug**, das den Nachweis fuehrt statt ihn zu behaupten: `apps/api/scripts/rls-preflight.mjs` misst gegen eine angegebene Verbindung Rollenrechte, Setzbarkeit des Mandantenkontexts, sichtbare Zeilen mit und ohne Kontext sowie vorhandene Zugriffsrechte. - **Vollstaendige Policy-Abdeckung.** Migration `20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine Regel. - **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben `app.current_tenant` (Migration `20260618112133_rls_policies`, `current_tenant_id()`) setzt die Rolle seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b, 260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion `current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring, nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann. Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout, FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance) pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` — ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige Aufrufer wirkungslos. ## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist **Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht umgestellt werden.** **Nachgemessen und korrigiert am 2026-09-09 (260909-eor, Aufgabe 3):** die zuvor hier genannte Zahl 182 stammte aus einer groeberen Zaehlung vor dieser Etappe und ist ueberholt. Die aktuelle, maschinell ermittelte und durch `apps/api/src/prisma/rls-access-inventory.spec.ts` dauerhaft gepruefte Bestandsaufnahme steht in **`docs/mandantentrennung-zugriffsklassifikation.md`**: **227** `this.prisma.*`-Fundstellen in `apps/api/src` (ohne Tests), zusammengefasst zu **59** (Datei, Modell)-Paaren, davon **31** `muss-mandantengebunden` und **9** `beides` (Hintergrunddienst mit sowohl uebergreifendem Lesen als auch mandantengebundenem Schreiben je Zeile) — zusammen der eigentliche Arbeitsvorrat fuer die Umstellung. **16** Paare betreffen keine mandantengebundene Tabelle (plattformweite Daten wie der Ausschreibungs- und Modulkatalog, D-03) und **3** sind bewusst uebergreifend mit ausgeschriebenem Grund. Zusaetzlich zu beachten: das entdeckte, aber in dieser Etappe nicht behobene `forTenant()`-Verbindungsproblem (WINDOWS #20, siehe unten). WINDOWS #19 (nullbares `tenantId` bei `SearchProvider`/`TenderRssFeedSource`) ist GESCHLOSSEN (260910-jab, Migration `20260910120000_rls_widen_membership_grant_and_platform_read`) — siehe `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Zwei belegte Befunde", und `.planning/WINDOWS.md`. Offen geblieben ist der Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle (WINDOWS #24). Darunter war bis Aufgabe 2 dieser Etappe auch der Anmeldeweg selbst, der strukturell nicht anders funktionieren konnte: `apps/api/src/auth/auth.service.ts` suchte den Benutzer anhand des Benutzernamens, **bevor** der Mandant bekannt war. Das ist inzwischen geloest — drei enge SECURITY-DEFINER-Funktionen (`auth_lookup_user_by_username`, `auth_lookup_user_by_email`, `auth_lookup_reset_token`, Migration `20260909160000_auth_lookup_functions`) uebernehmen die drei pre-tenant Lesezugriffe, alle nachfolgenden Schreibzugriffe laufen ueber `forTenant()`. Weiterhin betroffen sind Hintergrunddienste, die von Natur aus ohne Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die Ausschreibungs-Sofortmeldung und die Erstanlage des Administrators beim ersten Start — Details und Begruendung je Fundstelle in `docs/mandantentrennung-zugriffsklassifikation.md`. Diese Stellen brauchen einen ausdruecklichen, benannten Systemkontext (oder eine gezielte Umstellung auf `forTenant()`), bevor der Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser Aenderung** (Etappe 2/3 der Mandantentrennung). Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese Zugriffe aus. **Zusaetzlicher Sperrgrund, ebenfalls am 2026-09-09 gemessen (WINDOWS #20):** `forTenant()` selbst war bis Aufgabe 1 dieser Etappe defekt — `set_config()` lief auf einer anderen Datenbankverbindung als die eigentliche Abfrage, sodass selbst die 6 bisherigen `forTenant()`-Aufrufstellen (alle in `apps/api/src/ldap/ldap.service.ts` sowie `tenant.middleware.ts`/ `tenant.guard.ts`) den Mandantenkontext nie tatsaechlich gesetzt haben. Das ist inzwischen repariert (Array-Form von `$transaction`, `prisma-tenant.extension.ts`) und durch `apps/api/scripts/rls-scratch-check.mjs` live nachgewiesen. Ohne diese Reparatur waere jede Umstellung auf `forTenant()` in den folgenden Etappen wirkungslos gewesen. ## 4. Die Umstellung, Schritt fuer Schritt Erst durchfuehren, wenn Abschnitt 3 abgearbeitet ist. Reihenfolge einhalten — die Migration muss weiterhin als Tabelleneigentuemer laufen. 1. **Kennwort der Rolle einmalig setzen**, als Datenbank-Superuser: ```sql ALTER ROLE tessera_app WITH PASSWORD ''; ``` Dieser Befehl gehoert in keine Datei und in kein Protokoll — er wird direkt an `psql` uebergeben und danach aus der Shell-Historie entfernt. 2. **In der `.env` des Servers** `TESSERA_MIGRATE_DATABASE_URL` auf die **bisherige** Verbindung (Rolle `tessera`, Tabelleneigentuemer) setzen und `DATABASE_URL` auf die **neue** Rolle `tessera_app` umbiegen — in dieser Reihenfolge, denn `prisma migrate deploy` muss weiterhin mit Eigentuemerrechten laufen, waehrend die Anwendung selbst die eingeschraenkte Rolle bekommt. 3. Vor dem Neustart die Vorher-Pruefung aus Abschnitt 5 gegen die neue Rolle ausfuehren. 4. API-Dienst neu erstellen (`docker compose up -d --build --force-recreate api`, siehe Betriebshandbuch Abschnitt 4). ## 5. Die Vorher-Pruefung ```bash TESSERA_PREFLIGHT_DATABASE_URL="postgresql://tessera_app:@:5432/tessera" \ node apps/api/scripts/rls-preflight.mjs ``` Das Werkzeug meldet fuenf Pruefungen: Rollenrechte, Setzbarkeit des Mandantenkontexts, keine sichtbaren Zeilen ohne Kontext, sichtbare Zeilen mit Kontext, vollstaendige Zugriffsrechte. Es veraendert nichts — es liest ausschliesslich. **Ein gruener Bericht ist die Freigabebedingung fuer Schritt 4 aus Abschnitt 4 — aber er ist keine Freigabe fuer die Umstellung insgesamt.** Er bestaetigt nur, dass die Datenbankseite stimmt. Die unskalierten Zugriffe aus Abschnitt 3 (siehe `docs/mandantentrennung-zugriffsklassifikation.md` fuer den aktuellen Stand) misst er nicht und kann sie nicht messen — dafuer muesste er den Anwendungscode lesen, nicht die Datenbank. ## 6. Der Rueckweg **Wenn die API nach einer Umstellung nicht mehr hochkommt:** Das sichtbare Bild: der `api`-Container bleibt ungesund (Healthcheck schlaegt fehl), und das Protokoll (`docker compose logs api`) zeigt einen Authentifizierungs- oder Rechtefehler von PostgreSQL — etwa eine fehlgeschlagene Anmeldung oder eine verweigerte Berechtigung. Weg zurueck, zwei Schritte: 1. `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung (Rolle `tessera`) setzen. 2. `docker compose up -d --build --force-recreate api` ausfuehren. Die Rolle `tessera_app` darf dabei unangetastet bestehen bleiben — sie schadet nicht, solange niemand mit ihr verbindet. **Wichtiger Hinweis fuer den Server:** `/opt/tessera/docker-compose.yml` ist keine Arbeitskopie dieses Repositorys. Aenderungen an `docker-compose.yml` oder `docker-compose.prod.yml` in diesem Repository erreichen den Server nicht automatisch. Wer `TESSERA_MIGRATE_DATABASE_URL` dort verfuegbar haben will, traegt die Zeile selbst in `/opt/tessera/docker-compose.yml` ein (vorher sichern) — genau wie es bereits fuer andere nachtraegliche Variablen dokumentiert ist (Betriebshandbuch, Abschnitt zur Server-Konfiguration).