- Neuer Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)"
in der Kritikschrift mit b1 (woertliche Werkzeugausgabe + pg_policies-Liste),
b2 (Signaltabelle beide Fehlerrichtungen), b3 (NotFound-statt-Forbidden
je Methode), b4/b5 (bewusst nicht geloest/angefasst). Zehn datierte
Nachtraege an allen Stellen, die zuvor "keine Benutzerdimension" als
Stand beschrieben (t1/t4/r4/w1/w4/k1/k4/f1/f4/Abschluss) — historische
Messung bleibt lesbar.
- Klassifikation: drei Bestandsaufnahme-Zeilen (calendarSource,
widgetInstance, favoriteLink) mit Zusatz "Benutzerdimension seit
20260911120000 (260911-nke)"; neuer Punkt "Aufgelöst (260911-nke)" im
Abschnitt "Was diese Etappe NICHT entscheidet"; neuer Stand-Absatz —
Paarzahl (72) und Klassen-Verteilung bleiben unveraendert.
- Betriebsanleitung: `forTenant(prisma, tenantId, userId?)` und die zehn/
vier-Tabellen-Aufteilung nachgezogen.
- Datenbankrolle: neuer Absatz zu `app.current_user`/`current_user_id()`
neben `app.current_tenant`; SECURITY-DEFINER-Kopfkommentare unangetastet.
- Auftrag: 3b als erledigt markiert (Migrationsname, sechs statt drei
Umkehrungen, Endzahlen); 3a/3c unveraendert.
- WINDOWS.md: neuer Eintrag #34 (open, deviation) fuer die bewusst offene
Flanke — Aufrufer ohne userId sieht den ganzen Mandanten, kein Waechter
gebaut.
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 203/203 bestanden.
Erlaubnisliste gegen 8829999 eingehalten, schema.prisma/Compose/.env/3a
unveraendert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
11 KiB
Tessera – Mandantentrennung auf Datenbankebene (WINDOWS #18)
Diese Anleitung richtet sich an dieselben Kolleg:innen wie das Betriebshandbuch 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
- Der Befund
- Was bereits vorbereitet ist
- Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
- Die Umstellung, Schritt fuer Schritt
- Die Vorher-Pruefung
- 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 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_appohne Superuser- und ohne BYPASSRLS-Recht (Migration20260909130000_rls_app_role). - Getrennte Verbindungen fuer Migration und Laufzeit.
prisma migrate deploybraucht die Rechte des Tabelleneigentuemers, die Anwendung soll sie nicht haben —apps/api/scripts/migrate-and-start.shtrennt beide Schritte ueber die neue VariableTESSERA_MIGRATE_DATABASE_URL. - Ein Pruefwerkzeug, das den Nachweis fuehrt statt ihn zu behaupten:
apps/api/scripts/rls-preflight.mjsmisst 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_tablesergaenzt die bislang fehlenden 16 Tabellen; alle 20 Tabellen mittenantIdtragen jetzt eine Regel. - Eine zweite Sitzungsvariable fuer die Benutzerdimension. Neben
app.current_tenant(Migration20260618112133_rls_policies,current_tenant_id()) setzt die Rolle seit Migration20260911120000_rls_user_dimension_personal_tables(Etappe 3b, 260911-nke) zusaetzlichapp.current_user. Die zugehoerige Funktioncurrent_user_id()faltet den Leerstring perNULLIFaufNULL— der HelferforTenant()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. KeinGRANT EXECUTEnoetig — wie beicurrent_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) pruefencurrent_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.
-
Kennwort der Rolle einmalig setzen, als Datenbank-Superuser:
ALTER ROLE tessera_app WITH PASSWORD '<hier ein starkes, generiertes Kennwort einsetzen>';Dieser Befehl gehoert in keine Datei und in kein Protokoll — er wird direkt an
psqluebergeben und danach aus der Shell-Historie entfernt. -
In der
.envdes ServersTESSERA_MIGRATE_DATABASE_URLauf die bisherige Verbindung (Rolletessera, Tabelleneigentuemer) setzen undDATABASE_URLauf die neue Rolletessera_appumbiegen — in dieser Reihenfolge, dennprisma migrate deploymuss weiterhin mit Eigentuemerrechten laufen, waehrend die Anwendung selbst die eingeschraenkte Rolle bekommt. -
Vor dem Neustart die Vorher-Pruefung aus Abschnitt 5 gegen die neue Rolle ausfuehren.
-
API-Dienst neu erstellen (
docker compose up -d --build --force-recreate api, siehe Betriebshandbuch Abschnitt 4).
5. Die Vorher-Pruefung
TESSERA_PREFLIGHT_DATABASE_URL="postgresql://tessera_app:<kennwort>@<host>: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:
DATABASE_URLin der.envwieder auf die bisherige Verbindung (Rolletessera) setzen.docker compose up -d --build --force-recreate apiausfuehren.
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).