feat(quick-260909-dgj): Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung (Task 3/4)

- apps/api/scripts/rls-preflight.mjs misst fuenf benannte Eigenschaften
  (rollenrechte, kontext-setzbar, ohne-kontext-leer, mit-kontext-sichtbar,
  schreibrechte) jeweils in einer eigenen Transaktion gegen eine per
  TESSERA_PREFLIGHT_DATABASE_URL angegebene Verbindung; --print-plan
  verbindet nicht, das Werkzeug schreibt in keiner Betriebsart
- 5 Tests in rls-preflight.spec.ts (rot vor dem Werkzeug, jetzt gruen)
- docs/mandantentrennung-datenbankrolle.md: Befund, Sperrgrund (182
  unskalierte Zugriffe, Anmeldeweg), Handgriffe des Betreibers samt
  Kennwortsetzung, Freigabebedingung und Rueckweg; docs/README.md verweist
  darauf

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 10:03:25 +02:00
parent a5f99e52e3
commit 44a90e5bfd
4 changed files with 431 additions and 0 deletions
+4
View File
@@ -20,6 +20,10 @@ Daneben liegt das [CI/CD-Runbook](ci-cd-setup.md), das die Einrichtung der
Bau-Pipeline in Gitea beschreibt. Es richtet sich an dieselben Leute wie die
Betriebsanleitung, deckt aber nur den Weg vom Quelltext zum fertigen Abbild ab.
Ebenfalls dabei: [Mandantentrennung auf Datenbankebene](mandantentrennung-datenbankrolle.md),
das sich an dieselben Leute wie die Betriebsanleitung richtet und ausschliesslich
die Datenbankrolle behandelt, mit der Tessera verbindet (WINDOWS #18).
---
## Die drei Dinge, die am häufigsten Zeit kosten
+173
View File
@@ -0,0 +1,173 @@
<!-- 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.**
Am 2026-09-09 wurde im Quelltext von `apps/api/src` gezaehlt, wie oft die
Anwendung ueber den unskalierten Prisma-Client zugreift (also ohne den
Mandantenkontext zu setzen) gegenueber der Anzahl der Verwendungen des
mandantengebundenen `tenantPrisma`: **182 unskalierte Zugriffe** — 84 auf die
sieben bereits mit Policies versehenen Tabellen, 98 auf die 16 neu
hinzugekommenen — gegenueber lediglich **19 Verwendungen** von `tenantPrisma`
im gesamten API-Quelltext.
Darunter ist der Anmeldeweg selbst, und der kann strukturell nicht anders
funktionieren: `apps/api/src/auth/auth.service.ts` sucht den Benutzer anhand
des Benutzernamens, **bevor** der Mandant bekannt ist — der Mandant wird ja
erst aus dem gefundenen Benutzer bestimmt. Unter der Rolle `tessera_app`
liefert genau diese Suche null Zeilen. **Niemand koennte sich mehr
anmelden.**
Ebenso betroffen sind Hintergrunddienste, die von Natur aus ohne
Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die
Modulzugriffspruefung, die Treffersuche im Ausschreibungs-Radar und die
Erstanlage des Administrators beim ersten Start.
Diese 182 Stellen brauchen einen ausdruecklichen, benannten Systemkontext
(oder eine gezielte Umstellung auf `tenantPrisma`/`forTenant()`), bevor der
Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser
Aenderung.** Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt
ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese
182 Zugriffe aus.
## 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 182 unskalierten Zugriffe aus
Abschnitt 3 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).