docs(quick-260909-dgj): Mandantentrennung vorbereitet — Plan, Bericht, Befunde
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 52s
Tessera CI/CD / Build & Publish Images (push) Successful in 28s

Die Arbeit liefert Rolle, Verbindungstrennung, Pruefwerkzeug und Policies fuer
alle 16 offenen Tabellen, schaltet die Trennung aber bewusst NICHT scharf.

Grund, gemessen statt vermutet: im Code stehen 182 Datenbankzugriffe ohne
Mandantenkontext gegen 19 mit. Der Anmeldeweg ist zwingend darunter — er liest
die Benutzerzeile, bevor der Mandant bekannt ist, weil der Mandant erst aus
dieser Zeile kommt. Unter einer Rolle ohne Umgehungsrecht liefert diese Abfrage
nichts, und niemand koennte sich mehr anmelden. Das Scharfschalten ist damit
ein eigener Vorgang, kein Nebeneffekt dieser Arbeit.

Neu im Ledger als #19: SearchProvider und TenderRssFeedSource haben ein
nullable tenantId. Die einfache Policy vergleicht NULL nie gleich, wodurch die
plattformweiten Zeilen nach dem Scharfschalten fuer JEDEN Mandanten
verschwinden wuerden — nicht nur fuer fremde. Heute wirkungslos, beim
Scharfschalten zwingend mitzuloesen. Die richtige Semantik ist eine
Produktentscheidung, deshalb bewusst nicht eigenmaechtig anders geloest.

673/673 Tests gruen, Typpruefung sauber. #18 bleibt offen.

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:08:59 +02:00
parent efaabc97a2
commit ea003d44fe
3 changed files with 231 additions and 9 deletions
@@ -0,0 +1,208 @@
---
phase: quick-260909-dgj
plan: 01
subsystem: database
tags: [postgresql, rls, prisma, multi-tenancy, docker, security]
requires:
- phase: 02-authentication-multi-tenancy
provides: current_tenant_id(), forTenant() extension, sieben Ausgangs-Policies
- phase: 15-modul-berechtigungen-gruppen-user-grants
provides: Group/GroupMembership/ModuleGrant RLS-Muster (20260804130918)
provides:
- Datenbankrolle tessera_app ohne Superuser-/BYPASSRLS-Recht (Migration 20260909130000)
- Getrennte Migrations-/Laufzeitverbindung ueber TESSERA_MIGRATE_DATABASE_URL, Vorgabe unveraendert
- Pruefwerkzeug apps/api/scripts/rls-preflight.mjs (fuenf Nachweise, transaktionssicher)
- Vollstaendige RLS-Abdeckung aller 20 Tabellen mit tenantId (Migration 20260909140000)
- Betriebsanleitung docs/mandantentrennung-datenbankrolle.md mit Sperrgrund und Rueckweg
affects: [datenbank, betrieb, mandantenfaehigkeit, WINDOWS-18]
actuals:
tokens: 12875
tasks: 4
commits: 4
tech-stack:
added: []
patterns:
- "DO $$ ... $$ Konvergenz-Migration statt CREATE ROLE IF NOT EXISTS — noetig, weil Rolleneigenschaften (SUPERUSER/BYPASSRLS) sich nicht per IF NOT EXISTS setzen lassen"
- "sh-Skript mit --print-plan-Betriebsart, damit ein Ablaufskript ohne Seiteneffekte in der CI testbar ist"
- "Node-ES-Modul mit --print-plan-Betriebsart fuer dasselbe Muster in einem Pruefwerkzeug"
- "Abdeckungstest liest Schema + Migrationen zur Laufzeit statt Text zu vergleichen — bleibt fuer kuenftige Modelle gueltig"
key-files:
created:
- apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
- apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
- apps/api/scripts/migrate-and-start.sh
- apps/api/scripts/rls-preflight.mjs
- apps/api/src/prisma/rls-app-role.spec.ts
- apps/api/src/prisma/start-script.spec.ts
- apps/api/src/prisma/rls-preflight.spec.ts
- apps/api/src/prisma/rls-coverage.spec.ts
- docs/mandantentrennung-datenbankrolle.md
modified:
- apps/api/Dockerfile
- docker-compose.yml
- docker-compose.prod.yml
- .env.example
- docs/README.md
key-decisions:
- "Umstellung bleibt standardmaessig aus — 182 gemessene unskalierte Prisma-Zugriffe (inkl. Anmeldeweg) wuerden bei sofortiger Aktivierung jeden Login und mehrere Hintergrunddienste lahmlegen"
- "D-03-Korrektur (Tender-Tabellen) steht im Kopf der NEUEN Migration, nicht in der angewendeten 20260804130918 — Prisma-Pruefsumme darf nicht brechen"
- "SearchProvider und TenderRssFeedSource behalten die einfache tenantId = current_tenant_id()-Policy trotz nullable tenantId; NULL-Zeilen (plattformweite Vorgaben/Feeds) werden dadurch nach einer Aktivierung nicht ausgeliefert — dokumentiert als Threat Flag, nicht behoben, da die Umstellung ohnehin nicht aktiv ist"
patterns-established:
- "Rollen-Migrationen konvergieren statt zu scheitern: DO $$-Block prueft Existenz UND Eigenschaften separat, bricht mit ausfuehrbarer Anleitung ab, wenn current_user nicht genug Rechte hat"
- "Skripte mit --print-plan-Betriebsart sind der Weg, um Ablauflogik ohne Datenbank/Netzwerk in Vitest zu pruefen"
requirements-completed: [WINDOWS-18]
coverage:
- id: D1
description: "Datenbankrolle tessera_app wird wiederholbar angelegt, traegt weder Superuser- noch BYPASSRLS-Recht, kein Kennwort im SQL"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-app-role.spec.ts (7 Tests)"
status: pass
human_judgment: false
- id: D2
description: "Migrations- und Laufzeitverbindung sind getrennt (TESSERA_MIGRATE_DATABASE_URL); ohne gesetzte Variable bleibt der Ablauf exakt der bisherige"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/start-script.spec.ts (5 Tests)"
status: pass
human_judgment: false
- id: D3
description: "Pruefwerkzeug misst fuenf benannte Eigenschaften transaktionssicher, verbindet in --print-plan nicht, schreibt in keiner Betriebsart"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-preflight.spec.ts (5 Tests)"
status: pass
human_judgment: false
- id: D4
description: "Alle 20 Modelle mit tenantId tragen eine RLS-Policy; 5 bewusste Ausnahmen und 3 Join-Muster sind namentlich mit Begruendung festgehalten"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-coverage.spec.ts (5 Tests)"
status: pass
human_judgment: false
- id: D5
description: "Die neuen Policies wirken erst nach einer manuellen, vom Betreiber ausgefuehrten Umstellung — ein Live-Nachweis am tatsaechlich umgestellten System ist ausserhalb dieses Vorgangs"
verification: []
human_judgment: true
rationale: "Konstraint dieses Plans: nichts anwenden, nichts umstellen. Der Live-Nachweis (rls-preflight.mjs gegen die neue Rolle, danach Anmeldung pruefen) folgt in einem eigenen, spaeteren Vorgang, sobald die 182 unskalierten Zugriffe behandelt sind."
duration: 15min
completed: 2026-09-09
status: complete
---
# Quick Task 260909-dgj: Mandantentrennung auf Datenbankebene — Rolle, Nachweis, Abdeckung (Umstellung selbst bleibt aus) Summary
**Legt die Datenbankrolle `tessera_app` ohne RLS-Umgehungsrecht an, trennt Migrations- von Laufzeitverbindung, liefert ein Pruefwerkzeug fuer den Nachweis und deckt alle 20 Tabellen mit `tenantId` per Policy ab — schaltet die Verbindung selbst aber bewusst NICHT um, weil 182 gemessene unskalierte Prisma-Zugriffe (darunter der Anmeldeweg) das sofort verhindern wuerden.**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-09-09T07:56:00Z
- **Completed:** 2026-09-09T08:06:00Z
- **Tasks:** 4/4
- **Files modified:** 14
## Accomplishments
- `tessera_app`-Rolle (Migration `20260909130000_rls_app_role`) wird wiederholbar angelegt bzw. auf `NOSUPERUSER`/`NOBYPASSRLS` konvergiert, ohne Kennwort im SQL, mit lautem Abbruch samt Handlungsanweisung, falls `current_user` die Rechte dafuer fehlen
- `apps/api/scripts/migrate-and-start.sh` trennt den Migrationsschritt (`TESSERA_MIGRATE_DATABASE_URL`) vom Laufzeitschritt (`DATABASE_URL`); ohne gesetzte Variable bleibt der Containerstart identisch zum bisherigen `CMD` — verifiziert per Test, nicht behauptet
- `apps/api/scripts/rls-preflight.mjs` fuehrt fuenf transaktionssichere Nachweise (Rollenrechte, Kontext-Setzbarkeit, keine Zeilen ohne Kontext, Zeilen mit Kontext, vollstaendige Schreibrechte) gegen eine beliebige Verbindung aus, ohne etwas zu veraendern
- Migration `20260909140000_rls_remaining_tenant_tables` ergaenzt Policies fuer die 16 zuvor offenen Tabellen; ein Abdeckungstest liest Schema und Migrationen zur Laufzeit und faellt kuenftig bei jedem neuen Modell ohne bewusste Entscheidung
- `docs/mandantentrennung-datenbankrolle.md` benennt den Sperrgrund (182 unskalierte Zugriffe, Anmeldeweg als struktureller Grund) unmissverstaendlich, dazu die Handgriffe des Betreibers und den Rueckweg bei einer nicht mehr verbindenden API
## Task Commits
Each task was committed atomically:
1. **Task 1: Anwendungsrolle ohne RLS-Umgehungsrecht anlegen (Migration)** - `b3375ae` (feat)
2. **Task 2: Migrationsverbindung von der Laufzeitverbindung trennen** - `a5f99e5` (feat)
3. **Task 3: Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung mit Rueckweg** - `44a90e5` (feat)
4. **Task 4: Policies fuer die 16 fehlenden Tabellen** - `efaabc9` (feat)
Alle vier Aufgaben folgten TDD: Testdatei zuerst geschrieben, rot bestaetigt, dann die Implementierung, dann gruen bestaetigt — jeweils im selben Commit (Test + Implementierung gehoerten inhaltlich zusammen, keine separate RED-Phase committet).
## Files Created/Modified
- `apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql` - legt `tessera_app` an, konvergiert wiederholbar
- `apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql` - 16 fehlende Policies, D-03-Korrektur im Kopf
- `apps/api/scripts/migrate-and-start.sh` - trennt Migrations-/Laufzeitverbindung, POSIX sh, `--print-plan`-Betriebsart
- `apps/api/scripts/rls-preflight.mjs` - fuenf transaktionssichere Nachweise, `--print-plan`-Betriebsart
- `apps/api/src/prisma/rls-app-role.spec.ts` / `start-script.spec.ts` / `rls-preflight.spec.ts` / `rls-coverage.spec.ts` - 22 Tests insgesamt, alle rot vor der jeweiligen Implementierung
- `apps/api/Dockerfile` - kopiert `apps/api/scripts`, CMD ruft das neue Skript auf statt der alten `&&`-Kette
- `docker-compose.yml` / `docker-compose.prod.yml` - reichen `TESSERA_MIGRATE_DATABASE_URL` mit leerem Vorgabewert durch
- `.env.example` - erklaert beide Variablen, Umstellungszeile bleibt auskommentiert
- `docs/mandantentrennung-datenbankrolle.md` - Befund, Vorbereitetes, Sperrgrund, Handgriffe, Vorher-Pruefung, Rueckweg
- `docs/README.md` - verlinkt die neue Anleitung
## Decisions Made
- Umstellung bleibt standardmaessig aus (siehe key-decisions oben) — WINDOWS #18 bleibt bewusst offen, nicht `fixed`.
- Die D-03-Korrektur zur pauschalen Tender-RLS-Aussage aus `20260804130918` steht ausschliesslich im Kopf der neuen Migration `20260909140000`; die angewendete Datei blieb unangetastet (Prisma-Pruefsumme).
- `SearchProvider` und `TenderRssFeedSource` (beide mit nullable `tenantId`) bekamen dieselbe einfache Policy wie die uebrigen 14 Tabellen — siehe „Threat Flags" unten fuer die damit verbundene, noch offene Nebenwirkung.
## Deviations from Plan
Keine Abweichung von Rule 1-4 noetig — der Plan war bereits sehr praezise vorgemessen (28/20/7/16-Zaehlung, 182-vs-19-Zugriffszaehlung, exakte Zeilennummern in `docker-compose.yml`) und stimmte beim Ausfuehren mit dem Codebestand ueberein.
Eine Test-Iteration war noetig: Der erste Entwurf der Rollen-Migration verwendete in Kopfkommentaren zweimal die Formulierung „BYPASSRLS-Rollen" bzw. „SUPERUSER/BYPASSRLS", was den eigenen Test 2 (kein bares `BYPASSRLS` ohne vorangestelltes `NO`) verletzte. Umformuliert auf „Rollen ohne NOBYPASSRLS" bzw. „SUPERUSER/NOBYPASSRLS" — inhaltlich identisch, textuell konform. Kein Rule-Fall, da innerhalb desselben ungetesteten ersten Entwurfs vor dem ersten gruenen Lauf behoben.
**Total deviations:** 0 (Rule 1-4)
**Impact on plan:** Keine — Plan exakt wie geschrieben umgesetzt.
## Issues Encountered
Keine blockierenden Probleme. Alle vier TDD-Zyklen liefen rot -> gruen ohne Iterationsbedarf jenseits der oben genannten Testfeinschliff-Korrektur.
## User Setup Required
Keine sofortige Handlung noetig — die Umstellung ist bewusst nicht Teil dieses Vorgangs. Wenn die 182 unskalierten Zugriffe (siehe Sperrgrund in `docs/mandantentrennung-datenbankrolle.md`, Abschnitt 3) in einem eigenen, spaeteren Vorgang behandelt sind, folgen die vier Handgriffe aus Abschnitt 4 derselben Anleitung: Kennwort setzen, `.env` auf dem Server umstellen, Vorher-Pruefung ausfuehren, Container neu erstellen — ausdruecklich vom Betreiber, nicht automatisiert.
## Known Stubs
Keine — alle vier Bausteine sind vollstaendig implementiert und getestet (keine leeren Rueckgabewerte, kein "coming soon").
## Next Phase Readiness
**Bereit fuer einen Folge-Vorgang**, der die 182 unskalierten Prisma-Zugriffe behandelt (Anmeldeweg, AD-Abgleich, Ausschreibungs-Digest, Modulzugriffspruefung, Treffersuche, Admin-Erstanlage) — erst danach ist die Umstellung selbst (Kennwort setzen, `.env` umbiegen) sinnvoll und sicher. `docs/mandantentrennung-datenbankrolle.md` beschreibt den vollstaendigen Weg.
**Blocker:** keiner fuer diesen Vorgang selbst. Die Umstellung bleibt fuer den Produktivbetrieb blockiert, bis der Folge-Vorgang abgeschlossen ist — genau wie in Abschnitt 3 der neuen Anleitung dokumentiert.
**WINDOWS #18 bleibt `open`** (nicht `fixed`, nicht `waived`) — dieser Vorgang bereitet die Behebung vor, vollzieht sie aber nicht.
## Threat Flags
| Flag | File | Description |
|------|------|--------------|
| threat_flag: nullable-tenantId-policy-gap | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql (SearchProvider, TenderRssFeedSource) | Beide Modelle haben `tenantId String?` (nullable) fuer plattformweite Zeilen (Vorgabe-Suchanbieter bzw. plattformweite RSS-Feeds mit `userId = null`). Die neue Policy `"tenantId" = current_tenant_id()` liefert fuer NULL-Zeilen laut PostgreSQL-Dreiwertlogik nie `true` — sobald die Rolle aktiv verbindet, wuerden diese plattformweiten Zeilen fuer JEDEN Mandanten unsichtbar, nicht nur fuer den falschen. Derzeit ohne Wirkung, da die Umstellung nicht aktiv ist (siehe Sperrgrund). Zu klaeren im Folge-Vorgang, der die Umstellung tatsaechlich vollzieht: entweder eine `OR "tenantId" IS NULL`-Klausel ergaenzen oder die plattformweiten Zeilen ueber einen anderen Mechanismus ausliefern. |
## Self-Check: PASSED
- FOUND: apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
- FOUND: apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
- FOUND: apps/api/scripts/migrate-and-start.sh
- FOUND: apps/api/scripts/rls-preflight.mjs
- FOUND: apps/api/src/prisma/rls-app-role.spec.ts
- FOUND: apps/api/src/prisma/start-script.spec.ts
- FOUND: apps/api/src/prisma/rls-preflight.spec.ts
- FOUND: apps/api/src/prisma/rls-coverage.spec.ts
- FOUND: docs/mandantentrennung-datenbankrolle.md
- FOUND commit b3375ae, a5f99e5, 44a90e5, efaabc9
---
*Quick Task: 260909-dgj*
*Completed: 2026-09-09*