Files
tessera-ctl/docs/mandantentrennung-datenbankrolle.md
T
schalli 5f3a39c2c3 docs(quick-260909-eor): alle 227 Datenbankzugriffe klassifiziert und maschinell abgesichert
WINDOWS #18/#20, Aufgabe 3: docs/mandantentrennung-zugriffsklassifikation.md
haelt fuer jede der 227 this.prisma.*-Fundstellen (32 Dateien, zusammengefasst
zu 59 Datei-Modell-Paaren) eine Klasse fest — muss-mandantengebunden (31),
keine-mandantengebundene-tabelle (16), bewusst-uebergreifend (3, mit
ausgeschriebenem Grund) oder beides (9, der Hintergrunddienst-Sonderfall:
uebergreifend lesen, je Zeile mandantengebunden schreiben — betrifft
ldap.service.ts, tender-digest.scheduler.ts, tender-matching.service.ts).

rls-access-inventory.spec.ts ermittelt die Fundstellen bei jedem Testlauf neu
aus dem Quelltext und vergleicht sie gegen die Tabelle im Dokument — Datei und
Modellname als Schluessel, keine Zeilennummer. Scheitert nachweislich, sobald
eine Fundstelle fehlt oder ein Eintrag verwaist (per Testlauf geprueft, danach
zurueckgesetzt).

Zwei belegte Befunde im Dokument festgehalten: req.tenantPrisma wird gesetzt,
aber nirgends gelesen; WINDOWS #19 (nullbares tenantId bei SearchProvider/
TenderRssFeedSource) bleibt benannter Blocker fuer Etappe 3.

docs/mandantentrennung-datenbankrolle.md verweist jetzt auf das neue
Dokument und korrigiert die ueberholte Zahl 182 auf den nachgemessenen Stand
(227/59). WINDOWS.md #18/#19 um Nachtrag auf diesen Plan ergaenzt; #20 (der
in Aufgabe 1 gemessene und behobene forTenant()-Verbindungsdefekt) als
"fixed" markiert.

Deviation (Rule 3, blockierend fuer die Bestandsaufnahme-Pruefung):
auth.service.ts-Kommentar umformuliert, der zuvor woertlich
"this.prisma.user.findUnique" als erklaerenden Text enthielt und dadurch
einen Eigentreffer der grep-basierten Inventur-Pruefung erzeugte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 11:04:29 +02:00

9.8 KiB
Raw Blame History

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

  1. Der Befund
  2. Was bereits vorbereitet ist
  3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
  4. Die Umstellung, Schritt fuer Schritt
  5. Die Vorher-Pruefung
  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 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.

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) und WINDOWS #19 (nullbares tenantId bei SearchProvider/TenderRssFeedSource).

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:

    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 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

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:

  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).