Files
tessera-ctl/docs/mandantentrennung-datenbankrolle.md
T
schalli 03fb3bf9c7 docs(quick-260910-jab): Aktenstand kohaerent machen — Ledger, Klassifikation, Kritikschrift, Betriebsanleitung
- WINDOWS.md: #19 auf fixed gesetzt (Tabelle + JSON-Block), mit Beleg
  (Migrationsname + benannte Pruefungen) und ausdruecklicher Feststellung,
  dass die SearchProvider-Haelfte als widerlegte Praemisse schliesst, nicht
  als geloestes Problem. #18/#20/#21/#22/#23 bleiben unveraendert offen. Ein
  neuer Eintrag #24 haelt den fehlenden Verwaltungsweg fuer plattformweite
  Zeilen unter der Anwendungsrolle offen (verschwindet nicht mit #19). Die
  vier Kopfzahlen sind aus dem JSON-Block abgeleitet (6 offen, 17 behoben, 1
  zurueckgestellt, 24 gesamt).
- Klassifikation: #19-Block von offener Frage zu beantwortet, die
  Uebersichtszeile tenders (35/27) und Summenzeile (107/135) aus dem
  Quelltext neu abgeleitet, vier Bestandsaufnahme-Zeilen nachgezogen
  (searchProvider, groups.service.ts/user, module-grants.service.ts/
  moduleGrant, tender-rss-feed.service.ts/tenderRssFeedSource), der Punkt in
  "Was diese Etappe NICHT entscheidet" aufgeloest.
- Kritikschrift: neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und
  WINDOWS #19" mit tatsaechlich beobachteter Ausgabe, Regelliste der
  lebenden Datenbank, Signaltabelle mit beiden Fehlerrichtungen je Regel,
  der neuen Stelle aus Befund F, der selbst ausgefuehrten Messung zur
  fehlenden Benutzerdimension (keine zweite Sitzungsvariable gefunden) und
  dem, was dieser Durchlauf nicht loest/nicht anfasst. Fuenf ueberholte
  Bestandsstellen mit Nachtraegen versehen (g4, t4, die Signaltabellenzeile
  zu den RSS-Pfaden, drei aufgezeichnete Werkzeugausgaben, m4), die alten
  Messprotokolle bleiben woertlich stehen.
- Betriebsanleitung: die eine Stelle, die #19 als offen fuehrte, nennt jetzt
  den Aufloesungsstand und WINDOWS #24.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 14:44:34 +02:00

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

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