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

198 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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.
## 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:
```sql
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
```bash
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).