11 Commits

Author SHA1 Message Date
schalli ea003d44fe 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
2026-09-09 10:08:59 +02:00
schalli efaabc97a2 feat(quick-260909-dgj): Policies fuer die 16 fehlenden Tabellen (Task 4/4)
- Migration 20260909140000_rls_remaining_tenant_tables ergaenzt ENABLE +
  FORCE ROW LEVEL SECURITY und je eine tenant_isolation_policy fuer alle 16
  noch offenen Tabellen mit tenantId
- Kopfkommentar korrigiert die zu pauschale D-03-Aussage aus
  20260804130918: nur die drei Tender-Tabellen OHNE tenantId sind davon
  betroffen, die sechs MIT tenantId bekommen jetzt eine Policy — die alte
  Migrationsdatei bleibt unveraendert
- rls-coverage.spec.ts misst die Abdeckung aus Schema und Migrationen
  statt Text zu vergleichen (rot mit 16 gemeldeten Luecken vor der
  Migration, jetzt gruen); waechst automatisch mit kuenftigen Modellen und
  erzwingt bei jedem neuen tenantId-losen Modell eine bewusste Entscheidung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:05:15 +02:00
schalli 44a90e5bfd 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
2026-09-09 10:03:25 +02:00
schalli a5f99e52e3 feat(quick-260909-dgj): Migrationsverbindung von Laufzeitverbindung trennen (Task 2/4)
- apps/api/scripts/migrate-and-start.sh: TESSERA_MIGRATE_DATABASE_URL fuer
  den Migrationsschritt, DATABASE_URL bleibt unveraendert fuer den
  Laufzeitschritt; leer/nicht gesetzt = bisheriges Verhalten
- Dockerfile kopiert scripts/ und ruft das Skript als CMD auf; exec statt
  &&-Verkettung, damit Signale den Node-Prozess erreichen
- docker-compose.yml/.prod.yml reichen TESSERA_MIGRATE_DATABASE_URL durch
  (leerer Vorgabewert); docker-compose.dev.yml/.ci.yml unveraendert
- .env.example erklaert beide Variablen mit Platzhaltern, Umstellung bleibt
  auskommentiert
- 5 Tests in start-script.spec.ts (rot vor dem Skript, jetzt gruen)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:00:45 +02:00
schalli b3375ae016 feat(quick-260909-dgj): Anwendungsrolle tessera_app ohne RLS-Umgehungsrecht (Task 1/4)
- Migration 20260909130000_rls_app_role legt tessera_app mit NOSUPERUSER
  NOBYPASSRLS wiederholbar an bzw. konvergiert eine vorhandene Rolle darauf
- Kein Kennwort im SQL, Datenbankname und Eigentuemer dynamisch gebildet
- 7 Tests in rls-app-role.spec.ts (rot vor der Migration, jetzt gruen)
- Rolle wird von niemandem benutzt — WINDOWS #18 bleibt offen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:59:21 +02:00
schalli 8cb2d43f88 docs(quick-260909-dgj): Plan fuer wirksame Mandantentrennung auf Datenbankebene
WINDOWS #18: RLS ist heute wirkungslos, weil die Anwendungsrolle
Superuser ist und BYPASSRLS traegt. Der Plan folgt der vorgegebenen
Reihenfolge — erst die Rolle ohne Umgehungsrecht, dann der Nachweis,
dann die Ausweitung auf die 16 fehlenden Tabellen.

Gemessen und im Plan festgehalten: 182 Zugriffe im API-Quelltext laufen
ueber den unskalierten Prisma-Client (nur 19 ueber tenantPrisma),
darunter der Anmeldeweg selbst. Ein Umlegen des Schalters wuerde die
Anwendung aussperren; der Plan bereitet die Umstellung deshalb vor und
misst sie, vollzieht sie aber nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:55:59 +02:00
schalli fbb0d09e42 docs: Mandantentrennung greift nicht — Anwendungsrolle umgeht RLS (#18)
Beim Vorbereiten der RLS-Ausweitung gemessen: die API verbindet als Rolle
'tessera' (docker-compose.yml:33), und diese Rolle hat rolsuper=t und
rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen nicht
an; FORCE ROW LEVEL SECURITY hilft nicht, das betrifft nur den
Tabelleneigentuemer.

Praktisch belegt statt hergeleitet: ohne gesetztes app.current_tenant liefert
SELECT count(*) FROM "Group" zwei Zeilen, obwohl die Policy bei NULL-Kontext
null liefern muesste.

Folge: alle sieben bisher mit RLS ausgestatteten Tabellen sind faktisch
ungeschuetzt. Die Trennung haengt allein am manuellen where-tenantId im Code.
Die Migration 20260804130918 beschreibt RLS als 'zweites Sicherheitsnetz' —
dieses Netz existiert derzeit nicht.

Das aendert die Reihenfolge der geplanten Arbeit: erst eine Anwendungsrolle
ohne Superuser- und BYPASSRLS-Recht, dann greifen die vorhandenen Policies,
erst danach lohnt das Ergaenzen fehlender Tabellen. Sonst baut man Regeln, die
nichts tun und Sicherheit vortaeuschen.

Kein akutes Risiko: Tessera laeuft intern mit einem einzigen Mandanten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:42:26 +02:00
schalli e6679eb4b1 docs(quick-260909-cx0): Dateisicherung und Versionsangaben — Plan und Bericht
Zwei kleine, unabhaengige Reparaturen in getrennten Commits (dab72eb, c807049).

Bemerkenswert an der Versionskorrektur: sie hat sechs Empfehlungen zutage
gefoerdert, die nie eingebaut wurden — Keycloak als Identitaetsanbieter, Redis,
TanStack Query, shadcn/ui, Playwright als Projektabhaengigkeit und Husky. Die
stehen jetzt in einem eigenen Abschnitt 'Recommended But Not Adopted', damit
niemand sie beim Lesen fuer vorhanden haelt. Ausserdem laeuft Vitest in den
beiden Anwendungen in unterschiedlichen Hauptfassungen (3.2.6 gegen 4.1.9).

Der Technik-Block in CLAUDE.md ist generiert. Eine Korrektur allein dort waere
bei der naechsten Regeneration still zurueckgeholt worden, deshalb zusaetzlich
ein Herkunftsvermerk im Block und eine datierte Hinweiszeile in der
Recherchedatei, deren Zahlen unveraendert bleiben.

Nebenbei zwei Verfaelschungen in STATE.md zurueckgesetzt, die Werkzeugaufrufe
hinterlassen hatten: eine Platzhalterzeile in der Quick-Task-Tabelle und
verfaelschte Fortschrittszahlen (3/82 statt 17/83).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:41:17 +02:00
schalli c80704957a docs(claude): Versionsangaben auf den installierten Stand bringen
- CLAUDE.md Technik-Block zeigt jetzt den installierten Stand statt der
  2026-06/07-Empfehlung: Next.js 15.5.19, Prisma 6.19.3, NestJS 11.1.27,
  Express 5.2.1, Node node:24-alpine (neu ergaenzt), Vitest je App
  (3.2.6 / 4.1.9), Docker/Compose als gemessene Wirtseigenschaft
- Authentifizierungs-Zeilen ersetzt: kein Identitaetsanbieter im Einsatz,
  sondern @nestjs/jwt, passport/@nestjs/passport, argon2, ldapts;
  @nestjs/passport-Zweckangabe korrigiert (Anmelde-/JWT-Strategien statt
  Modul-zu-Modul-API-Keys)
- Nie uebernommene Empfehlungen (Keycloak, Redis, TanStack Query, shadcn/ui,
  Playwright, Husky, lint-staged) sowie die beiden nicht aktualisierten
  Hauptversionen (Next.js, Prisma) in eigenem Abschnitt "Recommended But
  Not Adopted" statt in den Ist-Tabellen
- Herkunftsvermerk ergaenzt; Alternatives Considered/Version Pinning
  Strategy/Sources als historische Entscheidungslage gekennzeichnet;
  Multi-Tenancy Strategy verweist auf die tatsaechliche
  prisma-tenant.extension.ts
- .planning/research/STACK.md erhaelt eine Hinweiszeile, Zahlen darin
  unveraendert (datiertes Rechercheergebnis)
- Keine Abhaengigkeit aktualisiert (package.json/pnpm-lock.yaml
  unveraendert, per Gate geprueft)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:37:27 +02:00
schalli dab72eb1f9 fix(compose): user-files dauerhaft speichern und Betriebshandbuch nachziehen
- docker-compose.yml und docker-compose.prod.yml mounten /app/user-files
  im Dienst api auf ein neues benanntes Volume user-files (Eigentuemerschaft
  uid 1001 folgt aus dem Image, kein Bind-Mount)
- docker-compose.dev.yml bleibt unveraendert (Compose fuehrt Mount-Listen
  ueber das Ziel zusammen)
- Betriebshandbuch Kapitel 6: Speicher als dauerhaft beschrieben, Mount-Zeile
  woertlich zum Kopieren, Hinweis dass /opt/tessera/docker-compose.yml auf
  dem Server separat gepflegt werden muss (Deploy erreicht sie nicht)
- Betriebshandbuch Kapitel 7: Fehlerzeile zu verschwundenen Avataren/Exporten
  an den reparierten Repository-Stand angepasst

WINDOWS #17 bleibt offen, bis die Serverdatei manuell ergaenzt ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:34:15 +02:00
schalli 7b49747c72 docs(quick-260909-cx0): Plan fuer Dateisicherung und Versionskorrektur
Zwei unabhaengige Aufgaben: user-files als benanntes Volume in beide
Repository-Compose-Dateien plus Betriebshandbuch Kapitel 6/7 (WINDOWS #17),
und die Technik-Tabelle in CLAUDE.md auf den installierten Stand bringen.
Beide Abnahmetore vor der Arbeit als rot gemessen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:31:51 +02:00
23 changed files with 2681 additions and 80 deletions
+9
View File
@@ -2,6 +2,15 @@ DB_PASSWORD=your_db_password_here
DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
NODE_ENV=development
# DATABASE_URL ist die Verbindung, mit der die laufende Anwendung arbeitet.
# TESSERA_MIGRATE_DATABASE_URL ist die separate Verbindung, mit der beim
# Containerstart NUR "prisma migrate deploy" laeuft. Bleibt sie leer, laeuft
# alles wie bisher — beide Schritte nutzen DATABASE_URL. Die getrennte
# Belegung ist erst sinnvoll, wenn docs/mandantentrennung-datenbankrolle.md
# vollstaendig abgearbeitet ist; vorher wuerde eine ungeprüfte Umstellung die
# Anwendung von ihren eigenen Daten aussperren.
# TESSERA_MIGRATE_DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
# Encrypts stored credentials (LDAP bind password, calendar and mailbox logins).
# Required - the stack refuses to start without it.
# Generate one with: openssl rand -hex 32
+14 -8
View File
@@ -1,19 +1,20 @@
---
gsd_state_version: 1.0
gsd_state_version: "1.0"
milestone: v1.2
milestone_name: Plattform-Berechtigungen
current_phase: 17
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
status: verified
stopped_at: "WINDOWS #14 und #15 am 2026-09-09 auf alpha abgenommen und geschlossen. Der Sync legt die vier kollidierenden Konten jetzt an (ohne Adresse, erster Anspruch behaelt sie) und meldet das in verstaendlichem Deutsch statt in Prisma-Text; Benutzerliste und Mitgliedersuche vertragen Konten ohne Adresse. Die Matrix-Suche laesst die nicht getroffene Achse stehen, in allen vier geprueften Faellen, Regression #6c intakt. Das Ledger ist damit erstmals ohne offene Punkte: 15 behoben, 1 zurueckgestellt (#12, kein Alarm-Postfach vorhanden). Ebenfalls zurueckgestellt bleibt Abnahmeplan 02-05 (Mandantentrennung, intern zweitrangig)."
last_updated: "2026-09-09T08:45:00.000Z"
stopped_at: "Drei Vorhaben am 2026-09-09 abgearbeitet: Dateisicherung fuer user-files (benanntes Volume), Versionsangaben in CLAUDE.md auf den installierten Stand samt Abschnitt ueber sechs nie eingebaute Empfehlungen, und die Vorbereitung der Mandantentrennung. Bei letzterer kam der schwerste Befund der Sitzung heraus: die vorhandene Datenbank-Trennung wirkt gar nicht, weil die Anwendungsrolle sie umgeht (#18) — praktisch gemessen. Gebaut sind Rolle, Verbindungstrennung, Pruefwerkzeug und Policies fuer alle 16 offenen Tabellen; das Scharfschalten bleibt aus, weil 182 Zugriffe ohne Mandantenkontext im Code stehen und der Anmeldeweg darunter zwingend ist. OFFEN: #17 (Volume-Zeile auf dem Server nachtragen), #18 (Scharfschalten, braucht die 182 Stellen), #19 (nullable tenantId bei SearchProvider und TenderRssFeedSource)."
last_updated: "2026-09-09T10:10:00.000Z"
last_activity: 2026-09-09
last_activity_desc: Ledger ohne offene Punkte; Mandanten-Branding auf Wunsch des Users zurueckgestellt
last_activity_desc: Mandantentrennung vorbereitet — Rolle, Policies und Pruefwerkzeug gebaut, Umschalten bewusst offen
state_head: c80704957a5518e316a53edc0b8d7e98052a9750
progress:
total_phases: 17
completed_phases: 17
total_plans: 83
completed_plans: 83
milestone_name: Plattform-Berechtigungen
---
# Project State
@@ -288,6 +289,7 @@ Recent decisions affecting current work:
- [Phase ?]: [17-03]: createRssFeed-Antwort traegt kein isPlatformWide (nur GET mappt es) — Komponente leitet es lokal aus dem verwendeten scope ab (Rule 1)
- [Phase ?]: [17-03]: Anzeige-Rollenpruefung auf settings/page.tsx ueber useAuthStore (unbekannt/erlaubt/verweigert); verbindliche Pruefung bleibt serverseitig
- [Phase ?]: [17-03]: REQUIREMENTS.md SRC-01..05 nachtraeglich ergaenzt — Luecke aus 17-01/17-02, dort schon in SUMMARY-Frontmatter gefuehrt
- [Phase 17]: [260909-cx0]: Benanntes Volume user-files statt Bind-Mount (uid-1001-Eigentuemerschaft aus dem Image)
### Pitfalls & Anti-Patterns
@@ -335,6 +337,7 @@ None yet.
- [Roadmap v1.1]: Whether AI-AG NetServer / cosinex VMP search pages require JS rendering is unverified — needs a Phase 13 start-of-phase spike before committing to playwright.
- Phase 14 Plan 03 (14-03): Task 4 human-verify OPEN — needs a real portal-alert mailbox (incl. Exchange/EWS live path) from the operator before INGEST-05's Exchange path is production-ready. Tasks 1-3 complete and committed (4d6fbb1, 8983231, 1be6b15, 48e1252); API 387/387, web 144/144 green.
- ~~Phase 15 Plan 06 (15-06): manueller Browser-Durchklick nicht ausgefuehrt~~ — ERLEDIGT 2026-09-07, Gegenprobe WINDOWS.md unrun-verify #1 nachgeholt und bestanden
- [260909-cx0] WINDOWS #17 (user-files-Volume) bleibt offen: Repository-Fix committet, aber /opt/tessera/docker-compose.yml auf alpha weicht ab und muss vom Nutzer manuell um dieselben zwei Zeilen ergaenzt werden (vorher sichern), danach Container neu erstellen und Ledger schliessen (gsd-tools windows fixed 17)
### Quick Tasks Completed
@@ -363,6 +366,9 @@ None yet.
| 21 | Verschluesselungsschluessel in den Beispiel-Umgebungsdateien dokumentiert: .env.example hatte gar keinen Eintrag, .env.prod.example nannte noch den alten Namen CALENDAR_ENCRYPTION_KEY. Compose-Teil des Backlog-Punkts war bereits mit 7bda56d erledigt (Vorgabewert raus, :?-Abbruch statt Ersatzwert) | 2026-08-11 | 379606e | — |
| 260907-let | Verbindungstest fuer das Postfach im Ausschreibungs-Radar nachgeruestet (WINDOWS #16): POST /modules/tender-radar/email-config/test plus Knopf "Verbindung testen" im Formular unter Meine Quellen. Nutzt die vorhandene testConnection() beider Inbox-Provider, Muster vom DKV-Modul. userId ausschliesslich aus dem Auth-Kontext (eigener IDOR-Test mit Koeder-userId), leerer Benutzername oder leeres Passwort faellt auf die gespeicherten verschluesselten Zugangsdaten desselben Nutzers zurueck, keine Zugangsdaten in Logs oder Antwort. Verifiziert: 646/646 API- und 228/228 Web-Tests, beide Typpruefungen sauber, Sprachschluessel-Gate von rot auf gruen. Offen: Browser-Abnahme gegen ein echtes Postfach (Ende-der-Phase, braucht Neubau durch den User) | 2026-09-07 | c4db3b2 | [260907-let-verbindungstest-fuer-das-postfach-im-aus](./quick/260907-let-verbindungstest-fuer-das-postfach-im-aus/) |
| 260909-ab3 | Zwei Befunde aus der Live-Pruefung behoben. **#14:** Die Suche in der Freigaben-Matrix filterte beide Achsen mit demselben Begriff und leerte dadurch die jeweils andere — jetzt bleibt die nicht getroffene Achse vollstaendig stehen, die Gruppensuche unter internem UND AD-Namen (#6c) ist per Regressionstest gesichert. **#15:** AD-Konten mit bereits vergebener Mailadresse werden nun angelegt, nur ohne Adresse (Produktentscheidung des Users vom 2026-09-09; der erste Anspruch behaelt die Adresse), auf BEIDEN Wegen — Sync und Einzelimport. Rohe Prisma-Texte gehen nur noch ins Log, der Bericht zeigt drei verstaendliche deutsche Abschnitte. **Sicherheitsfund nebenbei geschlossen (T-Q3-01):** der Update-Zweig schrieb die Mailadresse bedingungslos um, ein Verzeichniseintrag haette so die Adresse einer echten Person uebernehmen und deren Passwort-Reset empfangen koennen. `User.email` ist jetzt optional (Migration geschrieben, laeuft beim naechsten API-Start automatisch mit). Verifiziert 8/8: 651/651 API- und 233/233 Web-Tests, beide Typpruefungen sauber; die Sicherheitspruefung wurde durch Rueckbau falsifiziert (ohne Besitzpruefung schlaegt der Test fehl). **Am 2026-09-09 im Browser abgenommen, beide Ledger-Punkte geschlossen** (Bericht: 260909-ab3-UAT-2026-09-09.md) | 2026-09-09 | 2167046 | [260909-ab3-matrix-suche-und-sync-meldungen-reparier](./quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/) |
| 260909-cx0 | Hochgeladene Dateien (Avatare, DKV-Exporte) ueberlebten kein `--force-recreate` des api-Containers (WINDOWS #17) — lagen nur in der fluechtigen Container-Schicht, keine Compose-Datei mountete `/app/user-files`. Jetzt benanntes Docker-Volume `user-files` in `docker-compose.yml` und `docker-compose.prod.yml` (Eigentuemerschaft uid 1001 aus dem Image, kein Bind-Mount), Betriebshandbuch Kapitel 6/7 entsprechend nachgezogen. Zweiter, unabhaengiger Punkt: CLAUDE.md nannte fuer die Technik-Tabelle noch die 2026-06/07-Empfehlung (Next.js 16.2.x, Prisma 7.8.x, Keycloak, Redis, TanStack Query, shadcn/ui, Playwright, Husky, lint-staged) statt des installierten Stands — jetzt korrigiert auf Next.js 15.5.19, Prisma 6.19.3 etc., nie uebernommene Empfehlungen in eigenem Abschnitt "Recommended But Not Adopted", `.planning/research/STACK.md` nur mit Hinweiszeile ergaenzt. Keine Abhaengigkeit aktualisiert. **WINDOWS #17 bleibt offen** — die Aenderung erreicht die laufende Installation auf alpha nicht, `/opt/tessera/docker-compose.yml` weicht vom Repository ab und muss vom Nutzer selbst ergaenzt werden | 2026-09-09 | dab72eb,c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) |
| 260909-cx0 | Dateisicherung nachgeruestet und Versionsangaben geradegezogen. **user-files** liegt jetzt in einem benannten Volume (docker-compose.yml und .prod.yml) — vorher lag der Ordner nur in der fluechtigen Container-Schicht, hochgeladene Profilbilder und DKV-Exporte waeren bei jedem --force-recreate weg gewesen. Benanntes Volume statt Bind-Mount, weil das Image /app/user-files an uid 1001 uebereignet; ein frisch angelegtes Host-Verzeichnis gehoert root und haette aus dem Datenverlust einen kaputten Upload gemacht. docker-compose.dev.yml blieb bewusst unveraendert (Compose fuehrt Mount-Listen ueber das Ziel zusammen). **CLAUDE.md** nennt jetzt die installierten Fassungen statt der urspruenglich empfohlenen (Next.js 15.5.19 statt 16, Prisma 6.19.3 statt 7); neu ist ein Abschnitt 'Recommended But Not Adopted', der sechs nie eingebaute Empfehlungen benennt — darunter Keycloak, Redis und shadcn/ui. Der Block ist generiert, deshalb traegt er einen Herkunftsvermerk und die Recherchedatei eine datierte Hinweiszeile; ihre Zahlen blieben unangetastet. Keine Abhaengigkeit angefasst (per git diff gegengeprueft). **WINDOWS #17 bleibt offen**, bis der User dieselbe Volume-Zeile in /opt/tessera/docker-compose.yml nachtraegt — die Serverdatei weicht vom Repository ab | 2026-09-09 | c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) |
| 260909-dgj | Mandantentrennung auf Datenbankebene vorbereitet (WINDOWS #18). Ausloeser war ein gemessener Befund: die Anwendung verbindet als Rolle mit Superuser- und BYPASSRLS-Recht, daher greifen die sieben vorhandenen Policies gar nicht — ohne gesetzten Mandantenkontext lieferte 'SELECT count(*) FROM Group' zwei statt null Zeilen. Gebaut wurden: Rolle `tessera_app` ohne Umgehungsrecht (wiederholbare Migration, kein Kennwort im SQL), Trennung von Migrations- und Laufzeitverbindung ueber TESSERA_MIGRATE_DATABASE_URL, ein Pruefwerkzeug mit fuenf transaktionssicheren Nachweisen, Policies fuer die 16 fehlenden Tabellen und eine Betriebsanleitung. **Der Schalter bleibt bewusst aus, #18 bleibt offen:** im Code stehen 182 Datenbankzugriffe ohne Mandantenkontext gegen 19 mit — darunter zwingend der Anmeldeweg, der die Benutzerzeile liest, bevor der Mandant bekannt ist (er kommt erst aus dieser Zeile). Ein Umschalten wuerde die Anmeldung fuer alle sperren. Dabei fiel #19 an: SearchProvider und TenderRssFeedSource haben ein nullable tenantId; die einfache Policy wuerde die plattformweiten Zeilen nach dem Scharfschalten fuer jeden Mandanten unsichtbar machen. 673/673 Tests gruen | 2026-09-09 | efaabc9 | [260909-dgj-mandantentrennung-auf-alle-tabellen-mit-](./quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/) |
## Deferred Items
@@ -402,7 +408,7 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
Last session: 2026-09-09T08:45:00.000Z
Stopped at: Nichts in Arbeit, nichts offen, nichts vorgemerkt. Das Broken-Windows-Ledger ist leer (15 behoben, 1 zurueckgestellt). Zurueckgestellt sind: WINDOWS #12 (kein Postfach fuer Ausschreibungs-Alarme vorhanden), Abnahmeplan 02-05 (Mandantentrennung, solange Tessera nur intern laeuft), das Mandanten-Branding (Entscheidung des Users vom 2026-09-09) und die Lizenzpruefung bei der Modulaktivierung (ruht bis alle Module intern laufen). Damit gibt es derzeit KEINEN vorgemerkten naechsten Schritt — der naechste Anstoss kommt vom User.
Last session: 2026-09-09T10:10:00.000Z
Stopped at: Alles Beauftragte erledigt. Offen im Ledger: #17 (der User traegt die Volume-Zeile in /opt/tessera/docker-compose.yml nach), #18 (Mandantentrennung scharf schalten — braucht die 182 unskalierten Zugriffe, eigener Vorgang) und #19 (nullable tenantId, zusammen mit #18 zu loesen). Zurueckgestellt bleiben #12, Abnahmeplan 02-05, Mandanten-Branding und die Lizenzpruefung.
Resume file: None
Last activity: 2026-09-09 - Ledger geschlossen, Mandanten-Branding zurueckgestellt
Last activity: 2026-09-09 - Dateisicherung, Versionsangaben und Vorbereitung der Mandantentrennung
+29 -3
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 1
open_count: 3
waived_count: 1
fixed_count: 15
total_count: 17
last_updated: 2026-09-09T06:42:22.801Z
total_count: 19
last_updated: 2026-09-09T08:08:19.293Z
---
# Broken Windows Ledger
@@ -32,6 +32,8 @@ last_updated: 2026-09-09T06:42:22.801Z
| 15 | 16 | unmet-truth | apps/api/src/ldap/ldap.service.ts | | Der Sync reicht rohe Techniktexte an den Administrator durch und laesst echte AD-Konten still liegen. Am 2026-09-07 auf alpha gegen das echte AD gemessen (Lauf 14:42): zehn Fehlerzeilen unter den drei Zahlenzeilen, davon zwei Sorten. (1) Vier Konten scheitern mit der woertlichen Prisma-Meldung 'Invalid prisma.user.create() invocation: Unique constraint failed on the fields: (email)' — CN=uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss, usoftware_rw aus OU=CTL_PWS_Gruppen teilen sich offenbar eine E-Mail-Adresse. Sie werden dadurch NIE importiert, ohne dass der Administrator erfaehrt warum oder was er tun soll. (2) Sechs Eintraege melden englisch 'no username mapped (check sAMAccountName mapping)' — korrekt uebersprungene Kontakte/Ressourcen ohne sAMAccountName, aber die Meldung liest sich wie ein Fehler und ist nicht uebersetzt. Beides braucht eine verstaendliche deutsche Meldung; die E-Mail-Kollision zusaetzlich eine Entscheidung, ob solche Konten ohne E-Mail angelegt oder bewusst uebersprungen werden. Beleg: .planning/phases/16-ad-gruppen-synchronisation/uat-2026-09-07/windows6a-sync-zahlenzeilen.png | fixed | | 2026-09-07T12:47:32.058Z | 2026-09-09T06:25:18.145Z |
| 16 | 14 | unmet-truth | apps/api/src/tenders/tenders.controller.ts | | Das Postfach im Ausschreibungs-Radar hat keinen Verbindungstest, obwohl die Faehigkeit fertig vorliegt. Beide Inbox-Provider bringen testConnection() mit (imap.provider.ts:343, exchange-inbox.provider.ts:294), und das DKV-Modul nutzt sie ueber POST /dkv/test-connection samt Knopf 'Verbindung testen' im InboxConfigForm. Beim Ausschreibungs-Radar fehlt beides: der Controller kennt zu email-config nur GET (:343) und PUT (:357), kein Test-Endpunkt, und das Formular unter Meine Quellen hat keinen Knopf. Historie geprueft: der Knopf war nie vorhanden (git log -S testConnection im tender-radar-Frontend ist leer), es ist also eine Luecke, keine Regression. Folge fuer den Betrieb: ein Tippfehler in der EWS-Endpunkt-URL oder falsche Zugangsdaten fallen erst auf, wenn dauerhaft nichts ankommt — und dann ist nicht unterscheidbar, ob die Verbindung scheitert oder schlicht keine Alarm-Mail da war. Das trifft besonders WINDOWS #12, dessen ganzer Zweck der Beleg des handgeschriebenen NTLM/SOAP-Wegs ist. Aufgefallen am 2026-09-07 beim Einrichten des Postfachs. | fixed | | 2026-09-07T13:22:33.916Z | 2026-09-09T05:20:09.851Z |
| 17 | 6 | unmet-truth | docker-compose.yml | | Hochgeladene Dateien ueberleben kein Neuerstellen der Container. Der Code legt sie unter user-files/ ab (user.controller.ts:40 und :266 fuer Profilbilder, dazu die DKV-Exporte), aber KEINE der Compose-Dateien mountet dieses Verzeichnis — weder im Repository (docker-compose.yml, .prod.yml, .dev.yml haben nur das Volume pgdata) noch in der abweichenden Datei auf dem Server /opt/tessera/docker-compose.yml. Am 2026-09-09 gemessen: 'docker inspect' auf tessera-api-1 meldet ueberhaupt keinen Mount, /app/user-files liegt damit nur in der beschreibbaren Container-Schicht und ist bei jedem 'up -d --force-recreate' weg. Aufgefallen beim Schreiben des Betriebshandbuchs. KEIN Schaden entstanden: aktuell hat kein Nutzer ein Profilbild hinterlegt (avatarPath ueberall NULL), und die bisherigen Neuerstellungen trafen einen leeren Ordner. Die Luecke schlaegt zu, sobald der erste Nutzer ein Bild hochlaedt oder ein DKV-Export aufgehoben werden soll. Behebung: ein benanntes Volume oder Bind-Mount fuer user-files in beiden Compose-Dateien; die Server-Datei muss zusaetzlich von Hand ergaenzt werden, weil sie vom Repository abweicht. | open | | 2026-09-09T06:42:22.801Z | |
| 18 | 2 | unmet-truth | docker-compose.yml | | Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM "Group"' zwei Zeilen, waehrend die Policy USING ("tenantId" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend. | open | | 2026-09-09T07:42:13.878Z | |
| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. | open | | 2026-09-09T08:08:19.293Z | |
````json
[
@@ -238,6 +240,30 @@ last_updated: 2026-09-09T06:42:22.801Z
"reason": "",
"recorded_at": "2026-09-09T06:42:22.801Z",
"resolved_at": null
},
{
"id": 18,
"kind": "unmet-truth",
"phase": "2",
"file": "docker-compose.yml",
"line": null,
"description": "Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM \"Group\"' zwei Zeilen, waehrend die Policy USING (\"tenantId\" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T07:42:13.878Z",
"resolved_at": null
},
{
"id": 19,
"kind": "unmet-truth",
"phase": "2",
"file": "apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql",
"line": null,
"description": "Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T08:08:19.293Z",
"resolved_at": null
}
]
````
@@ -0,0 +1,435 @@
---
quick_id: 260909-cx0
slug: dateisicherung-nachruesten-und-versionsa
date: 2026-09-09
status: planned
relates_to: 06-desktop-client-ci-cd, 07-dkv-fleet-module
windows_ref: 17
severity: medium
phase: quick-260909-cx0
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- docker-compose.yml
- docker-compose.prod.yml
- docs/anleitung-betrieb.md
- CLAUDE.md
- .planning/research/STACK.md
autonomous: true
requirements: [WINDOWS-17]
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low
must_haves:
truths:
- "Ein Neuerstellen der Container (`up -d --force-recreate api`) auf Basis der Compose-Dateien aus dem Repository laesst hochgeladene Profilbilder und DKV-Exporte bestehen — sie liegen dann ausserhalb der beschreibbaren Container-Schicht."
- "Die gerenderte Konfiguration von `docker-compose.yml`, von `docker-compose.prod.yml` und der Kombination Basis + `docker-compose.dev.yml` traegt fuer den Dienst `api` je genau einen Mount mit dem Ziel `/app/user-files`."
- "Der Speicherort ist ein benanntes Volume, kein Host-Verzeichnis — der API-Prozess laeuft als uid 1001 und darf ohne Zutun des Betreibers hineinschreiben."
- "Das Betriebshandbuch (Kapitel 6) beschreibt den Speicher als dauerhaft, nennt die Mount-Zeile woertlich zum Uebernehmen in die abweichende Serverdatei und behauptet nicht mehr, `pgdata` sei der einzige dauerhafte Datenspeicher."
- "Das Betriebshandbuch sagt ausdruecklich, dass die Aenderung im Repository die laufende Installation NICHT erreicht und der Betreiber sie in `/opt/tessera/docker-compose.yml` selbst eintragen muss."
- "CLAUDE.md nennt fuer jede aufgefuehrte Technik die Fassung, die `pnpm install` tatsaechlich aufloest beziehungsweise die in den Compose-/Dockerfiles steht."
- "Technik, die empfohlen, aber nie eingebaut wurde, steht in CLAUDE.md nicht mehr als Bestandteil des Systems, sondern sichtbar als nicht uebernommene Empfehlung mit Angabe dessen, was stattdessen laeuft."
- "CLAUDE.md und `docs/anleitung-entwicklung.md` widersprechen einander in keiner Versionsangabe mehr."
- "Keine Abhaengigkeit wurde aktualisiert: `package.json` (Wurzel und je App) und `pnpm-lock.yaml` sind gegenueber dem Ausgangsstand unveraendert."
artifacts:
- docker-compose.yml
- docker-compose.prod.yml
- docs/anleitung-betrieb.md
- CLAUDE.md
- .planning/research/STACK.md
key_links:
- "`apps/api/Dockerfile:23-26` (Verzeichnis `/app/user-files` gehoert uid 1001 `nestjs`) plus `:36` (`USER nestjs`) <-> Wahl der Mount-Art: ein leeres benanntes Volume uebernimmt diese Eigentuemerschaft beim ersten Anlegen, ein frisch von Docker erzeugtes Host-Verzeichnis gehoert root. Deshalb benanntes Volume."
- "`apps/api/src/user/user.controller.ts:40` und `apps/api/src/dkv/dkv-export.service.ts:59` loesen beide vier Ebenen ueber `dist/` hinaus auf und landen im Container auf `/app/user-files` — exakt dem Mount-Ziel. Ein anderes Ziel wuerde am Schreibort vorbei mounten und die Luecke offen lassen."
- "Repository-Compose <-> `/opt/tessera/docker-compose.yml`: es gibt keine Verbindung. Der Deploy holt nur Images; die Aenderung wirkt erst, wenn der Betreiber sie dort selbst eintraegt."
- "CLAUDE.md-Block `GSD:stack-start source:research/STACK.md` <-> `.planning/research/STACK.md`: der Block ist eine woertliche Kopie der Empfehlung von 2026-06/07. Ohne Vermerk auf beiden Seiten holt eine kuenftige Regeneration die falschen Zahlen zurueck."
---
<objective>
Zwei kleine, voneinander vollstaendig unabhaengige Punkte. Getrennte Aufgaben, getrennte Commits.
**Punkt A (WINDOWS #17) — hochgeladene Dateien ueberleben kein Neuerstellen der Container.**
Der Code legt Profilbilder und DKV-Exporte unter `user-files/` ab, aber keine Compose-Datei
im Repository mountet dieses Verzeichnis. Es existiert damit nur in der beschreibbaren
Container-Schicht und ist bei jedem `up -d --force-recreate` weg. Bisher ist kein Schaden
entstanden — es hat noch kein Nutzer ein Profilbild hinterlegt —, aber der erste Upload
faellt in dieselbe Grube.
**Punkt B — CLAUDE.md nennt Fassungen, die nicht installiert sind.**
Die Technik-Tabelle ist eine woertliche Kopie der Stack-Empfehlung von 2026-06/07 und
beschreibt einen Wunschzustand: Next.js 16.2.x statt der installierten 15.5.19, Prisma 7.8.x
statt 6.19.3, dazu Keycloak, Redis, TanStack Query, shadcn/ui, Playwright, Husky und
lint-staged, von denen nichts im Projekt existiert. Das neue Entwicklungshandbuch
(`docs/anleitung-entwicklung.md`) dokumentiert bewusst den echten Stand — beide Dokumente
widersprechen sich damit schriftlich.
Purpose: Nutzerdaten ueberstehen einen Redeploy, und wer neu dazustoesst, liest in CLAUDE.md,
was wirklich installiert ist, statt was einmal empfohlen wurde.
Output: Zwei getrennte, jeweils fuer sich lauffaehige Commits — A (Datenhaltung + Handbuch),
B (Dokumentationskorrektur).
## Gemessener Ausgangszustand (am Arbeitsbaum geprueft, 2026-09-09)
### Punkt A
| Beleg | Fundstelle |
|-------|------------|
| Profilbilder werden nach `user-files/avatars/` geschrieben | `apps/api/src/user/user.controller.ts:40` (`path.resolve(__dirname, '..','..','..','..','user-files','avatars')`), Ablage `:249-266` |
| DKV-Exporte werden nach `user-files/` geschrieben | `apps/api/src/dkv/dkv-export.service.ts:59` (gleiche Aufloesung), Schreiben `:135` |
| Im Image ist der Zielpfad `/app/user-files`, angelegt und uebereignet an uid 1001 | `apps/api/Dockerfile:21` (`WORKDIR /app`), `:23-26` (`mkdir -p /app/user-files` + `chown nestjs:nestjs`), `:36` (`USER nestjs`) |
| `docker-compose.yml` kennt nur ein Volume, `pgdata`; der Dienst `api` hat gar keinen `volumes`-Block | `docker-compose.yml:20-65` (api) und `:92-93` (Top-Level-Volumes) |
| `docker-compose.prod.yml` ebenso | `docker-compose.prod.yml:22-61` (api) und `:89-90` |
| `docker-compose.dev.yml` mountet nur Quellcode und Prisma-Schema | `docker-compose.dev.yml:7-10` |
| Gerenderte Konfiguration traegt fuer `api` keinen Mount auf `/app/user-files` — in allen drei Kombinationen | selbst gemessen mit `docker compose config --format json` fuer Basis, Prod und Basis+Dev: dreimal `FEHLT` |
| Das Betriebshandbuch beschreibt die Luecke bereits und raet zu manuellem Herauskopieren | `docs/anleitung-betrieb.md:278-290` (Kapitel 6) und `:316` (Fehlertabelle Kapitel 7) |
### Punkt B
Verglichen wurden die Tabellenwerte in `CLAUDE.md:26-97` gegen `package.json` (Wurzel und je App)
und die in `pnpm-lock.yaml` aufgeloesten Fassungen (`importers:`-Abschnitt), dazu die
Compose- und Dockerfiles.
| CLAUDE.md sagt | Tatsaechlich installiert (2026-09-09) |
|---|---|
| Next.js 16.2.x (`:39`) | **15.5.19** (Vorgabe `^15.3.0`) |
| Prisma 7.8.x (`:56`) | **6.19.3** — `prisma` und `@prisma/client`, Vorgabe `^6.0.0`; auch `apps/api/Dockerfile:33` nennt 6.19.3 |
| Keycloak 26.6.x als Identitaetsanbieter (`:64`), `nest-keycloak-connect` (`:65`) | **nicht vorhanden** — kein Keycloak-Dienst in irgendeiner Compose-Datei, kein Paket im Lockfile. Angemeldet wird ueber `@nestjs/jwt` 11.0.2, `passport` 0.7.0, `argon2` 0.44.0, Verzeichnisanbindung ueber `ldapts` 8.1.8 |
| Redis 7.x als Cache/Sitzungsspeicher (`:58`) | **nicht vorhanden** — kein Dienst, kein Paket |
| TanStack Query 5.101.x (`:48`) | **nicht installiert** |
| shadcn/ui CLI v4 (`:43`) | **nicht benutzt** — keine `components.json`, kein `components/ui`-Verzeichnis |
| Playwright 1.x als E2E-Test (`:88`) | **keine Projektabhaengigkeit** — Browserpruefungen laufen ueber das Playwright-MCP-Werkzeug, im Repository existiert keine E2E-Suite und keine `playwright.config.*` |
| Husky 9.x (`:96`), lint-staged 15.x (`:97`) | **nicht installiert**, kein `.husky`-Verzeichnis |
| Vitest 3.x (`:87`) | gemischt: `apps/api` 3.2.6, `apps/web` **4.1.9** |
| Docker 27.x / Compose 2.x (`:78-79`) | Wirtseigenschaft, vom Repository nicht gesetzt; auf dieser Maschine gemessen: Docker 29.8.0, Compose v5.5.1 |
| Tauri 2.x (`:72`) | 2.11.1 / CLI 2.11.3 — stimmt; `apps/desktop` ist laut `docs/anleitung-entwicklung.md:38-40` bisher nur Geruest |
| pnpm 9.x, Turborepo 2.9.x, React 19.x, TypeScript 5.5+, Tailwind 4.3.x, next-themes 0.4.x, next-intl 4.13.x, react-grid-layout 2.2.x, Zustand 5.0.x, NestJS 11.x, Express 5.x, PostgreSQL 16.x, Biome 2.x, Testing Library | stimmen. Genau: pnpm 9.15.0, Turborepo 2.9.18, React/React-DOM 19.2.7, TypeScript 5.9.3, Tailwind 4.3.1, next-themes 0.4.6, next-intl 4.13.0, react-grid-layout 2.2.3, Zustand 5.0.14, NestJS 11.1.27, Express 5.2.1 (mittelbar ueber `@nestjs/platform-express`), `postgres:16-alpine`, Biome 2.5.0, `@testing-library/react` 16.3.2 |
| (fehlt in der Tabelle) | Node 24 — beide produktiven Dockerfiles ziehen `node:24-alpine` |
## Zur Verteilung auf den Server (bitte woertlich weitergeben, NICHT ausfuehren)
`/opt/tessera/docker-compose.yml` ist **keine** Arbeitskopie dieses Repositorys. Die Datei
wurde dort von Hand bearbeitet und weicht ab (Sicherung `docker-compose.yml.bak.20260811`).
Der Deploy holt ausschliesslich Images. Eine Aenderung an den Compose-Dateien im Repository
erreicht die laufende Installation deshalb **nie**.
Damit die Reparatur auf alpha wirkt, muss der Nutzer dieselben zwei Zeilen selbst in
`/opt/tessera/docker-compose.yml` eintragen und die Container einmal neu erstellen. Dieser
Plan sieht dafuer **keine** Handlung vor: kein SSH, kein `docker`-Aufruf gegen irgendeinen
Server, keine Datei ausserhalb dieses Arbeitsbaums. Das Betriebshandbuch bekommt die
Anleitung dafuer schriftlich, damit sie nicht in einer Sitzung verloren geht.
Solange das nicht geschehen ist, bleibt der Ledger-Eintrag WINDOWS #17 **offen** — die
Luecke besteht auf der laufenden Installation weiter. Der Executor schliesst ihn nicht;
das Schliessen ist die Entscheidung des Nutzers, nachdem er die Serverdatei ergaenzt hat
(`gsd-tools windows fixed 17`).
## Zur Wahl der Speicherart (Begruendung, wie gefordert)
Gewaehlt: **benanntes Volume** `user-files`, gemountet auf `/app/user-files`.
Ausschlaggebend ist die Eigentuemerschaft. Das Image legt `/app/user-files` an und uebereignet
es uid 1001 (`apps/api/Dockerfile:23-26`), der Prozess laeuft als dieser Nutzer (`:36`). Ein
leeres benanntes Volume uebernimmt beim ersten Mounten Inhalt und Eigentuemerschaft des
Image-Verzeichnisses — Schreiben funktioniert sofort. Ein Host-Verzeichnis, das Docker beim
Start neu anlegt, gehoert dagegen root; der API-Prozess koennte nicht hineinschreiben, und aus
einem Datenverlust wuerde ein kaputter Upload. Ein Bind-Mount waere also nur mit einer
zusaetzlichen Handlung des Betreibers (Verzeichnis anlegen und auf 1001 uebereignen) korrekt —
genau die Art stiller Voraussetzung, die auf dem Server erfahrungsgemaess untergeht.
Zur Sicherung passt das: das Betriebshandbuch nennt in Kapitel 6 bereits
`docker compose cp api:/app/user-files ./user-files-backup`, und dieser Befehl funktioniert
mit einem benannten Volume unveraendert weiter — nur ist er kuenftig eine Sicherung und keine
Rettung mehr. Zusaetzlich passt die Wahl zum bereits vorhandenen `pgdata`, das dieselbe Form hat.
Das Handbuch muss dennoch angefasst werden, weil Kapitel 6 heute das Gegenteil behauptet.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
Dateien, die beim Umsetzen gelesen werden muessen:
@docker-compose.yml
@docker-compose.prod.yml
@docs/anleitung-betrieb.md
Belege, die nicht veraendert werden (nur zum Nachschlagen):
- `apps/api/Dockerfile` — Zeilen 21-26 und 36 begruenden die Wahl des benannten Volumes.
- `apps/api/src/user/user.controller.ts:40` und `apps/api/src/dkv/dkv-export.service.ts:59`
— beide Schreibpfade, beide landen im Container auf `/app/user-files`.
- `docs/anleitung-entwicklung.md` — nennt bereits den echten Stand (pnpm 9.15.0, `node:24-alpine`,
Biome, Vitest in beiden Apps). Aufgabe B muss dazu passen, nicht davon abweichen.
- `pnpm-lock.yaml`, Abschnitt `importers:` — die verbindliche Quelle fuer die aufgeloesten
Fassungen. Nicht die `package.json`-Vorgaben (`^15.3.0`) als Fassung ausgeben.
Verbindliche Konventionen aus dem Bestand:
- Die Handbuecher unter `docs/` sind deutsch mit echten Umlauten (`für`, `über`). Neue Saetze
dort in derselben Schreibweise. Der Umlaut-Test in `apps/web/src/messages` betrifft nur die
Oberflaechentexte, nicht diese Dokumente.
- Der Technik-Block in CLAUDE.md ist englisch. Er bleibt englisch — dies ist eine
Zahlenkorrektur, keine Uebersetzung.
</context>
<tasks>
<task type="auto">
<name>Task 1: user-files dauerhaft speichern und das Betriebshandbuch nachziehen (WINDOWS #17)</name>
<files>docker-compose.yml, docker-compose.prod.yml, docs/anleitung-betrieb.md</files>
<precondition>Die Docker-CLI ist lokal aufrufbar (`docker compose version` antwortet). Die Pruefung rendert ausschliesslich Konfiguration und startet, baut und stoppt nichts.</precondition>
<reversibility rating="costly">Die Speicherart laesst sich spaeter aendern, aber nicht folgenlos: ein Wechsel auf ein Host-Verzeichnis erfordert einmaliges Umkopieren des Volume-Inhalts und eine Uebereignung an uid 1001.</reversibility>
<action>
Beide Compose-Dateien im Repository bekommen denselben Zusatz. Nichts anderes aendern —
keine Umgebungsvariablen, keine Ports, keine Healthchecks.
In `docker-compose.yml`: beim Dienst `api` einen Block `volumes:` einfuegen (Einrueckung wie
bei `networks:` desselben Dienstes) mit dem einzigen Eintrag `- user-files:/app/user-files`.
Sinnvolle Stelle ist direkt vor `healthcheck:` (heute Zeile 60). Anschliessend im
Top-Level-Block `volumes:` (heute Zeile 92-93) neben `pgdata:` einen zweiten Eintrag
`user-files:` ergaenzen — ohne Wert, das ist ein benanntes Volume mit Vorgaben.
In `docker-compose.prod.yml`: identisch, Dienst `api` (Block vor `healthcheck:`, heute
Zeile 56) und Top-Level-Block `volumes:` (heute Zeile 89-90).
`docker-compose.dev.yml` bleibt unveraendert. Grund, gemessen: Compose fuehrt die
Mount-Listen ueber das Ziel zusammen, die Kombination Basis + Dev traegt den neuen Mount
also automatisch mit — nachgewiesen an der gerenderten Konfiguration. Ein zweiter Eintrag
dort waere Doppelpflege.
Danach `docs/anleitung-betrieb.md`, Kapitel 6, Abschnitt "Was sonst noch an Zustand
existiert" (heute Zeile 269-290). Drei Dinge:
(1) Der erste Aufzaehlungspunkt (`PostgreSQL-Daten`, Zeile 271-277) behauptet, `pgdata` sei
der einzige dauerhafte Datenspeicher. Das stimmt nach dieser Aenderung nicht mehr — Satz so
umformulieren, dass es zwei benannte Volumes gibt. Den vorhandenen Schreibfehler
"daürhafte" dabei mitkorrigieren.
(2) Der Aufzaehlungspunkt `Hochgeladene Dateien` (Zeile 278-290) wird ersetzt. Er muss
kuenftig sagen: die Dateien liegen im benannten Volume `user-files`, gemountet auf
`/app/user-files` im Dienst `api`, eingetragen in `docker-compose.yml` und
`docker-compose.prod.yml`; sie ueberstehen ein `--force-recreate`; gesichert werden sie
weiterhin mit `docker compose cp api:/app/user-files ./user-files-backup`, alternativ ueber
eine Sicherung des Volumes; das Volume traegt im `docker volume ls` den Projektnamen als
Praefix (`<projekt>_user-files`). Die Verweise auf `apps/api/src/user/user.controller.ts`
und `apps/api/src/dkv/dkv-export.service.ts` als Schreibstellen bleiben erhalten. Zeigen Sie
dabei einen kurzen YAML-Ausschnitt mit genau den zwei neuen Zeilen — die Dienst-Zeile in der
Form `user-files:/app/user-files` und den Top-Level-Eintrag —, damit ein Betreiber sie
kopieren kann. Diese Zeichenkette ist Teil der Abnahmepruefung.
(3) Ein deutlich abgesetzter Hinweis im selben Abschnitt: `/opt/tessera/docker-compose.yml`
auf dem Server ist keine Arbeitskopie des Repositorys, wurde von Hand bearbeitet und wird
von einem Deploy nicht angefasst. Wer die Reparatur dort haben will, traegt dieselben zwei
Zeilen selbst ein (vorher sichern) und erstellt die Container einmal neu. Bis dahin gilt
fuer die laufende Installation weiterhin der alte, verlustbehaftete Zustand; pruefbar mit
`docker inspect tessera-api-1` und einem Blick auf `Mounts`.
Schliesslich Kapitel 7, Fehlertabelle, Zeile 316 ("Avatare/DKV-Exporte nach einem Deploy
verschwunden"): die Ursachenspalte trifft nach dieser Aenderung nur noch auf eine
Installation zu, deren Compose-Datei den Mount nicht hat. Zeile entsprechend umschreiben
und als Abhilfe den Eintrag der zwei Zeilen samt Verweis auf Kapitel 6 nennen, nicht mehr
das vorherige "langfristig ergaenzen".
Ausdruecklich nicht Teil dieser Aufgabe: irgendein Aufruf gegen alpha oder einen anderen
Server, `docker compose up`, `pull`, `restart` oder das Anlegen von Verzeichnissen auf
einem Host.
</action>
<verify>
<automated>node -e "const {execFileSync}=require('child_process');const fs=require('fs');const env={...process.env,TESSERA_ENCRYPTION_KEY:'x',DATABASE_URL:'x',JWT_SECRET:'x',DB_PASSWORD:'x',TESSERA_ADMIN_EMAIL:'x',TESSERA_ADMIN_PASSWORD:'x'};const r=a=>JSON.parse(execFileSync('docker',['compose',...a,'config','--format','json'],{env}));const c=(l,cfg)=>{const m=(cfg.services.api.volumes||[]).filter(v=>v.target==='/app/user-files');console.log(l,m.length===1?'OK '+m[0].type+':'+m[0].source:'FEHLT');return m.length===1;};const doc=fs.readFileSync('docs/anleitung-betrieb.md','utf8').includes('user-files:/app/user-files');console.log('Betriebshandbuch nennt die Mount-Zeile',doc?'OK':'FEHLT');const res=[c('basis',r(['-f','docker-compose.yml'])),c('prod',r(['-f','docker-compose.prod.yml'])),c('basis+dev',r(['-f','docker-compose.yml','-f','docker-compose.dev.yml'])),doc];process.exit(res.every(Boolean)?0:1)"</automated>
<note>Am 2026-09-09 gegen den unveraenderten Arbeitsbaum ausgefuehrt: alle vier Pruefungen melden FEHLT, Rueckgabewert 1. Gegen eine Kopie mit dem hier beschriebenen Zusatz melden die drei Compose-Pruefungen `OK volume:user-files`, Rueckgabewert 0. Das Tor ist also echt rot und wird durch genau diese Aenderung gruen.</note>
</verify>
<done>
Alle drei gerenderten Konfigurationen (Basis, Prod, Basis+Dev) tragen fuer den Dienst `api`
genau einen Mount vom Typ `volume` mit Quelle `user-files` auf `/app/user-files`; beide
Compose-Dateien fuehren `user-files` als benanntes Top-Level-Volume neben `pgdata`;
`docker-compose.dev.yml` ist unveraendert; Kapitel 6 des Betriebshandbuchs beschreibt den
Speicher als dauerhaft, nennt die Mount-Zeile woertlich, nennt den Sicherungsbefehl und
weist auf die abweichende Serverdatei hin; die Fehlerzeile in Kapitel 7 passt dazu; kein
Aufruf gegen einen Server wurde ausgefuehrt; WINDOWS #17 bleibt offen.
</done>
</task>
<task type="auto">
<name>Task 2: Versionsangaben in CLAUDE.md auf den installierten Stand bringen</name>
<files>CLAUDE.md, .planning/research/STACK.md</files>
<reversibility rating="reversible">Reine Dokumentationsaenderung, jederzeit zuruecknehmbar.</reversibility>
<action>
Nur Text. Keine Abhaengigkeit wird aktualisiert, kein `pnpm add`, kein `pnpm update`,
`package.json` und `pnpm-lock.yaml` bleiben unangetastet.
Zuerst die Zahlen selbst nachschlagen und nicht aus diesem Plan uebernehmen: `pnpm-lock.yaml`,
Abschnitt `importers:`, liefert je Arbeitsbereich Vorgabe und aufgeloeste Fassung. Verbindlich
ist die aufgeloeste Fassung. Fuer Dienste ausserhalb von npm gelten die Compose- und
Dockerfiles (`postgres:16-alpine`, `node:24-alpine`). Die Tabelle im `objective` dieses Plans
ist der am 2026-09-09 gemessene Stand und dient als Gegenprobe — weicht Ihr Befund ab,
zaehlt Ihr Befund, und die Abweichung gehoert in die Zusammenfassung.
Dann den Block zwischen `<!-- GSD:stack-start ... -->` und `<!-- GSD:stack-end -->`
(CLAUDE.md Zeile 22-169) ueberarbeiten. Der Block bleibt englisch.
(a) Direkt unter die Ueberschrift `## Technology Stack` einen kurzen Herkunftsvermerk setzen:
dass die Tabellen den installierten Stand zeigen, am 2026-09-09 gegen `package.json`,
`pnpm-lock.yaml` und die Compose-/Dockerfiles geprueft; dass die urspruengliche Empfehlung
von 2026-06/07 in `.planning/research/STACK.md` liegt; und dass eine Regeneration dieses
Blocks aus jener Datei die Zahlen wieder verfaelschen wuerde.
(b) In den Technik-Tabellen jede Versionsangabe auf die tatsaechlich aufgeloeste Fassung
setzen. Die Spaltenueberschrift so benennen, dass klar ist, dass dort der Ist-Stand steht.
Ergaenzen Sie eine Zeile fuer Node (`node:24-alpine`, aus beiden produktiven Dockerfiles),
weil das die verbindliche Laufzeit ist und bisher fehlt. Vitest bekommt beide Fassungen mit
Angabe der App, weil sie sich unterscheiden. Bei Docker und Docker Compose gehoert dazu,
dass es Wirtseigenschaften sind, die das Repository nicht festlegt — mit der auf der
Entwicklungsmaschine gemessenen Fassung und Datum. Bei den Authentifizierungs-Zeilen tritt
an die Stelle des nie eingebauten Identitaetsanbieters, was wirklich laeuft: `@nestjs/jwt`,
`passport` mit `@nestjs/passport`, `argon2` fuer Passwoerter und `ldapts` fuer die
Verzeichnisanbindung; `@nestjs/passport` traegt heute ausserdem eine falsche Zweckangabe
(angeblich Schluessel-Authentifizierung zwischen Modulen) — richtig ist der Einsatz fuer die
Anmelde- und JWT-Strategien.
(c) Alles, was empfohlen, aber nie uebernommen wurde, verschwindet aus den Ist-Tabellen und
erscheint stattdessen in einem neuen, deutlich benannten Abschnitt im selben Block, direkt
unter den Tabellen: die Empfehlung, was stattdessen im Einsatz ist, und woher die Empfehlung
stammt. Betroffen sind der Identitaetsanbieter samt zugehoerigem NestJS-Paket, der
Cache-Dienst, die Server-State-Bibliothek, die Komponentenbibliothek, der E2E-Testlaeufer und
die beiden Git-Hook-Werkzeuge; ebenso die beiden Faelle, in denen zwar dieselbe Technik, aber
ein aelterer Hauptstand installiert ist (Next.js und Prisma) — dort mit dem klaren Vermerk,
dass die neuere Fassung empfohlen, aber nicht uebernommen wurde, und ohne jede Aussage
darueber, ob eine Aktualisierung geplant sei.
Dieser Abschnitt MUSS eine Aufzaehlung sein, keine Tabelle. Die Abnahmepruefung verwirft
jede Zeile, die eine dieser Techniken als erste Tabellenzelle fuehrt — das ist genau die
Form, die "ist eingebaut" behauptet.
(d) Die drei Anschluss-Abschnitte im selben Block angleichen, damit sie der korrigierten
Tabelle nicht widersprechen:
- `## Alternatives Considered` (Zeile 99-117): einen Einleitungssatz voranstellen, dass die
Tabelle die Entscheidungslage von 2026-06 festhaelt und ihre Spalte "Recommended" keine
Aussage ueber den heutigen Stand ist. Die Tabelle selbst bleibt inhaltlich stehen.
- `## Version Pinning Strategy` (Zeile 147-152): die Beispielangaben auf die installierten
Haupt-Fassungen bringen; die Zeile zum Identitaetsanbieter-Image ist gegenstandslos und
gehoert in die Aufzaehlung aus (c) statt in eine Regel.
- `## Sources` (Zeile 154-167): als Quellen der damaligen Empfehlung kennzeichnen, nicht als
Belege des Ist-Stands. Die Links bleiben.
- `## Multi-Tenancy Strategy` (Zeile 119-125) bleibt: Prisma Client Extensions sind
tatsaechlich im Einsatz (`apps/api/src/prisma/prisma-tenant.extension.ts`). Diesen Beleg
dort ergaenzen.
(e) Zuletzt `.planning/research/STACK.md`: unter die Ueberschrift `# v1.0 Base Stack
(reference — unchanged)` (Zeile 97) eine einzelne, abgesetzte Hinweiszeile setzen, dass es
sich um die Empfehlung vom Juni/Juli 2026 handelt, dass sie teilweise nicht uebernommen
wurde und dass der installierte Stand in CLAUDE.md steht. Nichts loeschen, keine Zahl in
diesem Dokument aendern — es ist ein datiertes Rechercheergebnis und bleibt als solches
lesbar.
Zum Schluss `docs/anleitung-entwicklung.md` gegenlesen (nicht aendern) und in der
Zusammenfassung bestaetigen, dass keine Versionsangabe der beiden Dokumente einander mehr
widerspricht.
</action>
<verify>
<automated>node -e "const t=require('fs').readFileSync('CLAUDE.md','utf8').split('\n');const has=re=>t.some(l=>re.test(l));const bad=t.filter(l=>/^\| (Keycloak|Redis|TanStack Query|Husky|lint-staged|Playwright|nest-keycloak-connect|shadcn\/ui) \|/.test(l));const res=[['Next-Zeile nennt 15.5.19',has(/^\| Next\.js \| 15\.5\.19/)],['Prisma-Zeile nennt 6.19.3',has(/^\| Prisma \| 6\.19\.3/)],['keine nicht installierte Technik als Ist-Zeile',bad.length===0]];for(const e of res)console.log(e[1]?'OK ':'ROT ',e[0]);if(bad.length)console.log('Treffer:\n'+bad.map(l=>l.slice(0,50)).join('\n'));process.exit(res.every(e=>e[1])?0:1)"</automated>
<automated>git diff --exit-code -- package.json apps/api/package.json apps/web/package.json apps/desktop/package.json packages/shared/package.json packages/module-sdk/package.json pnpm-lock.yaml</automated>
<note>Erste Pruefung am 2026-09-09 gegen den unveraenderten Arbeitsbaum ausgefuehrt: alle drei Punkte ROT, acht Trefferzeilen, Rueckgabewert 1; gegen eine korrigierte Kopie Rueckgabewert 0. Die zweite Pruefung ist heute gruen und bleibt es nur, solange keine Abhaengigkeit angefasst wird — sie ist die Absicherung gegen ein versehentliches Aktualisieren.</note>
</verify>
<done>
Jede Versionsangabe im Technik-Block von CLAUDE.md entspricht der in `pnpm-lock.yaml`
aufgeloesten Fassung beziehungsweise den Compose-/Dockerfiles; eine Node-Zeile ist ergaenzt;
Vitest ist mit beiden Fassungen je App gefuehrt; Docker/Compose sind als Wirtseigenschaft
mit Messdatum gekennzeichnet; die Authentifizierungs-Zeilen beschreiben die eingebaute
eigene Anmeldung statt eines Identitaetsanbieters; alle nie uebernommenen Empfehlungen
stehen sichtbar in einer Aufzaehlung statt in den Ist-Tabellen; Herkunftsvermerk gesetzt;
`Alternatives Considered`, `Version Pinning Strategy` und `Sources` widersprechen der
Tabelle nicht mehr; `.planning/research/STACK.md` traegt eine Hinweiszeile und ist sonst
unveraendert; `package.json` und `pnpm-lock.yaml` sind unveraendert.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| beschreibbare Container-Schicht -> dauerhafter Speicher | Von Nutzern hochgeladene Inhalte (Profilbilder, DKV-Exporte) verlassen die fluechtige Schicht und ueberdauern den Container |
| Host-Dateisystem <-> Container (verworfene Bind-Mount-Variante) | Ein Host-Verzeichnis waere ein zweiter Zugriffsweg auf Nutzerdaten, vorbei an der API |
| Dokument -> Leser/Agent | CLAUDE.md praegt Annahmen darueber, welche Schutzmechanismen angeblich vorhanden sind |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-cx0-01 | Information Disclosure | benanntes Volume `user-files` (docker-compose.yml, docker-compose.prod.yml) | low | accept | Es entsteht keine neue Zugriffsflaeche: die Dateien werden weiterhin ausschliesslich ueber authentifizierte Endpunkte ausgeliefert (`GET /users/me/avatar` streamt aus dem in der Datenbank hinterlegten Pfad; DKV-Downloads pruefen den Dateinamen gegen ein `DKV_*.xlsx`-Muster, `apps/api/src/dkv/dkv.controller.ts:134`). Es wird kein statisches Verzeichnis veroeffentlicht. Neu ist allein die Lebensdauer |
| T-cx0-02 | Tampering | verworfene Bind-Mount-Variante | medium | mitigate | Benanntes Volume statt Host-Pfad: kein Verzeichnis des Wirts wird in den Container gereicht, kein repo-relativer Pfad legt Nutzerinhalte in die Arbeitskopie. Zusaetzlich bleibt die Eigentuemerschaft uid 1001 aus `apps/api/Dockerfile:23-26` erhalten, statt root-eigene Rechte einzufuehren |
| T-cx0-03 | Denial of Service | Plattenbedarf des Volumes | low | accept | Wachstum ist bereits im Code begrenzt: Profilbilder sind auf 2 MB gedeckelt (`apps/api/src/user/user.controller.ts:232`) und je Nutzer bleibt genau eine Datei (aeltere Endungen werden geloescht, `:255-261`); DKV-Exporte werden auf zehn Dateien beschnitten (`MAX_EXPORT_FILES = 10`, `apps/api/src/dkv/dkv-export.service.ts:11`). Das Volume selbst hat keine Groessengrenze — als Betriebshinweis in Kapitel 6 aufgenommen, keine Codeaenderung |
| T-cx0-04 | Spoofing | CLAUDE.md, Abschnitt Authentifizierung | medium | mitigate | Das Dokument nennt heute einen Identitaetsanbieter, der nicht existiert. Wer das glaubt, nimmt Schutzfunktionen an (Sitzungsverwaltung, Sperren, Verzeichnisfoederation), die in Wahrheit selbst gebaut sind. Task 2 ersetzt die Angabe durch die tatsaechliche Kette aus eigenem JWT, `argon2` und `ldapts` |
| T-cx0-05 | Repudiation | WINDOWS #17 im Ledger | low | mitigate | Der Eintrag wird NICHT geschlossen. Die Reparatur im Repository erreicht die laufende Installation nicht; ein Schliessen wuerde einen Zustand behaupten, der auf alpha nicht vorliegt. Die Zusammenfassung haelt fest, was noch aussteht und wer es tut |
| T-cx0-SC | Tampering | npm/pnpm-Installationen | n/a | accept | Dieser Plan installiert kein Paket und aendert keine Abhaengigkeit. Eine Pruefung der Paketherkunft ist deshalb nicht erforderlich; die zweite automatisierte Pruefung in Task 2 (`git diff --exit-code` auf alle `package.json` und `pnpm-lock.yaml`) erzwingt genau das |
</threat_model>
<verification>
Automatisiert (beide Aufgaben, aus der Wurzel des Arbeitsbaums):
1. Das Mount-Tor aus Task 1 — drei gerenderte Konfigurationen plus die Mount-Zeile im
Betriebshandbuch. War vor der Arbeit vierfach rot.
2. Das Versions-Tor aus Task 2 — zwei Stichproben auf die korrigierten Zahlen plus die
Ausschlusspruefung fuer nie eingebaute Technik. War vor der Arbeit dreifach rot.
3. `git diff --exit-code` auf alle `package.json` und `pnpm-lock.yaml` — belegt, dass keine
Abhaengigkeit angefasst wurde.
4. `git status --short` — nur die fuenf im Plan genannten Dateien duerfen geaendert sein.
<human-check>
Nachzuholen durch den Nutzer, nicht durch den Executor — beides braucht einen Neubau der
Container, den der Nutzer selbst ausfuehrt:
(a) **Lokaler Beweis, dass die Dateien ueberleben.** Container mit den geaenderten
Compose-Dateien neu erstellen, im Portal unter den eigenen Einstellungen ein Profilbild
hochladen, danach `docker compose up -d --force-recreate api`, Seite neu laden: das Bild ist
noch da. Gegenprobe frueher: genau hier ging es verloren.
(b) **Uebernahme auf alpha.** Dieselben zwei Zeilen in `/opt/tessera/docker-compose.yml`
eintragen (Datei vorher sichern), Container neu erstellen, danach `docker inspect
tessera-api-1` pruefen — unter `Mounts` muss `/app/user-files` erscheinen. Erst wenn das
erledigt ist, darf WINDOWS #17 geschlossen werden (`gsd-tools windows fixed 17`).
(c) **Durchsicht der korrigierten Technik-Tabelle** durch den Nutzer, falls gewuenscht —
inhaltlich pruefbar ohne Fachkenntnis: es darf nichts drinstehen, was es im Projekt nicht gibt.
</human-check>
</verification>
<success_criteria>
1. Beide automatisierten Tore gruen, beide waren vorher nachweislich rot.
2. Genau zwei Commits, in dieser Reihenfolge und jeweils fuer sich lauffaehig:
Commit 1 (Task 1) — `fix(compose): user-files dauerhaft speichern und Betriebshandbuch nachziehen`;
Commit 2 (Task 2) — `docs(claude): Versionsangaben auf den installierten Stand bringen`.
3. Keine Abhaengigkeit aktualisiert, kein Server angefasst, keine Datei ausserhalb des
Arbeitsbaums beruehrt.
4. WINDOWS #17 bleibt offen; die Zusammenfassung nennt den verbliebenen Schritt des Nutzers
woertlich und in Alltagssprache.
5. Die Zusammenfassung nennt die gewaehlte Speicherart samt Begruendung und alle beim
Nachschlagen gefundenen Abweichungen von der Versionstabelle in diesem Plan.
</success_criteria>
<output>
Create `.planning/quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/260909-cx0-SUMMARY.md` when done.
Festhalten: die tatsaechlich eingetragene Mount-Zeile im Wortlaut (zum Kopieren fuer den
Server), das Ergebnis beider Tore vor und nach der Arbeit, jede Abweichung zwischen der
Versionstabelle dieses Plans und dem selbst nachgeschlagenen Stand, sowie der offene Rest:
Serverdatei ergaenzen und danach den Ledger-Eintrag schliessen.
</output>
@@ -0,0 +1,174 @@
---
phase: quick-260909-cx0
plan: 01
subsystem: infra
tags: [docker-compose, docker-volume, backup, documentation, stack-versions]
requires: []
provides:
- "Benanntes Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`, in `docker-compose.yml` und `docker-compose.prod.yml`"
- "Betriebshandbuch-Kapitel 6/7 beschreiben den Speicher korrekt als dauerhaft und weisen auf die abweichende Serverdatei hin"
- "CLAUDE.md Technik-Block zeigt den installierten Stand statt der 2026-06/07-Empfehlung, mit eigenem Abschnitt fuer nie uebernommene Empfehlungen"
- ".planning/research/STACK.md traegt eine datierte Hinweiszeile ohne Zahlenaenderung"
affects: [dokumentation, betrieb, onboarding]
actuals:
tokens: 5226
tasks: 2
commits: 2
plan_head_before: dab72eb^
tech-stack:
added: []
patterns: ["Benanntes Docker-Volume statt Bind-Mount fuer Container-interne Schreibverzeichnisse mit fester uid-Eigentuemerschaft"]
key-files:
created: []
modified:
- docker-compose.yml
- docker-compose.prod.yml
- docs/anleitung-betrieb.md
- CLAUDE.md
- .planning/research/STACK.md
key-decisions:
- "Benanntes Volume user-files statt Bind-Mount: das Image legt /app/user-files an und uebereignet es uid 1001, ein leeres benanntes Volume uebernimmt das beim ersten Mounten, ein frisch von Docker erzeugtes Host-Verzeichnis gehoert dagegen root"
- "docker-compose.dev.yml bleibt unveraendert, da Compose Mount-Listen ueber das Ziel zusammenfuehrt und die Kombination Basis+Dev den neuen Mount automatisch mittraegt"
- "Nie uebernommene Empfehlungen (Keycloak, Redis, TanStack Query, shadcn/ui, Playwright, Husky, lint-staged) sowie zwei veraltete Hauptversionen (Next.js, Prisma) stehen in CLAUDE.md jetzt in einer Aufzaehlung statt in den Ist-Tabellen"
- "STACK.md bleibt als datiertes Rechercheergebnis unveraendert, nur eine Hinweiszeile ergaenzt - keine Regeneration wuerde die korrigierten CLAUDE.md-Zahlen zurueckholen, ohne dass jemand die Herkunftsvermerke sieht"
requirements-completed: [WINDOWS-17]
coverage:
- id: D1
description: "Alle drei gerenderten Compose-Konfigurationen (Basis, Prod, Basis+Dev) mounten fuer den Dienst api genau ein benanntes Volume user-files auf /app/user-files; Betriebshandbuch nennt die Mount-Zeile woertlich"
requirement: "WINDOWS-17"
verification:
- kind: other
ref: "node -e Skript aus PLAN.md Task 1 <automated> — docker compose config --format json fuer Basis/Prod/Basis+Dev plus String-Suche im Betriebshandbuch"
status: pass
human_judgment: false
- id: D2
description: "CLAUDE.md nennt den tatsaechlich installierten Stand (Next.js 15.5.19, Prisma 6.19.3 etc.), keine nie eingebaute Technik mehr als Ist-Tabellenzeile, package.json/pnpm-lock.yaml unveraendert"
verification:
- kind: other
ref: "node -e Skript aus PLAN.md Task 2 <automated> — Zeilenpruefung auf 15.5.19/6.19.3 plus Ausschlusspruefung; git diff --exit-code auf alle package.json/pnpm-lock.yaml"
status: pass
human_judgment: false
- id: D3
description: "Lokaler Beweis, dass hochgeladene Dateien ein --force-recreate ueberleben, und Uebernahme der Mount-Zeilen auf /opt/tessera/docker-compose.yml auf alpha"
verification: []
human_judgment: true
rationale: "Beide Pruefungen erfordern einen Neubau der Container (lokal bzw. auf alpha) durch den Nutzer selbst - ausserhalb dieses Ausfuehrungsschritts, siehe execution_notes/constraints des Plans"
duration: 12min
completed: 2026-09-09
status: complete
---
# Quick Task 260909-cx0: Dateisicherung nachgeruestet und Versionsangaben korrigiert Summary
**Hochgeladene Dateien liegen jetzt in einem benannten Docker-Volume statt in der fluechtigen Container-Schicht, und CLAUDE.md nennt die tatsaechlich installierten Paketversionen statt der 2026-06/07-Empfehlung.**
## Performance
- **Duration:** ca. 12 min
- **Started:** 2026-09-09T07:25:00Z (ungefaehr, kein exakter Start-Zeitstempel erfasst)
- **Completed:** 2026-09-09T07:37:39Z
- **Tasks:** 2/2
- **Files modified:** 5
## Accomplishments
- WINDOWS #17 (Datenverlust bei `--force-recreate`) im Repository behoben: `docker-compose.yml` und `docker-compose.prod.yml` mounten `/app/user-files` im Dienst `api` jetzt auf das benannte Volume `user-files`
- Betriebshandbuch (`docs/anleitung-betrieb.md`) Kapitel 6 beschreibt den Speicher korrekt als dauerhaft, nennt die Mount-Zeile woertlich zum Kopieren und weist ausdruecklich auf die vom Repository abweichende `/opt/tessera/docker-compose.yml` hin; Kapitel 7 Fehlertabelle passt dazu
- `CLAUDE.md` zeigt jetzt den installierten Stand (Next.js 15.5.19, Prisma 6.19.3, NestJS 11.1.27, Express 5.2.1, Node `node:24-alpine`, Vitest je App, Docker/Compose als gemessene Wirtseigenschaft, eigenes Auth-Stack statt Keycloak) statt der alten Empfehlung
- Nie uebernommene Empfehlungen (Keycloak, Redis, TanStack Query, shadcn/ui, Playwright, Husky, lint-staged) sowie zwei veraltete Hauptversionen (Next.js 16 statt 15, Prisma 7 statt 6) stehen jetzt sichtbar in einer eigenen Aufzaehlung "Recommended But Not Adopted" statt als Ist-Tabellenzeile
- `.planning/research/STACK.md` traegt eine datierte Hinweiszeile, Zahlen darin unveraendert
## Task Commits
Each task was committed atomically:
1. **Task 1: user-files dauerhaft speichern und das Betriebshandbuch nachziehen (WINDOWS #17)** - `dab72eb` (fix)
2. **Task 2: Versionsangaben in CLAUDE.md auf den installierten Stand bringen** - `c807049` (docs)
_Kein separater Plan-Metadaten-Commit gemaess Konstellation dieses Ausfuehrungsschritts (SUMMARY.md/STATE.md werden vom Orchestrator committet)._
## Files Created/Modified
- `docker-compose.yml` - `api`-Dienst mountet `user-files:/app/user-files`, Top-Level-Volume `user-files` ergaenzt
- `docker-compose.prod.yml` - identisch zu `docker-compose.yml`
- `docs/anleitung-betrieb.md` - Kapitel 6 (Datenhaltung, Mount-Zeile, Server-Hinweis) und Kapitel 7 (Fehlertabelle) korrigiert, Schreibfehler "daürhafte" behoben
- `CLAUDE.md` - Technik-Block zwischen `GSD:stack-start`/`GSD:stack-end` auf installierten Stand gebracht, neuer Abschnitt "Recommended But Not Adopted", Herkunftsvermerk, angepasste Alternatives Considered/Version Pinning Strategy/Sources/Multi-Tenancy Strategy
- `.planning/research/STACK.md` - Hinweiszeile unter der Ueberschrift "v1.0 Base Stack (reference — unchanged)", sonst unveraendert
## Die tatsaechlich eingetragene Mount-Zeile (zum Kopieren auf den Server)
In beiden Compose-Dateien beim Dienst `api` (vor `healthcheck:`):
```yaml
volumes:
- user-files:/app/user-files
```
Im Top-Level-Block `volumes:` (neben `pgdata:`):
```yaml
volumes:
pgdata:
user-files:
```
Genau diese zwei Aenderungen (Dienst-Zeile + Top-Level-Eintrag) muss der Nutzer selbst in `/opt/tessera/docker-compose.yml` eintragen, um die Reparatur auf alpha wirksam zu machen (vorher sichern).
## Ergebnis der beiden Tore, vor und nach der Arbeit
**Tor 1 (Task 1, Mount):** Vor der Aenderung: `docker compose config --format json` fuer Basis, Prod und Basis+Dev meldete dreimal `FEHLT`, das Betriebshandbuch enthielt die Mount-Zeile nicht — Ruckgabewert 1. Nach der Aenderung: alle drei Konfigurationen melden `OK volume:user-files`, das Betriebshandbuch enthaelt die Zeile — Ruckgabewert 0. Selbst ausgefuehrt und bestaetigt (siehe Ausfuehrungsprotokoll dieses Schritts).
**Tor 2 (Task 2, Versionen):** Vor der Aenderung: Next.js-Zeile nannte 16.2.x (nicht 15.5.19), Prisma-Zeile nannte 7.8.x (nicht 6.19.3), acht Tabellenzeilen fuehrten nie eingebaute Technik als Ist-Zeile — Ruckgabewert 1. Nach der Aenderung: alle drei Teilpruefungen `OK`, Ruckgabewert 0. Der Abhaengigkeits-Guard (`git diff --exit-code` auf alle `package.json` und `pnpm-lock.yaml`) war vor und nach der Arbeit gruen (Ruckgabewert 0) — bestaetigt, dass keine Abhaengigkeit angefasst wurde.
## Beim Nachschlagen gefundene Abweichungen von der Versionstabelle des Plans
Keine. Die eigene Pruefung gegen `pnpm-lock.yaml` (`importers:`-Abschnitt), die Compose-/Dockerfiles und die lokal gemessenen Docker-/Compose-Versionen (Docker 29.8.0, Compose v5.5.1) deckt sich in jedem Punkt mit der im Plan-`objective` dokumentierten Tabelle vom 2026-09-09. Keine Abweichung zu vermerken.
## Decisions Made
- Benanntes Volume statt Bind-Mount fuer `user-files` — Begruendung: Eigentuemerschaft. Das Image legt `/app/user-files` an und uebereignet es uid 1001 (`apps/api/Dockerfile:23-26`), der Prozess laeuft als dieser Nutzer (`:36`). Ein leeres benanntes Volume uebernimmt beim ersten Mounten Inhalt und Eigentuemerschaft des Image-Verzeichnisses; ein von Docker frisch angelegtes Host-Verzeichnis gehoert dagegen root und wuerde ohne eine zusaetzliche manuelle Uebereignung durch den Betreiber zu kaputten Uploads fuehren.
- `docker-compose.dev.yml` bewusst nicht angefasst — Compose fuehrt Mount-Listen ueber das Ziel zusammen, die Kombination Basis+Dev traegt den neuen Mount automatisch mit (selbst am gerenderten Ergebnis geprueft).
- Nie uebernommene Empfehlungen und veraltete Hauptversionen in CLAUDE.md aus den Ist-Tabellen entfernt und in einen eigenen, deutlich benannten Aufzaehlungs-Abschnitt verschoben, statt sie dort stehen zu lassen wo "ist eingebaut" impliziert wuerde.
- `.planning/research/STACK.md` inhaltlich nicht angetastet (nur eine Hinweiszeile) — es ist ein datiertes Rechercheergebnis, keine Live-Dokumentation.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
**Reparatur auf alpha steht noch aus.** `/opt/tessera/docker-compose.yml` auf dem Testserver ist keine Arbeitskopie dieses Repositorys — sie wurde dort von Hand bearbeitet und weicht ab; ein Deploy holt ausschliesslich Images und fasst diese Datei nicht an. Diese Reparatur erreicht die laufende Installation deshalb **nicht von selbst**.
**Naechster Schritt (durch den Nutzer):**
1. `/opt/tessera/docker-compose.yml` sichern.
2. Die beiden oben genannten Zeilen (Dienst-Mount + Top-Level-Volume) dort eintragen.
3. Container einmal neu erstellen.
4. Pruefen: `docker inspect tessera-api-1` → unter `Mounts` muss `/app/user-files` erscheinen.
5. Erst danach den Ledger-Eintrag schliessen: `gsd-tools windows fixed 17`.
Bis dahin bleibt **WINDOWS #17 im Ledger offen** — dieser Ausfuehrungsschritt hat ihn absichtlich nicht geschlossen, weil die Luecke auf der laufenden Installation weiterbesteht.
Zusaetzlich, falls gewuenscht (keine Voraussetzung fuer den Server-Schritt): lokaler Beweis, dass Dateien einen `--force-recreate` ueberleben (Container mit den geaenderten Compose-Dateien neu erstellen, Profilbild hochladen, `docker compose up -d --force-recreate api`, Seite neu laden — Bild muss noch da sein).
## Next Phase Readiness
- Kein Blocker fuer weitere Arbeit. WINDOWS #17 bleibt bewusst offen, bis der Nutzer die Serverdatei ergaenzt hat.
- CLAUDE.md und `docs/anleitung-entwicklung.md` wurden gegengelesen: keine Versionsangabe der beiden Dokumente widerspricht der jeweils anderen mehr.
---
*Quick Task: 260909-cx0*
*Completed: 2026-09-09*
## Self-Check: PASSED
@@ -0,0 +1,638 @@
---
quick_id: 260909-dgj
slug: mandantentrennung-auf-alle-tabellen-mit-
date: 2026-09-09
status: planned
relates_to: 02-authentication-multi-tenancy, 15-modul-berechtigungen-gruppen-user-grants
windows_ref: 18
severity: high
phase: quick-260909-dgj
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
- apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
- apps/api/src/prisma/rls-app-role.spec.ts
- apps/api/src/prisma/rls-coverage.spec.ts
- apps/api/src/prisma/start-script.spec.ts
- apps/api/src/prisma/rls-preflight.spec.ts
- apps/api/scripts/migrate-and-start.sh
- apps/api/scripts/rls-preflight.mjs
- apps/api/Dockerfile
- docker-compose.yml
- docker-compose.prod.yml
- .env.example
- docs/mandantentrennung-datenbankrolle.md
- docs/README.md
autonomous: true
requirements: [WINDOWS-18]
estimate:
tokens: 85000
raw_tokens: 85000
tasks: 4
confidence: low
must_haves:
truths:
- "Eine Datenbankrolle `tessera_app` wird durch eine Migration angelegt und traegt nachweislich weder das Superuser- noch das RLS-Umgehungsrecht; die Migration konvergiert eine bereits vorhandene Rolle auf genau diese Eigenschaften, statt zu scheitern."
- "Dieselbe Migration laesst sich beliebig oft anwenden, ohne zu scheitern — sie laeuft bei jedem Containerstart erneut ueber `prisma migrate deploy` nur einmal, muss aber gegen eine Datenbank, in der Rolle und Rechte schon existieren, fehlerfrei durchlaufen."
- "Im SQL der Migration steht kein Kennwort. Das Setzen des Kennworts ist als ausdruecklicher Handgriff des Betreibers dokumentiert, samt der auszufuehrenden Anweisung."
- "Die Anwendung kann als eine andere Rolle laufen als die, mit der Migrationen angewendet werden: `prisma migrate deploy` nutzt `TESSERA_MIGRATE_DATABASE_URL`, wenn gesetzt, sonst `DATABASE_URL`; der Node-Prozess nutzt in beiden Faellen unveraendert `DATABASE_URL`."
- "Ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` verhaelt sich der Containerstart exakt wie vorher — lokal, in der CI und auf dem Server bleibt der bisherige Ablauf gueltig, ohne dass jemand etwas anpassen muss."
- "Ein Pruefwerkzeug misst gegen eine beliebige Datenbank, ob die Trennung unter einer angegebenen Rolle tatsaechlich greift: Rollenrechte, Setzbarkeit von `app.current_tenant`, null sichtbare Zeilen ohne Kontext, sichtbare Zeilen mit Kontext, vorhandene Schreib-/Leserechte. Es veraendert dabei nichts."
- "Das Pruefwerkzeug laesst sich ohne Datenbank aufrufen und gibt dann seinen Pruefplan aus — dadurch ist es in der CI testbar, die keine Datenbank hat."
- "Alle 20 Modelle mit `tenantId`-Spalte tragen nach dieser Aenderung eine Policy; die 16 bisher fehlenden sind in einer zweiten Migration ergaenzt."
- "Jede Tabelle ohne Policy ist namentlich mit Begruendung aufgefuehrt — im Kopf der neuen Migration und als Ausnahmeliste in einem Test, der bei jedem neuen Modell eine bewusste Entscheidung erzwingt."
- "Keine bereits angewendete Migrationsdatei wurde veraendert; Korrekturen an frueheren Aussagen stehen ausschliesslich im Kopf der neuen Migration."
- "Die Betriebsanleitung nennt die Umstellung als noch NICHT vollzogen, nennt die gemessene Zahl der unskalierten Zugriffe als Sperrgrund, beschreibt den Weg zurueck und sagt, was ein Betreiber tut, wenn die API nach einer Umstellung nicht mehr verbindet."
artifacts:
- 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/rls-coverage.spec.ts
- apps/api/src/prisma/start-script.spec.ts
- apps/api/src/prisma/rls-preflight.spec.ts
- docs/mandantentrennung-datenbankrolle.md
key_links:
- "`docker-compose.yml:33` (`DATABASE_URL` der API zeigt auf Rolle `tessera`) <-> `docker-compose.yml:76` (`POSTGRES_USER: tessera`). Die zweite Zeile ist die Ursache: die von `POSTGRES_USER` angelegte Rolle ist Superuser des Clusters. Deshalb ist RLS heute wirkungslos, und deshalb reicht es nicht, Policies zu ergaenzen."
- "`apps/api/Dockerfile:38` (CMD fuehrt `prisma migrate deploy` und `node main.js` mit derselben `DATABASE_URL` aus) <-> jede Rollentrennung. Solange beide Schritte dieselbe Verbindung nutzen, muesste die Anwendungsrolle DDL-Rechte und Tabelleneigentum haben — womit die Trennung wieder verloren waere. Die Trennung der beiden URLs ist die Voraussetzung fuer alles Weitere."
- "`apps/api/src/auth/auth.service.ts:37-41` (`validateUser` liest `User` bewusst ohne Mandantenkontext — der Kommentar sagt es woertlich) <-> jede wirksame Policy auf `User`. Unter der neuen Rolle liefert genau diese Abfrage null Zeilen, und niemand kann sich mehr anmelden. Das ist der Grund, warum dieser Plan die Umstellung vorbereitet und misst, aber nicht vollzieht."
- "`apps/api/src/prisma/prisma-tenant.extension.ts:16` (`set_config('app.current_tenant', $1, true)` innerhalb einer Transaktion) <-> Rechte der neuen Rolle. Transaktionslokales Setzen einer benutzerdefinierten Einstellung braucht kein besonderes Recht; das Pruefwerkzeug misst es trotzdem, statt es anzunehmen."
- "`apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` (Kopf: RLS als 'zweites Sicherheitsnetz', Tender-Tabellen 'bewusst ohne RLS, D-03') <-> gemessener Bestand. Die Pauschalaussage trifft nur auf die drei Tender-Tabellen ohne `tenantId` zu. Die Korrektur gehoert in den Kopf der NEUEN Migration — die alte Datei darf nicht angefasst werden, weil Prisma ihre Pruefsumme fuehrt."
- "`apps/api/vitest.config.ts:8` (`passWithNoTests: true`) <-> jede Abnahmepruefung dieses Plans. Ohne `--passWithNoTests=false` liefert ein Aufruf auf eine noch nicht existierende Testdatei den Erfolgscode 0 — die Pruefung waere von vornherein gruen und damit wertlos. Gemessen am 2026-09-09."
---
<objective>
Die Mandantentrennung auf Datenbankebene wirksam machen — in der Reihenfolge, die WINDOWS #18
vorgibt: erst die Rolle, dann der Nachweis, dann die Ausweitung.
**Der gemessene Ausgangsstand (am 2026-09-09 im Arbeitsbaum nachgeprueft, nicht uebernommen):**
Die API verbindet laut `docker-compose.yml:33` als Rolle `tessera`. Diese Rolle entsteht aus
`POSTGRES_USER: tessera` (`docker-compose.yml:76`) und ist damit Superuser des Clusters; auf dem
Testserver wurde `rolsuper = t` und `rolbypassrls = t` gemessen. PostgreSQL wendet Row-Level
Security auf solche Rollen grundsaetzlich nicht an. `FORCE ROW LEVEL SECURITY` aendert daran
nichts — es erzwingt Policies nur auf den Tabelleneigentuemer, nicht auf Rollen mit
Umgehungsrecht. Die sieben vorhandenen Policies (User, Group, GroupMembership, LdapConfig,
LdapFieldMapping, ModuleGrant, PasswordResetToken) sind daher heute wirkungslos.
Nachgezaehlt im Schema: 28 Modelle, davon 20 mit `tenantId`-Spalte, davon 7 mit Policy — also 16
Tabellen ohne. Alle 16 haben eine direkte `tenantId`-Spalte; keine braucht ein Unterabfrage-Muster.
**Der zweite, ebenso wichtige Befund — er bestimmt, was dieser Plan NICHT tut:**
Die Anwendung ist ueberwiegend gegen den unskalierten Prisma-Client geschrieben. Gezaehlt am
2026-09-09 in `apps/api/src`, ohne Testdateien: 84 Zugriffe auf die sieben bereits mit Policies
versehenen Tabellen und 98 Zugriffe auf die 16 noch offenen — zusammen 182 — gegenueber lediglich
19 Verwendungen von `tenantPrisma` im gesamten API-Quelltext. Darunter ist der Anmeldeweg selbst:
`apps/api/src/auth/auth.service.ts:37-41` liest `User` ohne Mandantenkontext, und der Kommentar
darueber sagt ausdruecklich, dass das so sein muss, weil die Anmeldung mandantenuebergreifend
funktionieren muss — der Mandant wird ja erst aus dem gefundenen Benutzer bestimmt. Dazu kommen
Hintergrunddienste, die von Natur aus ohne Kontext laufen: `ldap-sync.scheduler.ts`,
`tender-digest.scheduler.ts`, `admin-seed.service.ts`, `module-access.service.ts` und
`tender-matching.service.ts`.
Wuerde man heute nur die Verbindung auf eine Rolle ohne Umgehungsrecht umstellen, lieferten all
diese Abfragen null Zeilen. Niemand koennte sich mehr anmelden, und die Hintergrunddienste
liefen leer. Das ist kein Restrisiko, das ist die sichere Folge.
**Was dieser Plan deshalb liefert:** die Rolle, den sauberen Weg, die Anwendung ueberhaupt unter
einer anderen Rolle laufen zu lassen als der, die migriert, ein Messwerkzeug, das den Nachweis
fuehrt statt ihn zu behaupten, und die vollstaendige Policy-Abdeckung samt benannter Ausnahmen.
**Was dieser Plan ausdruecklich NICHT liefert:** das Umlegen des Schalters. Die Umstellung bleibt
standardmaessig aus. Sie wird erst moeglich, wenn die 182 unskalierten Zugriffe behandelt sind —
das ist eigene Arbeit fuer einen eigenen Vorgang, und die Betriebsanleitung sagt das offen.
WINDOWS #18 bleibt danach offen.
Output: zwei Migrationen, ein Startskript, ein Pruefwerkzeug, vier Testdateien, eine
Betriebsanleitung.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
@apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql
@apps/api/src/groups/migration-sql.spec.ts
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/Dockerfile
@docker-compose.yml
</context>
<constraints_global>
Fuer alle vier Aufgaben gilt:
1. **Keine bereits angewendete Migrationsdatei veraendern.** Prisma fuehrt zu jeder Migration eine
Pruefsumme; eine nachtraegliche Aenderung laesst `prisma migrate deploy` mit einem
Aenderungsfehler abbrechen — und damit startet der API-Container nicht mehr. Korrekturen an
frueheren Aussagen stehen im Kopf der neuen Migration.
2. **Nichts anwenden, nichts starten.** Keine Migration ausfuehren, kein `docker compose up`,
`pull`, `restart` oder `build`, kein Zugriff auf 192.168.13.12. Es werden ausschliesslich
Dateien geschrieben.
3. **Kein Kennwort in eine Datei schreiben**, die im Repository landet — weder im SQL noch in
`.env.example` noch in der Anleitung. Dort stehen Platzhalter.
4. **Jede Abnahmepruefung braucht `--passWithNoTests=false`.** `apps/api/vitest.config.ts:8` setzt
`passWithNoTests: true`; ein Aufruf auf eine fehlende Testdatei liefert sonst den Erfolgscode 0.
Am 2026-09-09 gemessen: ohne die Option Ende-Code 0, mit der Option Ende-Code 1.
</constraints_global>
<!-- planner-discipline-allow: PASSWORD -->
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Anwendungsrolle ohne RLS-Umgehungsrecht anlegen (Migration)</name>
<files>apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql, apps/api/src/prisma/rls-app-role.spec.ts</files>
<precondition>Der Arbeitsbaum enthaelt `apps/api/prisma/migrations/` mit `20260909120000_user_email_optional` als juengstem Eintrag; der neue Ordner muss zeitlich danach sortieren. Es wird keine Datenbank benoetigt und keine Migration angewendet.</precondition>
<reversibility rating="reversible">Die Rolle wird angelegt, aber von niemandem benutzt. Ein Rueckbau ist ein `DROP ROLE` von Hand; solange die Rolle keine Verbindung aufbaut, hat ihre Existenz keine Wirkung auf den Betrieb.</reversibility>
<behavior>
Die Testdatei entsteht zuerst und ist rot, bevor die Migration geschrieben wird. Sie liest die
neue `migration.sql` als Text — genau wie `apps/api/src/groups/migration-sql.spec.ts` es fuer
die Phase-15-Migrationen tut — und braucht dafuer keine Datenbank.
- Test 1: Die Migration nennt die Rolle `tessera_app`.
- Test 2: Sie entzieht ausdruecklich beide Umgehungswege — `NOSUPERUSER` und `NOBYPASSRLS`
kommen beide vor, und `BYPASSRLS` kommt nirgends ohne vorangestelltes `NO` vor.
- Test 3: Sie ist wiederholbar. Vor dem Anlegen steht eine Existenzpruefung ueber `pg_roles`;
der Text enthaelt `DO $$` und `IF NOT EXISTS`.
- Test 4: Der Datenbankname ist nicht fest verdrahtet — der Verbindungsanspruch wird ueber
`current_database()` erteilt.
- Test 5: Der Eigentuemer der kuenftigen Tabellen ist nicht fest verdrahtet — die
Vorgaberechte werden fuer `current_user` gesetzt.
- Test 6: Es steht kein Kennwort im SQL. Die Datei enthaelt keine Stelle, an der auf das
Schluesselwort PASSWORD ein Hochkomma folgt.
- Test 7: Die Migration erteilt alle vier Datenzugriffsarten (SELECT, INSERT, UPDATE, DELETE).
</behavior>
<action>
Zuerst `apps/api/src/prisma/rls-app-role.spec.ts` schreiben (rot), dann die Migration.
Die Testdatei uebernimmt das Muster aus `apps/api/src/groups/migration-sql.spec.ts`: eine
Hilfsfunktion, die das Verzeichnis `apps/api/prisma/migrations` liest, genau einen Ordner mit
der Endung `_rls_app_role` erwartet und dessen `migration.sql` als Zeichenkette zurueckgibt.
Bewusst dieselbe Hilfsfunktion nachbauen statt sie zu importieren — die vorhandene ist in
ihrer Datei privat, und eine Kopie von zwoelf Zeilen ist billiger als eine neue
Abhaengigkeit zwischen zwei Testdateien.
Dann `apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql` anlegen. Der
Ordnername muss exakt so lauten; nur dann sortiert er hinter `20260909120000_user_email_optional`
und wird von der Testdatei gefunden.
Kopfkommentar der Migration, in deutschen Saetzen und ausfuehrlich genug, dass ein spaeterer
Leser die Entscheidung nachvollziehen kann. Er muss diese Punkte tragen: dass die bisher
verwendete Rolle `tessera` aus `POSTGRES_USER` entsteht und deshalb Superuser ist; dass
PostgreSQL RLS auf Superuser- und BYPASSRLS-Rollen nicht anwendet und `FORCE ROW LEVEL
SECURITY` daran nichts aendert, weil es nur den Tabelleneigentuemer erfasst; dass die sieben
vorhandenen Policies deshalb heute ohne Wirkung sind (WINDOWS #18, gemessen am 2026-09-09);
dass diese Migration allein noch nichts umstellt, weil niemand die neue Rolle benutzt; und
dass das Kennwort bewusst nicht hier gesetzt wird, sondern vom Betreiber von Hand — mit
Verweis auf `docs/mandantentrennung-datenbankrolle.md`.
Der SQL-Koerper besteht aus einem `DO $$`-Block und anschliessenden Rechtevergaben:
(a) Existenz: Wenn in `pg_roles` keine Zeile mit `rolname = 'tessera_app'` steht, die Rolle
anlegen. Vorher pruefen, ob `current_user` das ueberhaupt darf (`rolsuper` oder `rolcreaterole`
aus `pg_roles`). Darf er es nicht, mit `RAISE EXCEPTION` abbrechen und im Meldungstext die
genau auszufuehrende Anweisung nennen sowie darauf hinweisen, dass sie einmalig als
Datenbank-Superuser laufen muss. Lautes Scheitern mit Anleitung ist hier richtig; ein
stilles Ueberspringen wuerde einen Betreiber im Glauben lassen, die Rolle existiere.
Die Rolle wird mit `LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE` angelegt und ohne
jede Kennwortangabe.
(b) Konvergenz: Wenn die Rolle bereits existiert und `current_user` Superuser ist, ihre
Eigenschaften unbedingt auf denselben Stand ziehen (`ALTER ROLE tessera_app WITH LOGIN
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE`). Nur ein Superuser darf die Merkmale
SUPERUSER und BYPASSRLS setzen oder entziehen — ist `current_user` keiner, statt des `ALTER`
pruefen, ob `rolsuper` und `rolbypassrls` bereits beide falsch sind, und andernfalls wieder
mit `RAISE EXCEPTION` samt auszufuehrender Anweisung abbrechen. Damit ist die Migration auf
einer Datenbank, in der schon alles stimmt, ein reiner Durchlauf.
(c) Rechte, alle idempotent (ein wiederholtes `GRANT` ist in PostgreSQL folgenlos):
Verbindungsrecht auf die aktuelle Datenbank — den Namen dynamisch ueber `current_database()`
und `format(..., %I)` einsetzen, damit die Migration auch gegen eine anders benannte Datenbank
laeuft; `USAGE` auf das Schema `public`; `SELECT, INSERT, UPDATE, DELETE` auf alle Tabellen in
`public`; `USAGE, SELECT` auf alle Sequenzen in `public` (das Schema nutzt derzeit keine
Sequenzen — nachgezaehlt, kein einziges `autoincrement` —, die Vergabe kostet nichts und
verhindert einen spaeteren Stolperstein).
(d) Vorgaberechte, damit kuenftige Migrationen nicht jedes Mal nachziehen muessen:
`ALTER DEFAULT PRIVILEGES FOR ROLE <current_user> IN SCHEMA public GRANT SELECT, INSERT,
UPDATE, DELETE ON TABLES TO tessera_app` und dasselbe fuer Sequenzen. Die Rolle im
`FOR ROLE`-Teil dynamisch aus `current_user` bilden, nicht `tessera` hineinschreiben — der
Eigentuemer ist die Rolle, die migriert, und die kann auf einer anderen Installation anders
heissen.
Keine DDL an Tabellen, keine Policy, kein `ALTER TABLE`. Diese Migration beruehrt keine Daten.
</action>
<verify>
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-app-role.spec.ts</automated>
</verify>
<done>Der Ordner `apps/api/prisma/migrations/20260909130000_rls_app_role/` enthaelt eine `migration.sql`, die die Rolle `tessera_app` wiederholbar anlegt und auf `NOSUPERUSER`/`NOBYPASSRLS` konvergiert, Datenbank- und Eigentuemernamen dynamisch bildet, alle vier Datenzugriffsarten samt Vorgaberechten erteilt und kein Kennwort enthaelt. Die sieben Tests in `src/prisma/rls-app-role.spec.ts` laufen gruen; vor dem Schreiben der Migration waren sie rot.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Migrationsverbindung von der Laufzeitverbindung trennen — abgeschaltet als Vorgabe</name>
<files>apps/api/scripts/migrate-and-start.sh, apps/api/Dockerfile, docker-compose.yml, docker-compose.prod.yml, .env.example, apps/api/src/prisma/start-script.spec.ts</files>
<precondition>`sh` ist aufrufbar (im Alpine-Abbild, in der Gitea-CI und lokal jeweils vorhanden). Der Test startet ausschliesslich das neue Skript in einer Ausgabebetriebsart; er ruft weder Prisma noch Node an und baut keine Verbindung auf.</precondition>
<reversibility rating="reversible">Der Zustand ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` ist Zeile fuer Zeile derselbe Ablauf wie die bisherige CMD-Zeile. Ein Rueckbau ist das Zuruecksetzen von vier Dateien.</reversibility>
<behavior>
`apps/api/src/prisma/start-script.spec.ts` entsteht zuerst und ist rot. Er ruft das Skript mit
`execFileSync('sh', [pfad, '--print-plan'], { env, encoding: 'utf-8' })` auf und wertet die
Ausgabe aus. Der Pfad wird ueber `join(__dirname, '../../scripts/migrate-and-start.sh')`
gebildet, damit der Test unabhaengig vom Arbeitsverzeichnis laeuft.
- Test 1 (Rueckwaertsvertraeglichkeit): Mit gesetztem `DATABASE_URL` und ohne
`TESSERA_MIGRATE_DATABASE_URL` meldet die Ausgabe fuer beide Schritte `DATABASE_URL` als
Quelle.
- Test 2 (Trennung): Sind beide gesetzt, meldet der Migrationsschritt
`TESSERA_MIGRATE_DATABASE_URL` und der Laufzeitschritt weiterhin `DATABASE_URL`.
- Test 3 (leer zaehlt als nicht gesetzt): Ist `TESSERA_MIGRATE_DATABASE_URL` die leere
Zeichenkette, verhaelt sich das Skript wie in Test 1 — Compose reicht nicht gesetzte
Variablen als leere Zeichenketten weiter.
- Test 4 (kein Geheimnisabfluss): Die Ausgabe enthaelt keinen der beiden uebergebenen
Verbindungswerte, sondern nur die Namen der Variablen. Als Wert im Test eine erkennbare
Zeichenkette verwenden und pruefen, dass sie in der Ausgabe nicht vorkommt.
- Test 5 (fehlende Angabe): Ohne `DATABASE_URL` bricht das Skript mit einem Ende-Code
ungleich 0 ab und nennt den fehlenden Variablennamen.
</behavior>
<action>
Zuerst den Test schreiben (rot), dann das Skript, dann die drei Aufrufer.
`apps/api/scripts/migrate-and-start.sh` neu anlegen, POSIX-`sh`, mit `set -e`. Kopfkommentar:
warum es das Skript gibt — `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers,
die Anwendung soll sie gerade nicht haben; ohne getrennte Verbindungen ist eine Rollentrennung
nicht moeglich. Ablauf:
Fehlt `DATABASE_URL`, mit einer Meldung auf die Standardfehlerausgabe und Ende-Code 1
abbrechen. Andernfalls die Migrationsverbindung bestimmen: `TESSERA_MIGRATE_DATABASE_URL`,
wenn nicht leer, sonst `DATABASE_URL` — in `sh` ist das die Ersetzung mit Doppelpunkt, die
eine leere Zeichenkette wie eine nicht gesetzte behandelt. Merken, welche der beiden Quellen
gewaehlt wurde.
Ist das erste Argument `--print-plan`, zwei Zeilen ausgeben — die gewaehlte Quelle fuer den
Migrationsschritt und die Quelle fuer den Laufzeitschritt, jeweils als Name der Variablen,
niemals als Wert — und mit Ende-Code 0 zurueckkehren, ohne irgendetwas auszufuehren. Diese
Betriebsart existiert allein, damit die CI das Verhalten pruefen kann, ohne eine Datenbank zu
haben.
Sonst: `prisma migrate deploy --schema apps/api/prisma/schema.prisma` aus
`apps/api/node_modules/.bin/` ausfuehren, wobei `DATABASE_URL` nur fuer diesen einen Aufruf
auf die Migrationsverbindung gesetzt wird (vorangestellte Zuweisung, kein `export`), und
danach mit `exec node apps/api/dist/main.js` in den Anwendungsprozess wechseln, der die
unveraenderte `DATABASE_URL` aus der Umgebung erbt. Das `exec` ist wichtig, damit Signale den
Node-Prozess erreichen — die bisherige CMD-Zeile hatte dasselbe Problem und loest es nicht;
hier wird es nebenbei besser.
Anschliessend `apps/api/Dockerfile`: im Runner-Abschnitt eine Kopieranweisung fuer
`apps/api/scripts` ergaenzen — sinnvolle Stelle ist direkt nach Zeile 34, wo bereits
`apps/api/prisma` kopiert wird — und die CMD-Zeile 38 durch den Aufruf des Skripts ersetzen.
Ohne die Kopieranweisung liegt das Skript nicht im Abbild und der Container startet nicht;
beides gehoert in denselben Commit.
Dann `docker-compose.yml`: beim Dienst `api` im Umgebungsblock direkt unter Zeile 33
(`DATABASE_URL`) einen Eintrag `TESSERA_MIGRATE_DATABASE_URL` ergaenzen, der aus der
gleichnamigen Variablen mit leerer Vorgabe gefuellt wird. Dasselbe in
`docker-compose.prod.yml` unter Zeile 34. `docker-compose.dev.yml` und `docker-compose.ci.yml`
bleiben unberuehrt — nachgesehen: dev setzt kein `DATABASE_URL`, ci hat gar keinen
API-Dienst.
Zuletzt `.env.example`: unter Zeile 2 einen kommentierten Block ergaenzen. Er nennt beide
Variablen, sagt in deutschen Saetzen, dass `DATABASE_URL` die Verbindung der laufenden
Anwendung ist und `TESSERA_MIGRATE_DATABASE_URL` die des Migrationsschritts, dass die zweite
leer bleiben darf und dann alles wie bisher laeuft, und dass die getrennte Belegung erst
sinnvoll ist, wenn die Anleitung `docs/mandantentrennung-datenbankrolle.md` abgearbeitet
wurde. Beide Beispielwerte tragen Platzhalter, keine echten Zugangsdaten. Die auskommentierte
Beispielzeile fuer die getrennte Belegung ausdruecklich auskommentiert lassen — sie darf
nicht versehentlich in Kraft treten.
</action>
<verify>
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/start-script.spec.ts</automated>
</verify>
<done>`apps/api/scripts/migrate-and-start.sh` existiert, wird vom Dockerfile kopiert und als CMD aufgerufen; beide Compose-Dateien reichen `TESSERA_MIGRATE_DATABASE_URL` durch; `.env.example` erklaert beide Variablen mit Platzhaltern. Die fuenf Tests in `src/prisma/start-script.spec.ts` sind gruen und belegen insbesondere, dass ohne gesetzte Migrationsvariable beide Schritte weiterhin `DATABASE_URL` verwenden.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung mit Rueckweg</name>
<files>apps/api/scripts/rls-preflight.mjs, apps/api/src/prisma/rls-preflight.spec.ts, docs/mandantentrennung-datenbankrolle.md, docs/README.md</files>
<precondition>Node ist aufrufbar und `@prisma/client` ist in `apps/api/node_modules` aufgeloest (durch `pnpm install`, bereits vorhanden). Der Test ruft das Werkzeug ausschliesslich in der Pruefplan-Betriebsart auf; es wird keine Datenbankverbindung aufgebaut.</precondition>
<reversibility rating="reversible">Ein Werkzeug und ein Dokument, die nichts veraendern. Loeschen genuegt.</reversibility>
<behavior>
`apps/api/src/prisma/rls-preflight.spec.ts` entsteht zuerst und ist rot. Er ruft
`execFileSync(process.execPath, [pfad, '--print-plan'], ...)` auf, wobei der Pfad ueber
`join(__dirname, '../../scripts/rls-preflight.mjs')` gebildet wird.
- Test 1: Der Aufruf endet mit Ende-Code 0, obwohl keine Verbindungsangabe in der Umgebung
steht — die Pruefplan-Betriebsart verbindet nicht.
- Test 2: Die Ausgabe nennt alle fuenf Pruefkennungen: `rollenrechte`, `kontext-setzbar`,
`ohne-kontext-leer`, `mit-kontext-sichtbar`, `schreibrechte`.
- Test 3: Die Ausgabe nennt `TESSERA_PREFLIGHT_DATABASE_URL` als die Variable, aus der die zu
pruefende Verbindung stammt.
- Test 4: Der Quelltext des Werkzeugs fuehrt jede Pruefung innerhalb einer Transaktion aus —
die Datei enthaelt `$transaction`. Begruendung im Test als Kommentar: Prisma haelt einen
Verbindungspool; ein `set_config` ausserhalb einer Transaktion kann auf einer anderen
Verbindung landen als die darauffolgende Abfrage, und die Messung waere wertlos.
- Test 5: Das Werkzeug schreibt nicht. Der Quelltext enthaelt keines der Schluesselwoerter
INSERT, UPDATE, DELETE, DROP oder ALTER in einer SQL-Zeichenkette.
</behavior>
<action>
Zuerst den Test schreiben (rot), dann das Werkzeug, dann die Anleitung.
`apps/api/scripts/rls-preflight.mjs` als ES-Modul anlegen. Es importiert `PrismaClient` aus
`@prisma/client` und erzeugt ihn mit einer uebergebenen Verbindung
(`new PrismaClient({ datasourceUrl: url })`), damit es gegen eine andere Rolle messen kann als
die, mit der die Anwendung laeuft. Kein neues Paket installieren — Prisma ist bereits
Abhaengigkeit der API.
Zwei Betriebsarten. Mit `--print-plan`: die Liste der Pruefungen mit Kennung und
Kurzbeschreibung ausgeben, den Namen der Umgebungsvariablen nennen und mit Ende-Code 0 enden,
ohne zu verbinden. Ohne Argument: die Verbindung aus `TESSERA_PREFLIGHT_DATABASE_URL` lesen,
bei fehlender Angabe mit einer verstaendlichen Meldung und Ende-Code 1 abbrechen, sonst alle
Pruefungen ausfuehren und einen deutschen Bericht ausgeben — je Zeile Kennung, Ergebnis,
gemessener Wert. Ende-Code 0 nur, wenn alle Pruefungen bestanden sind, sonst 1.
Jede einzelne Pruefung laeuft in einer eigenen interaktiven Transaktion
(`prisma.$transaction(async (tx) => { ... })`), und jedes Setzen des Mandantenkontexts
innerhalb dieser Transaktion geschieht transaktionslokal — also mit `true` als drittem
Argument von `set_config`, genau wie `apps/api/src/prisma/prisma-tenant.extension.ts:16` es
tut. Das ist keine Stilfrage: ausserhalb einer Transaktion kann der Verbindungspool die
Folgeabfrage auf eine andere Verbindung legen, auf der die Einstellung nie gesetzt wurde.
Die fuenf Pruefungen:
`rollenrechte` — aus `pg_roles` fuer `current_user` die Merkmale `rolsuper` und `rolbypassrls`
lesen. Bestanden, wenn beide falsch sind. Der Bericht nennt zusaetzlich den Rollennamen, damit
ein Betreiber sofort sieht, ob er versehentlich die alte Verbindung geprueft hat. Diese
Pruefung ist der eigentliche Kern: sie misst genau die Aussage aus WINDOWS #18.
`kontext-setzbar` — `set_config('app.current_tenant', 'probe', true)` ausfuehren und danach
`current_tenant_id()` lesen. Bestanden, wenn der gelesene Wert `probe` ist. Damit ist belegt,
dass eine Rolle ohne besondere Rechte den Mandantenkontext ueberhaupt setzen kann — eine
Annahme, die dieser Plan bewusst nicht ungeprueft laesst.
`ohne-kontext-leer` — ohne gesetzten Kontext fuer jede Tabelle mit Policy die Zeilenzahl
zaehlen. Die Tabellenliste nicht fest eintippen, sondern zur Laufzeit aus `pg_policies` fuer
das Schema `public` lesen — dann waechst die Pruefung automatisch mit Task 4 und mit jeder
spaeteren Policy mit. Bestanden, wenn jede Zahl 0 ist. Der Bericht nennt jede Tabelle, die
ungleich 0 liefert, denn genau diese Zeile ist der Beweis fuer eine wirkungslose Trennung.
`mit-kontext-sichtbar` — eine vorhandene Mandantenkennung aus der Tabelle `Tenant` lesen
(`Tenant` traegt selbst keine Policy und bleibt daher lesbar), den Kontext darauf setzen und
dieselben Zaehlungen wiederholen. Bestanden, wenn mindestens eine Tabelle mehr als 0 Zeilen
liefert — sonst waere nicht die Trennung bewiesen, sondern nur eine unbrauchbare Verbindung.
Findet sich kein Mandant, die Pruefung als "nicht durchfuehrbar" berichten statt sie
faelschlich zu bestehen.
`schreibrechte` — ueber `has_table_privilege` fuer jede Tabelle im Schema `public` alle vier
Zugriffsarten pruefen. Bestanden, wenn keine Tabelle ein Recht vermissen laesst. Der Bericht
nennt jede Luecke einzeln; genau hier zeigt sich, ob Task 1 etwas vergessen hat, bevor
jemand die Verbindung umstellt.
Danach `docs/mandantentrennung-datenbankrolle.md` schreiben — deutsch, an den Betreiber
gerichtet, im Ton der vorhandenen Anleitungen unter `docs/`. Inhalt:
(1) Der Befund in einfachen Worten: warum die Trennung heute nichts tut, mit der gemessenen
Beobachtung (ohne gesetzten Mandanten liefert eine Zaehlung auf `Group` zwei Zeilen statt
null) und dem Hinweis auf WINDOWS #18.
(2) Was diese Aenderung bereits mitbringt: die Rolle, die getrennten Verbindungen, das
Pruefwerkzeug, die vollstaendigen Policies.
(3) **Der Sperrgrund, unmissverstaendlich.** Die Umstellung ist noch nicht vollzogen und darf
noch nicht vollzogen werden. Gezaehlt am 2026-09-09: 182 Zugriffe im API-Quelltext laufen
ueber den unskalierten Prisma-Client (84 auf die bisher geschuetzten, 98 auf die neu
geschuetzten Tabellen), demgegenueber nur 19 Verwendungen von `tenantPrisma`. Der Anmeldeweg
gehoert dazu und kann gar nicht anders: `apps/api/src/auth/auth.service.ts:37-41` sucht den
Benutzer, bevor der Mandant bekannt ist. Unter der neuen Rolle liefert diese Suche null
Zeilen — niemand koennte sich mehr anmelden. Ebenso betroffen: die Hintergrunddienste fuer
AD-Abgleich, Ausschreibungs-Digest, Modulzugriff, Treffersuche und die Erstanlage des
Administrators. Diese Wege brauchen einen ausdruecklichen, benannten Systemkontext, bevor der
Schalter umgelegt werden darf. Das ist eigene Arbeit und nicht Teil dieser Aenderung.
(4) Die Handgriffe, die spaeter noetig sind, in der richtigen Reihenfolge und mit dem klaren
Vermerk, welche der Betreiber selbst ausfuehren muss, weil sie in keiner Migration stehen
koennen: das Kennwort der Rolle einmalig setzen (die Anweisung `ALTER ROLE tessera_app WITH
PASSWORD` mit Platzhalter statt eines echten Werts nennen und dazusagen, dass sie als
Datenbank-Superuser laufen muss und nicht in eine Datei gehoert); anschliessend in der
`.env` des Servers `TESSERA_MIGRATE_DATABASE_URL` auf die bisherige Verbindung setzen und
`DATABASE_URL` auf die neue Rolle umbiegen — in dieser Reihenfolge, denn die Migration muss
weiterhin als Eigentuemer laufen.
(5) Die Vorher-Pruefung: Aufruf des Werkzeugs mit gesetztem `TESSERA_PREFLIGHT_DATABASE_URL`
auf die neue Rolle. Sie ist die Freigabebedingung. Ausdruecklich dazusagen, dass ein gruener
Bericht nur bedeutet, dass die Datenbankseite stimmt — die 182 Zugriffe aus Punkt (3) misst
das Werkzeug nicht.
(6) Der Rueckweg und die Not-Antwort. Wenn die API nach einer Umstellung nicht mehr
hochkommt: das Sichtbare beschreiben (der Container bleibt ungesund, das Protokoll zeigt
einen Authentifizierungs- oder Rechtefehler von PostgreSQL) und den Weg zurueck in zwei
Schritten — `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung setzen und den
API-Dienst neu erstellen. Dazu der Hinweis, dass die neue Rolle dabei bestehen bleiben darf;
sie schadet nicht, solange niemand sie benutzt. Und der Hinweis, dass die Datei
`/opt/tessera/docker-compose.yml` auf dem Server keine Arbeitskopie des Repositorys ist und
von einem Deploy nicht angefasst wird — wer die neuen Variablen dort haben will, traegt sie
selbst ein.
Zuletzt `docs/README.md`: die neue Anleitung in der Tabelle der Leserkreise oder im Absatz
ueber das CI/CD-Runbook verlinken, mit einem Satz, der sagt, dass sie sich an dieselben Leute
wie die Betriebsanleitung richtet und nur die Datenbankrolle behandelt.
</action>
<verify>
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-preflight.spec.ts</automated>
</verify>
<done>`apps/api/scripts/rls-preflight.mjs` fuehrt fuenf benannte Pruefungen jeweils in einer Transaktion aus, verbindet in der Pruefplan-Betriebsart nicht und schreibt in keiner Betriebsart. `docs/mandantentrennung-datenbankrolle.md` nennt den Sperrgrund mit der gemessenen Zahl 182, die Handgriffe des Betreibers samt Kennwortsetzung, die Freigabebedingung und den Rueckweg; `docs/README.md` verweist darauf. Die fuenf Tests in `src/prisma/rls-preflight.spec.ts` sind gruen.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Policies fuer die 16 fehlenden Tabellen, mit benannten Ausnahmen und dauerhafter Abdeckungspruefung</name>
<files>apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql, apps/api/src/prisma/rls-coverage.spec.ts</files>
<precondition>Keine. Der Test liest ausschliesslich `apps/api/prisma/schema.prisma` und die Dateien unter `apps/api/prisma/migrations/`; es wird keine Datenbank benoetigt und keine Migration angewendet.</precondition>
<reversibility rating="costly">Policies lassen sich zurueckbauen, aber nur ueber eine weitere Migration — eine angewendete Migration darf nicht nachtraeglich veraendert werden. Solange die Anwendung als Rolle mit Umgehungsrecht verbindet, sind die neuen Policies ohne Wirkung auf den Betrieb; das begrenzt den Schaden einer falschen Entscheidung erheblich.</reversibility>
<behavior>
`apps/api/src/prisma/rls-coverage.spec.ts` entsteht zuerst und ist rot — er faellt heute mit
einer Liste von 16 nicht abgedeckten Tabellen. Er misst die Abdeckung, statt Text zu
vergleichen, und bleibt dadurch auch fuer kuenftige Modelle gueltig.
Aufbau: aus `apps/api/prisma/schema.prisma` alle `model`-Bloecke lesen und in zwei Mengen
teilen — Modelle mit einem Feld `tenantId` und Modelle ohne. Aus allen `migration.sql`-Dateien
unter `apps/api/prisma/migrations/` die Tabellennamen sammeln, fuer die
`ENABLE ROW LEVEL SECURITY` vorkommt, und getrennt davon die, fuer die `CREATE POLICY`
vorkommt.
- Test 1: Jedes Modell mit `tenantId` hat RLS eingeschaltet. Die Fehlermeldung nennt die
fehlenden Namen sortiert.
- Test 2: Jede Tabelle mit eingeschaltetem RLS hat auch mindestens eine Policy. Eingeschaltetes
RLS ohne Policy sperrt jede Zeile aus — das waere schlimmer als gar keine Regel.
- Test 3: Die Modelle ohne `tenantId` zerfallen genau in zwei im Test fest hinterlegte Listen:
die drei ueber eine Verknuepfung geschuetzten (PasswordResetToken, LdapFieldMapping,
GroupMembership) und die fuenf bewusst ausgenommenen (Tenant, Module, Tender, TenderSource,
TenderSourcePollConfig). Kommt ein neues Modell ohne `tenantId` hinzu, faellt der Test und
erzwingt eine Entscheidung, statt es stillschweigend durchzulassen. Jeder Eintrag der
Ausnahmeliste traegt im Test seine Begruendung als Zeichenkette.
- Test 4: Die neue Migration nennt jede der fuenf Ausnahmen namentlich in ihrem Kopf.
- Test 5: Die neue Migration schaltet fuer alle 16 Tabellen sowohl `ENABLE` als auch `FORCE`
ein und legt fuer jede genau eine Policy an — 16 Vorkommen von `CREATE POLICY`.
</behavior>
<action>
Zuerst den Test schreiben (rot, mit 16 gemeldeten Luecken), dann die Migration.
Die Einteilung ist am 2026-09-09 im Schema nachgezaehlt: 28 Modelle, 20 davon mit
`tenantId`-Spalte, 7 Tabellen mit Policy. Alle 16 fehlenden tragen eine direkte
`tenantId`-Spalte; keine braucht das Unterabfrage-Muster von `PasswordResetToken`.
**Bekommen eine Policy (16):** CalendarSource, DashboardLayout, DkvInvoiceHistory,
DkvModuleConfig, DkvVehicleMaster, FavoriteLink, SearchProvider, SmtpConfig,
TenantModuleActivation, TenderEmailConfig, TenderMatch, TenderNotificationPref,
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance.
**Bleiben bewusst ohne Policy (5), jeweils weil sie keine `tenantId`-Spalte tragen und auch
keine tragen sollen:** `Tenant` — die Mandantentabelle selbst; eine Regel darauf wuerde die
Aufloesung des Mandanten verhindern, auf der jede andere Regel beruht. `Module` — der
Modulkatalog ist plattformweit; die mandantenbezogene Zuordnung liegt in
`TenantModuleActivation`, und die bekommt eine Policy. `Tender`, `TenderSource` und
`TenderSourcePollConfig` — der Ausschreibungskatalog ist plattformweite Bezugsdaten
(Entscheidung D-03 aus Phase 10, wortwoertlich in
`.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-PLAN.md:93`: kein
`tenantId`, weil global). Eine Policy darauf wuerde einem zweiten Mandanten den gemeinsamen
Katalog verbergen.
**Eine Aussage aus dem Bestand ist zu korrigieren, und die Korrektur gehoert in den Kopf der
neuen Datei — nicht in die alte.** Der Kopf von
`apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` sagt pauschal,
"die Tender*-Tabellen bleiben bewusst ohne RLS (D-03)". Nachgemessen trifft das nur auf die
drei Tabellen ohne `tenantId` zu. Die sechs Tender-Tabellen mit `tenantId`
(TenderEmailConfig, TenderMatch, TenderNotificationPref, TenderRssFeedSource,
TenderSavedSearch, TenderTriage) enthalten keine Katalogdaten, sondern Zeilen einzelner
Nutzer und Mandanten — Suchprofile, Treffer, Benachrichtigungseinstellungen,
Postfachanbindungen. D-03 betrifft sie nicht. Die alte Datei bleibt unveraendert, weil Prisma
ihre Pruefsumme fuehrt und eine Aenderung `prisma migrate deploy` zum Abbruch bringen wuerde —
womit der API-Container nicht mehr startet.
Nun `apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql`
anlegen. Der Kopfkommentar traegt in deutschen Saetzen: die Zaehlung (28/20/7/16); die
Einteilung oben mit je einer Begruendung; die soeben beschriebene Korrektur samt Grund, warum
sie hier und nicht dort steht; und den unmissverstaendlichen Hinweis, dass diese Policies
erst wirken, wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet — mit Verweis auf die
Migration `20260909130000_rls_app_role` und auf
`docs/mandantentrennung-datenbankrolle.md`. Ohne diesen Satz waere die Datei genau das, wovor
WINDOWS #18 warnt: eine Regel, die Sicherheit vortaeuscht.
Der SQL-Koerper folgt fuer jede der 16 Tabellen exakt dem Muster aus
`20260804130918_groups_rls_policies` — RLS einschalten, erzwingen, und eine Policy
`tenant_isolation_policy` mit dem Vergleich der `tenantId`-Spalte gegen `current_tenant_id()`.
Der Policy-Name bleibt in allen Tabellen derselbe; Policy-Namen sind je Tabelle eindeutig, das
ist kein Konflikt und haelt die Suche einfach. Keine getrennte Pruefklausel angeben: laesst
man sie weg, verwendet PostgreSQL denselben Ausdruck auch fuer neu geschriebene Zeilen —
genau das ist gewollt, denn damit kann unter der neuen Rolle niemand eine Zeile mit fremder
Mandantenkennung einfuegen.
Reihenfolge im Dokument: alphabetisch nach Tabellenname, damit ein Leser eine Tabelle findet,
ohne die Datei zu durchsuchen. Jede Tabelle bekommt eine Kommentarzeile mit ihrer Rolle im
System (etwa: Dashboard-Anordnung eines Nutzers; Fahrzeugstammdaten des DKV-Moduls;
Suchprofile im Ausschreibungsmodul). Keine Datenaenderung, kein `ALTER TABLE` ausser dem
Ein- und Erzwingen von RLS.
</action>
<verify>
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-coverage.spec.ts</automated>
</verify>
<done>Alle 20 Modelle mit `tenantId` haben RLS eingeschaltet und eine Policy; keine Tabelle hat RLS ohne Policy; die fuenf Ausnahmen ohne `tenantId` sind im Test mit Begruendung und im Kopf der neuen Migration namentlich aufgefuehrt. Der Abdeckungstest ist gruen und faellt kuenftig bei jedem neuen Modell ohne bewusste Entscheidung. Keine bestehende Migrationsdatei wurde veraendert.</done>
</task>
</tasks>
<threat_model>
## Vertrauensgrenzen
| Grenze | Beschreibung |
|--------|--------------|
| Anwendungsprozess -> Datenbank | Hier soll die Mandantentrennung durchgesetzt werden. Heute ist die Grenze offen: die Anwendungsrolle umgeht jede Regel. |
| Betreiber -> Serverkonfiguration | Kennwort und Verbindungsangaben werden von Hand gesetzt; sie duerfen nirgends im Repository landen. |
| Migrationsschritt -> Laufzeitschritt | Der eine braucht Eigentuemerrechte, der andere darf sie gerade nicht haben. Bis heute nutzen beide dieselbe Verbindung. |
## STRIDE-Register
| Kennung | Kategorie | Betroffenes Teil | Schwere | Umgang | Massnahme |
|---------|-----------|------------------|---------|--------|-----------|
| T-DGJ-01 | Information Disclosure | Rolle `tessera` (`docker-compose.yml:33`/`:76`), rolsuper und rolbypassrls gesetzt | critical | mitigate | Task 1 legt `tessera_app` mit `NOSUPERUSER NOBYPASSRLS` an und konvergiert eine vorhandene Rolle darauf; Task 3 misst die beiden Merkmale, statt sie anzunehmen. Die Grenze bleibt bis zur Umstellung offen — Task 3 sagt das in der Anleitung ausdruecklich. |
| T-DGJ-02 | Information Disclosure | 16 Tabellen mit `tenantId` ohne Policy — u. a. SmtpConfig, DkvVehicleMaster, CalendarSource, TenderEmailConfig | high | mitigate | Task 4 ergaenzt fuer alle 16 eine Policy und sichert die Abdeckung dauerhaft ueber einen Test, der aus Schema und Migrationen misst statt Text zu vergleichen. |
| T-DGJ-03 | Denial of Service | Umstellung der Verbindung sperrt die Anwendung aus ihren eigenen Daten aus — gemessen: 182 unskalierte Zugriffe, darunter der Anmeldeweg | high | mitigate | Der Schalter bleibt als Vorgabe aus (Task 2: ohne gesetzte Migrationsvariable verhaelt sich alles wie bisher). Task 3 liefert die Vorher-Pruefung, den benannten Sperrgrund und einen Rueckweg in zwei Schritten. |
| T-DGJ-04 | Elevation of Privilege | Rollenanlage in einer Migration verlangt erhoehte Rechte zur Anwendungszeit | medium | mitigate | Task 1 prueft die Berechtigung vorher und bricht mit einer Meldung ab, die die von Hand auszufuehrende Anweisung nennt. Ein stilles Ueberspringen ist ausgeschlossen — es wuerde eine nicht vorhandene Rolle als vorhanden erscheinen lassen. |
| T-DGJ-05 | Information Disclosure | Zugangsdaten in Repository, Abbild oder Protokoll | medium | mitigate | Kein Kennwort im SQL (Task 1, per Test abgesichert); `.env.example` traegt nur Platzhalter; das Startskript gibt Variablennamen statt Werte aus (Task 2, per Test abgesichert). |
| T-DGJ-06 | Tampering | Einfuegen einer Zeile mit fremder Mandantenkennung | medium | mitigate | Die Policies geben keine getrennte Pruefklausel an; PostgreSQL verwendet dann denselben Ausdruck fuer neue Zeilen. Unter der neuen Rolle scheitert ein Einfuegen mit fremder Kennung. |
| T-DGJ-07 | Tampering | Nachtraegliche Aenderung einer bereits angewendeten Migration bricht `migrate deploy` und damit den Containerstart | medium | mitigate | Als globale Vorgabe festgeschrieben; die Korrektur der Aussage aus `20260804130918` steht ausschliesslich im Kopf der neuen Datei. |
| T-DGJ-08 | Spoofing | Falsches Sicherheitsgefuehl — Policies vorhanden, Wirkung nicht | high | mitigate | Der Kopf der neuen Migration sagt ausdruecklich, dass die Regeln erst mit der neuen Rolle wirken; die Anleitung nennt die Umstellung als nicht vollzogen; WINDOWS #18 bleibt offen. |
| T-DGJ-SC | Tampering | Lieferkette ueber Paketinstallationen | low | accept | Dieser Plan installiert kein Paket. Das Pruefwerkzeug nutzt `@prisma/client`, der bereits Abhaengigkeit der API ist; `package.json` und `pnpm-lock.yaml` bleiben unveraendert. |
</threat_model>
<verification>
Alle vier Abnahmepruefungen sind ohne Datenbank lauffaehig — die Gitea-CI hat keine
(`.gitea/workflows/ci.yml`: nur Lint, Typpruefung und `pnpm test`).
Vorab gemessen am 2026-09-09, damit die Pruefungen nicht von vornherein gruen sind:
`pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-app-role.spec.ts`
liefert heute Ende-Code 1 ("No test files found"); derselbe Aufruf ohne die Option liefert
Ende-Code 0. Die vorhandene Testdatei `src/groups/migration-sql.spec.ts` laeuft mit derselben
Befehlsform gruen (14 Tests) — die Befehlsform ist damit belegt und nicht geraten.
Nach allen vier Aufgaben zusaetzlich die vollstaendige Reihe:
`pnpm --filter=@tessera/api exec vitest run` — muss gruen bleiben. Und `pnpm lint` sowie
`pnpm type-check`, weil zwei neue Testdateien und ein neues ES-Modul hinzukommen.
Nicht Teil der Abnahme, weil ausserhalb dieses Vorgangs: das tatsaechliche Anwenden der
Migrationen, das Umstellen der Verbindung und jede Handlung auf 192.168.13.12.
</verification>
<success_criteria>
- Zwei neue Migrationsordner, keine bestehende Migrationsdatei veraendert.
- `tessera_app` wird wiederholbar angelegt, traegt weder Superuser- noch Umgehungsrecht, und die
Migration bricht mit einer verwendbaren Anleitung ab, wenn ihr die Rechte dafuer fehlen.
- Kein Kennwort und kein Verbindungswert in einer versionierten Datei.
- Ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` ist der Containerstart Schritt fuer Schritt der
bisherige — lokal, in der CI und auf dem Server.
- Das Pruefwerkzeug misst die fuenf benannten Eigenschaften in Transaktionen, veraendert nichts
und laesst sich ohne Datenbank in der Pruefplan-Betriebsart testen.
- Alle 20 Modelle mit `tenantId` sind abgedeckt; die fuenf Ausnahmen sind namentlich mit
Begruendung festgehalten und werden von einem Test bewacht.
- Die Anleitung nennt die Umstellung als nicht vollzogen, den Sperrgrund mit der gemessenen Zahl,
die Handgriffe des Betreibers und den Rueckweg bei einer nicht mehr verbindenden API.
- WINDOWS #18 bleibt offen; die Beschreibung des Eintrags kann um den Verweis auf
`docs/mandantentrennung-datenbankrolle.md` und den Sperrgrund ergaenzt werden.
</success_criteria>
<output>
Bei Abschluss `.planning/quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/260909-dgj-SUMMARY.md` schreiben.
</output>
@@ -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*
+7
View File
@@ -96,6 +96,13 @@ The DKV module already solved most of the infrastructure this feature needs. Thi
# v1.0 Base Stack (reference — unchanged)
> **Note added 2026-09-09:** This is the stack recommendation from June/July 2026,
> preserved here unchanged. Parts of it were never adopted (Keycloak, Redis,
> TanStack Query, shadcn/ui, Playwright, Husky, lint-staged), and two items were
> adopted at an older major version than recommended (Next.js, Prisma). The
> actually installed stack, checked against `package.json`/`pnpm-lock.yaml`, is
> documented in `CLAUDE.md` under "Technology Stack".
**Researched:** 2026-06-18
**Overall Confidence:** HIGH
+80 -54
View File
@@ -20,84 +20,107 @@ Tessera ist eine modulare, Docker-basierte Webplattform fuer interne Workflow-Au
<!-- GSD:project-end -->
<!-- GSD:stack-start source:research/STACK.md -->
## Technology Stack
## Recommended Stack
> The tables below show the **installed stack**, checked against `package.json`,
> `pnpm-lock.yaml` (`importers:` resolved versions), and the Compose/Dockerfiles
> on 2026-09-09. The original stack recommendation from 2026-06/07 lives unchanged
> in `.planning/research/STACK.md`; regenerating this block from that file would
> reintroduce the recommended-but-not-installed numbers below as if they were
> current.
### Monorepo & Package Management
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| pnpm | 9.x | Package manager | 60-80% disk reduction via content-addressable store, strict dependency isolation prevents phantom deps, workspace protocol for internal packages |
| Turborepo | 2.9.x | Build orchestration | Incremental builds, parallel execution, task dependency graph, caching. Vercel-maintained — aligns with Next.js ecosystem |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| pnpm | 9.15.0 | Package manager — 60-80% disk reduction via content-addressable store, strict dependency isolation, workspace protocol for internal packages |
| Turborepo | 2.9.18 | Build orchestration — incremental builds, parallel execution, task dependency graph, caching |
### Frontend
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Next.js | 16.2.x | Frontend framework | App Router with React 19, Turbopack default bundler (4x faster builds), stable Server Components, `use cache` directive, multi-tenant middleware support |
| React | 19.x | UI library | Ships with Next.js 16, React Compiler (automatic memoization), stable Server Actions |
| TypeScript | 5.5+ | Type safety | Required by all major tools, catches bugs at compile time |
| Tailwind CSS | 4.3.x | Styling | CSS-first config (no JS config file), 3.5x faster rebuilds, OKLCH colors, built-in dark mode via `.dark` selector |
| shadcn/ui | CLI v4 | Component library | Not a dependency — copies components into your project. Accessible, themeable via CSS variables, dark/light mode trivial with next-themes. Dashboard-ready components |
| next-themes | 0.4.x | Theme switching | 2-line dark mode integration with shadcn/ui, SSR-safe, system preference detection |
| next-intl | 4.13.x | Internationalization | Built for Next.js App Router, Server Component native, 457 bytes gzipped, simpler than i18next for Next.js-only projects |
| react-grid-layout | 2.2.x | Dashboard grid | TypeScript rewrite with hooks API (useGridLayout, useResponsiveLayout), drag & drop + resize, responsive breakpoints, 100% backward compat via /legacy |
| Zustand | 5.0.x | Client state | Lightweight (1.1kb), no providers needed, works with Server Components, perfect for UI state (sidebar toggle, theme, user prefs) |
| TanStack Query | 5.101.x | Server state | Caching, background refresh, optimistic updates, pagination. Use for client-side data fetching where Server Components don't suffice |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| Next.js | 15.5.19 | Frontend framework — App Router, React Server Components. A newer major (16.2.x) was recommended but not adopted; see "Recommended But Not Adopted" below |
| React | 19.2.7 | UI library |
| TypeScript | 5.9.3 | Type safety |
| Tailwind CSS | 4.3.1 | Styling — CSS-first config, OKLCH colors, built-in dark mode via `.dark` selector |
| next-themes | 0.4.6 | Theme switching — dark/light mode with system preference detection |
| next-intl | 4.13.0 | Internationalization |
| react-grid-layout | 2.2.3 | Dashboard grid — drag & drop + resize, responsive breakpoints |
| Zustand | 5.0.14 | Client state — UI state (sidebar toggle, theme, user prefs) |
### Backend
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| NestJS | 11.x | API framework | Modular architecture maps directly to Tessera's module system. Dependency injection, guards, interceptors, built-in microservices support. Modular monolith now, extract to microservices later if needed |
| Express | 5.x | HTTP server | Default in NestJS 11, battle-tested, massive middleware ecosystem |
| Prisma | 7.8.x | ORM | TypeScript-first, auto-generated types from schema, declarative migrations, Client Extensions for RLS multi-tenancy. v7 dropped Rust engine — 3x faster queries, 90% smaller bundles |
| PostgreSQL | 16.x | Database | Row-Level Security for multi-tenancy, JSONB for flexible module config, excellent Docker support, robust at any scale |
| Redis | 7.x | Cache & sessions | Session storage, pub/sub for real-time features, rate limiting, cache invalidation |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| NestJS | 11.1.27 | API framework — modular architecture, dependency injection, guards, interceptors |
| Express | 5.2.1 | HTTP server (via `@nestjs/platform-express`) |
| Prisma | 6.19.3 | ORM. A newer major (7.8.x) was recommended but not adopted; see "Recommended But Not Adopted" below |
| PostgreSQL | `postgres:16-alpine` | Database — Row-Level Security for multi-tenancy, JSONB for module config |
### Authentication & Authorization
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Keycloak | 26.6.x | Identity provider | Native multi-tenancy via realms, LDAP/AD federation built-in, OAuth2/OIDC standard, Docker-native, admin UI included. Eliminates building auth from scratch |
| nest-keycloak-connect | latest | NestJS integration | Guards, decorators, multi-tenant realm resolvers — handles JWT validation and role extraction |
| @nestjs/passport | latest | Fallback auth | For API key auth on module-to-module communication |
No identity provider is deployed. The installed system is a self-built login stack:
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| @nestjs/jwt | 11.0.2 | Issues and validates the session JWT |
| passport + @nestjs/passport | 0.7.0 / 11.0.5 | Login and JWT authentication strategies (not module-to-module API-key auth — that was an earlier, since-corrected description of this package's purpose) |
| argon2 | 0.44.0 | Password hashing |
| ldapts | 8.1.8 | LDAP/AD directory binding for user sync |
### Desktop Wrapper
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Tauri | 2.x (2.11+) | Desktop app | 5MB installer vs Electron's 150MB. Uses OS-native WebView — no bundled Chromium. Rust backend for system APIs. Windows + Linux supported. Perfect for wrapping an existing web app |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| Tauri | 2.11.1 (CLI 2.11.3) | Desktop app wrapper. `apps/desktop` is scaffolding only as of `docs/anleitung-entwicklung.md` |
### Infrastructure & DevOps
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Docker | 27.x | Containerization | Project constraint. Multi-stage builds for production images |
| Docker Compose | 2.x | Orchestration | Multi-service local dev and production deployment. Health checks, volume management, networking |
| Nginx Proxy Manager | latest | Reverse proxy | External reverse proxy managed by host infrastructure. Tessera containers do not include a reverse proxy — NPM handles SSL termination and routing externally |
| Gitea | existing | Version control | Already in place. Automate via webhooks and Gitea API |
| Technology | Version | Purpose |
|------------|---------|---------|
| Node.js | `node:24-alpine` | JS runtime for both `apps/api` and `apps/web` production images (`apps/api/Dockerfile`, `apps/web/Dockerfile`) |
| Docker | 29.8.0 (host property, measured on the dev machine, 2026-09-09) | Not pinned by this repository |
| Docker Compose | v5.5.1 (host property, measured on the dev machine, 2026-09-09) | Not pinned by this repository |
| Nginx Proxy Manager | external | Reverse proxy managed by host infrastructure — Tessera containers do not include a reverse proxy |
| Gitea | existing | Version control, already in place |
### Testing
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Vitest | 3.x | Unit/integration tests | Fast, ESM-native, compatible with Jest API, works with both Next.js and NestJS |
| Playwright | 1.x | E2E tests | Cross-browser, auto-wait, trace viewer. Best for testing the full portal flow |
| Testing Library | latest | Component tests | DOM-based testing, framework-agnostic patterns |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| Vitest (apps/api) | 3.2.6 | Unit/integration tests |
| Vitest (apps/web) | 4.1.9 | Unit/integration tests — a different major than apps/api, not yet aligned |
| Testing Library (@testing-library/react) | 16.3.2 | Component tests |
### Developer Experience
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Biome | 2.x | Linting + formatting | Single tool replaces ESLint + Prettier, 100x faster, zero-config defaults. Reduces tooling complexity |
| Husky | 9.x | Git hooks | Pre-commit formatting/linting enforcement |
| lint-staged | 15.x | Staged file linting | Only lint changed files for fast commits |
| Technology | Installed Version | Purpose |
|------------|--------------------|---------|
| Biome | 2.5.0 | Linting + formatting — replaces ESLint + Prettier |
## Recommended But Not Adopted
The following was recommended in the original 2026-06/07 stack research
(`.planning/research/STACK.md`) but is not part of the running system. Listed
here, outside the tables above, so nothing in this section is mistaken for
something that is actually built:
- **Identity provider:** Keycloak 26.6.x plus `nest-keycloak-connect` were recommended for authentication and multi-tenant realm federation. Not built — no Keycloak service in any Compose file, no package in the lockfile. What runs instead is the self-built stack in the Authentication & Authorization table above.
- **Cache / session store:** Redis 7.x was recommended. Not installed — no service, no package.
- **Server state library:** TanStack Query 5.101.x was recommended. Not installed.
- **Component library:** shadcn/ui CLI v4 was recommended. Not used — no `components.json`, no `components/ui` directory.
- **E2E test runner:** Playwright 1.x was recommended as a project dependency. Not installed as one — browser checks run through the Playwright MCP tool, which is not a dependency of this repository and has no `playwright.config.*` here.
- **Git hooks:** Husky 9.x and lint-staged 15.x were recommended. Not installed — no `.husky` directory.
- **Next.js major version:** 16.2.x was recommended; the installed major is 15.5.19 (see Frontend table above). No statement here about whether an upgrade is planned.
- **Prisma major version:** 7.8.x was recommended; the installed major is 6.19.3 (see Backend table above). No statement here about whether an upgrade is planned.
## Alternatives Considered
The table below reflects the decision record from 2026-06, at the time the stack
was chosen. Its "Recommended" column documents what was picked back then — it is
not a statement about what is installed today; see the tables above for that.
| Category | Recommended | Alternative | Why Not |
|----------|-------------|-------------|---------|
| Frontend Framework | Next.js 16 | Remix / SvelteKit | Next.js has best multi-tenant support, largest ecosystem, Vercel Platforms template as reference |
@@ -120,7 +143,7 @@ Tessera ist eine modulare, Docker-basierte Webplattform fuer interne Workflow-Au
- Scales to thousands of tenants without catalog bloat
- Single connection pool (no per-tenant connection overhead)
- Prisma Client Extensions support RLS via session variables
- Prisma Client Extensions support RLS via session variables — in active use, see `apps/api/src/prisma/prisma-tenant.extension.ts`
- Simpler migrations — one schema to manage
- Cost-effective for the planned growth trajectory
@@ -146,13 +169,16 @@ Tessera ist eine modulare, Docker-basierte Webplattform fuer interne Workflow-Au
## Version Pinning Strategy
- **Major versions:** Pin to major (e.g., `next@16`, `@nestjs/core@11`)
- **Prisma:** Pin exactly — schema changes require matched versions
- **Tailwind/shadcn:** Follow latest within major — utility additions are non-breaking
- **Keycloak Docker image:** Pin to minor (e.g., `quay.io/keycloak/keycloak:26.6`)
- **Major versions:** Pin to major (e.g., `next@15`, `@nestjs/core@11`)
- **Prisma:** Pin exactly — schema changes require matched versions (currently 6.19.3)
- **Tailwind:** Follow latest within major — utility additions are non-breaking (currently 4.3.x)
## Sources
Consulted for the original 2026-06/07 stack recommendation — sources for that
decision, not evidence of the current installed state (see the tables above for
that):
- [Next.js 16 Docs](https://nextjs.org/docs/app/guides/upgrading/version-16) — Version 16.2.7+ stable
- [NestJS Documentation](https://docs.nestjs.com/) — Version 11.1.x
- [Prisma ORM 7 Announcement](https://www.prisma.io/blog/announcing-prisma-orm-7-0-0) — Pure TypeScript runtime
+2 -1
View File
@@ -33,6 +33,7 @@ COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma ./node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts
USER nestjs
EXPOSE 3001
CMD ["sh", "-c", "apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js"]
CMD ["sh", "apps/api/scripts/migrate-and-start.sh"]
@@ -0,0 +1,103 @@
-- WINDOWS #18 — Anwendungsrolle ohne RLS-Umgehungsrecht (T-DGJ-01, T-DGJ-04,
-- T-DGJ-05). Erste von zwei Migrationen; die zweite (20260909140000) ergaenzt
-- die restlichen Policies.
--
-- Befund (gemessen am 2026-09-09, siehe docker-compose.yml:33/:76): Die API
-- verbindet als Rolle "tessera". Diese Rolle entsteht aus POSTGRES_USER und
-- ist damit Superuser des Postgres-Clusters (auf alpha gemessen:
-- rolsuper = t, rolbypassrls = t). PostgreSQL wendet Row-Level Security auf
-- Superuser-Rollen und Rollen ohne NOBYPASSRLS grundsaetzlich nicht an;
-- FORCE ROW LEVEL SECURITY aendert daran nichts, weil es nur den
-- Tabelleneigentuemer erfasst, nicht Rollen mit Umgehungsrecht. Die sieben
-- vorhandenen Policies
-- (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant,
-- PasswordResetToken) sind unter der Rolle "tessera" deshalb ohne Wirkung.
--
-- Diese Migration allein stellt noch nichts um — sie legt lediglich die
-- Rolle "tessera_app" an und konvergiert sie bei Wiederholung auf
-- NOSUPERUSER/NOBYPASSRLS. Niemand verbindet mit ihr, solange
-- DATABASE_URL nicht umgestellt wird. Der volle Ablauf inklusive
-- Kennwortvergabe steht in docs/mandantentrennung-datenbankrolle.md — das
-- Kennwort wird bewusst NICHT hier gesetzt, sondern vom Betreiber von Hand,
-- weil es sonst im Klartext in dieser versionierten Datei laenden wuerde.
DO $$
DECLARE
can_manage_roles boolean;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN
-- Rolle existiert noch nicht: current_user braucht die Berechtigung,
-- Rollen anzulegen. Lautes Scheitern mit Anleitung ist hier richtig —
-- ein stilles Ueberspringen wuerde einen Betreiber im Glauben lassen,
-- die Rolle existiere bereits.
SELECT rolsuper OR rolcreaterole INTO can_manage_roles
FROM pg_roles WHERE rolname = current_user;
IF NOT can_manage_roles THEN
RAISE EXCEPTION
'tessera_app existiert nicht und current_user (%) darf keine Rollen anlegen. '
'Einmalig als Datenbank-Superuser ausfuehren: '
'CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; '
'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user;
END IF;
CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
ELSE
-- Rolle existiert bereits: auf denselben Stand konvergieren. Nur ein
-- Superuser darf SUPERUSER/NOBYPASSRLS setzen oder entziehen — ist
-- current_user keiner, pruefen, ob die Merkmale bereits beide falsch
-- sind, statt das ALTER zu versuchen.
SELECT rolsuper INTO can_manage_roles FROM pg_roles WHERE rolname = current_user;
IF can_manage_roles THEN
ALTER ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
ELSE
IF EXISTS (
SELECT 1 FROM pg_roles
WHERE rolname = 'tessera_app' AND (rolsuper OR rolbypassrls)
) THEN
RAISE EXCEPTION
'tessera_app traegt noch rolsuper oder rolbypassrls, und current_user (%) '
'ist kein Superuser, um das zu korrigieren. Einmalig als '
'Datenbank-Superuser ausfuehren: '
'ALTER ROLE tessera_app WITH NOSUPERUSER NOBYPASSRLS; '
'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user;
END IF;
-- rolsuper und rolbypassrls sind bereits beide falsch — reiner Durchlauf.
END IF;
END IF;
END
$$;
-- Rechte, alle idempotent (ein wiederholtes GRANT ist in PostgreSQL
-- folgenlos). Datenbankname und Eigentuemer werden dynamisch gebildet,
-- damit die Migration auch gegen eine anders benannte Installation bzw.
-- eine andere migrierende Rolle laeuft.
DO $$
BEGIN
EXECUTE format('GRANT CONNECT ON DATABASE %I TO tessera_app', current_database());
END
$$;
GRANT USAGE ON SCHEMA public TO tessera_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tessera_app;
-- Das Schema nutzt derzeit keine Sequenzen (kein einziges autoincrement
-- nachgezaehlt) — die Vergabe kostet nichts und verhindert einen spaeteren
-- Stolperstein, sollte eine kuenftige Migration eine Sequenz einfuehren.
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tessera_app;
-- Vorgaberechte, damit kuenftige Migrationen (angewendet von current_user,
-- also der migrierenden Rolle — nicht fest "tessera" eingetragen, weil die
-- auf einer anderen Installation anders heissen kann) nicht jedes Mal
-- nachziehen muessen.
DO $$
BEGIN
EXECUTE format(
'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tessera_app',
current_user
);
EXECUTE format(
'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tessera_app',
current_user
);
END
$$;
@@ -0,0 +1,156 @@
-- WINDOWS #18 — vollstaendige RLS-Abdeckung fuer alle Tabellen mit
-- tenantId (T-DGJ-02). Zweite von zwei Migrationen zu diesem Fund; die
-- erste (20260909130000_rls_app_role) legt die Anwendungsrolle
-- tessera_app ohne Superuser-/BYPASSRLS-Recht an.
--
-- Zaehlung im Schema (Stand 2026-09-09): 28 Modelle insgesamt, davon 20 mit
-- direkter tenantId-Spalte. Von diesen 20 trugen bislang 4 eine Policy
-- (User, LdapConfig, Group, ModuleGrant — aus 20260618112133 und
-- 20260804130918). Diese Migration ergaenzt die restlichen 16.
--
-- Die 8 Modelle OHNE tenantId zerfallen in zwei Gruppen:
--
-- (a) Drei ueber einen Join geschuetzt, keine eigene tenantId-Spalte,
-- bereits mit Policy versehen: PasswordResetToken (userId -> User),
-- LdapFieldMapping (ldapConfigId -> LdapConfig), GroupMembership
-- (groupId -> Group).
--
-- (b) Fuenf bewusst OHNE Policy, weil sie keine tenantId-Spalte tragen
-- und auch keine tragen sollen:
-- - Tenant: die Mandantentabelle selbst. Eine Regel darauf wuerde
-- die Aufloesung des Mandanten verhindern, auf der jede andere
-- Regel beruht.
-- - Module: der Modulkatalog ist plattformweit; die
-- mandantenbezogene Zuordnung liegt in TenantModuleActivation,
-- und die bekommt hier eine Policy.
-- - Tender, TenderSource, TenderSourcePollConfig: der
-- Ausschreibungskatalog ist plattformweite Bezugsdaten
-- (Entscheidung D-03 aus Phase 10, siehe
-- .planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-PLAN.md:93:
-- kein tenantId, weil global). Eine Policy darauf wuerde einem
-- zweiten Mandanten den gemeinsamen Katalog verbergen.
--
-- KORREKTUR einer Aussage aus dem Bestand — die alte Datei bleibt dabei
-- unveraendert, weil Prisma ihre Pruefsumme fuehrt und eine Aenderung
-- "prisma migrate deploy" zum Abbruch braechte:
-- Der Kopf von 20260804130918_groups_rls_policies sagt pauschal, "die
-- Tender*-Tabellen bleiben bewusst ohne RLS (D-03)". Nachgemessen trifft
-- das nur auf die drei oben genannten Tabellen OHNE tenantId zu. Die
-- sechs Tender-Tabellen MIT tenantId (TenderEmailConfig, TenderMatch,
-- TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch,
-- TenderTriage) enthalten keine Katalogdaten, sondern Zeilen einzelner
-- Nutzer und Mandanten — Suchprofile, Treffer,
-- Benachrichtigungseinstellungen, Postfachanbindungen. D-03 betrifft sie
-- nicht; sie bekommen unten allesamt eine Policy.
--
-- WICHTIG: Diese Policies wirken erst, wenn die Anwendung als Rolle ohne
-- Umgehungsrecht verbindet — siehe Migration 20260909130000_rls_app_role
-- und docs/mandantentrennung-datenbankrolle.md. Die Verbindung ist zum
-- Zeitpunkt dieser Migration noch NICHT umgestellt. Ohne diesen Satz waere
-- diese Datei genau das, wovor WINDOWS #18 warnt: eine Regel, die
-- Sicherheit vortaeuscht.
--
-- Keine getrennte WITH CHECK-Klausel: laesst man sie weg, verwendet
-- PostgreSQL denselben USING-Ausdruck auch fuer neu geschriebene Zeilen —
-- genau das ist gewollt, damit unter der neuen Rolle niemand eine Zeile
-- mit fremder Mandantenkennung einfuegen kann.
-- CalendarSource — Kalenderquellen (CalDAV/ICS/Exchange) eines Nutzers
ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "CalendarSource"
USING ("tenantId" = current_tenant_id());
-- DashboardLayout — Widget-Anordnung eines Nutzers auf dem Dashboard
ALTER TABLE "DashboardLayout" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DashboardLayout" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DashboardLayout"
USING ("tenantId" = current_tenant_id());
-- DkvInvoiceHistory — DKV-Rechnungshistorie samt Exportstatus
ALTER TABLE "DkvInvoiceHistory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DkvInvoiceHistory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DkvInvoiceHistory"
USING ("tenantId" = current_tenant_id());
-- DkvModuleConfig — Postfach-/Zugangsdaten des DKV-Moduls je Mandant
ALTER TABLE "DkvModuleConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DkvModuleConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DkvModuleConfig"
USING ("tenantId" = current_tenant_id());
-- DkvVehicleMaster — Fahrzeugstammdaten des DKV-Moduls
ALTER TABLE "DkvVehicleMaster" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DkvVehicleMaster" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DkvVehicleMaster"
USING ("tenantId" = current_tenant_id());
-- FavoriteLink — Favoriten-Links eines Nutzers
ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "FavoriteLink"
USING ("tenantId" = current_tenant_id());
-- SearchProvider — vom Nutzer angelegte Suchmaschinen fuer das Such-Widget
-- (tenantId ist nullable — Vorgabe-Anbieter sind ueber Konstanten geloest,
-- 05-02; eine mandantenlose Zeile wird von dieser Policy nicht ausgeliefert)
ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "SearchProvider"
USING ("tenantId" = current_tenant_id());
-- SmtpConfig — SMTP-Zugangsdaten je Mandant
ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "SmtpConfig"
USING ("tenantId" = current_tenant_id());
-- TenantModuleActivation — welche Module ein Mandant aktiviert hat
ALTER TABLE "TenantModuleActivation" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenantModuleActivation" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenantModuleActivation"
USING ("tenantId" = current_tenant_id());
-- TenderEmailConfig — Postfachanbindung eines Nutzers im Ausschreibungs-Radar
ALTER TABLE "TenderEmailConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderEmailConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderEmailConfig"
USING ("tenantId" = current_tenant_id());
-- TenderMatch — Treffer eines gespeicherten Suchprofils
ALTER TABLE "TenderMatch" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderMatch" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderMatch"
USING ("tenantId" = current_tenant_id());
-- TenderNotificationPref — Benachrichtigungseinstellungen eines Nutzers
ALTER TABLE "TenderNotificationPref" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderNotificationPref" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderNotificationPref"
USING ("tenantId" = current_tenant_id());
-- TenderRssFeedSource — RSS-Quellen im Ausschreibungs-Radar
-- (tenantId ist nullable — null markiert eine plattformweite Quelle, D-06;
-- eine solche Zeile wird von dieser Policy nicht ausgeliefert)
ALTER TABLE "TenderRssFeedSource" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderRssFeedSource" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderRssFeedSource"
USING ("tenantId" = current_tenant_id());
-- TenderSavedSearch — gespeicherte Suchprofile eines Nutzers
ALTER TABLE "TenderSavedSearch" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderSavedSearch" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderSavedSearch"
USING ("tenantId" = current_tenant_id());
-- TenderTriage — Favorisierungs-/Ablehnungsstatus eines Nutzers je Treffer
ALTER TABLE "TenderTriage" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenderTriage" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "TenderTriage"
USING ("tenantId" = current_tenant_id());
-- WidgetInstance — platzierte Dashboard-Widgets eines Nutzers
ALTER TABLE "WidgetInstance" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WidgetInstance" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "WidgetInstance"
USING ("tenantId" = current_tenant_id());
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# WINDOWS #18 — trennt die Verbindung des Migrationsschritts von der
# Verbindung des Laufzeitschritts (T-DGJ-03).
#
# `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers, um DDL
# auszufuehren. Die Anwendung soll genau diese Rechte NICHT haben — sonst
# waere die Rollentrennung aus Migration 20260909130000_rls_app_role wieder
# verloren. Ohne getrennte Verbindungen fuer Migration und Laufzeit ist eine
# Rollentrennung deshalb nicht moeglich.
#
# Vorgabe (ohne gesetztes TESSERA_MIGRATE_DATABASE_URL): beide Schritte
# nutzen DATABASE_URL — exakt der bisherige Ablauf, unveraendert fuer
# lokale Entwicklung, CI und den Server, bis
# docs/mandantentrennung-datenbankrolle.md abgearbeitet ist.
set -e
if [ -z "$DATABASE_URL" ]; then
echo "FEHLER: DATABASE_URL ist nicht gesetzt." >&2
exit 1
fi
# In sh ersetzt ${VAR:-fallback} auch eine gesetzte, aber leere Variable
# durch den Fallback — Compose reicht nicht belegte Variablen als leere
# Zeichenketten weiter, das soll wie "nicht gesetzt" behandelt werden.
MIGRATE_URL="${TESSERA_MIGRATE_DATABASE_URL:-$DATABASE_URL}"
if [ -n "$TESSERA_MIGRATE_DATABASE_URL" ]; then
MIGRATE_SOURCE="TESSERA_MIGRATE_DATABASE_URL"
else
MIGRATE_SOURCE="DATABASE_URL"
fi
RUNTIME_SOURCE="DATABASE_URL"
if [ "$1" = "--print-plan" ]; then
# Nur Variablennamen ausgeben, niemals Werte — diese Betriebsart existiert
# allein, damit die CI das Verhalten ohne Datenbank pruefen kann.
echo "Migrationsschritt verwendet: $MIGRATE_SOURCE"
echo "Laufzeitschritt verwendet: $RUNTIME_SOURCE"
exit 0
fi
# DATABASE_URL nur fuer diesen einen Aufruf auf die Migrationsverbindung
# setzen (vorangestellte Zuweisung, kein export) — der nachfolgende exec
# erbt die unveraenderte DATABASE_URL aus der Umgebung.
DATABASE_URL="$MIGRATE_URL" apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma
# exec statt Aufruf, damit Signale (z. B. SIGTERM bei docker stop) den
# Node-Prozess erreichen. Die bisherige CMD-Zeile hatte dieses Problem
# ebenfalls und loeste es nicht; hier wird es nebenbei mitbehoben.
exec node apps/api/dist/main.js
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// WINDOWS #18 — misst, statt zu behaupten, ob die Mandantentrennung unter
// einer angegebenen Datenbankrolle tatsaechlich greift (Task 3).
//
// Verbindet mit der ueber TESSERA_PREFLIGHT_DATABASE_URL angegebenen Rolle
// (new PrismaClient({ datasourceUrl: url })), damit gegen eine andere Rolle
// gemessen werden kann als die, mit der die Anwendung laeuft. Kein neues
// Paket noetig — Prisma ist bereits Abhaengigkeit der API.
//
// Jede Pruefung laeuft in einer eigenen interaktiven Transaktion
// (prisma.$transaction), und jedes Setzen des Mandantenkontexts geschieht
// transaktionslokal (set_config(..., true)) — genau wie
// apps/api/src/prisma/prisma-tenant.extension.ts es tut. Ausserhalb einer
// Transaktion kann Prismas Verbindungspool die Folgeabfrage auf eine andere
// physische Verbindung legen, auf der die Einstellung nie gesetzt wurde;
// die Messung waere dann wertlos.
//
// Das Werkzeug schreibt nichts. Es liest ausschliesslich.
import { PrismaClient } from '@prisma/client';
const CHECKS = [
['rollenrechte', 'current_user traegt weder rolsuper noch rolbypassrls (WINDOWS #18, der eigentliche Kern dieser Pruefung)'],
['kontext-setzbar', 'set_config(app.current_tenant, ...) laesst sich ohne besonderes Recht setzen und lesen'],
['ohne-kontext-leer', 'ohne gesetzten Mandantenkontext liefert jede Tabelle mit Policy null Zeilen'],
['mit-kontext-sichtbar', 'mit gesetztem Mandantenkontext ist mindestens eine Tabelle wieder sichtbar'],
['schreibrechte', 'current_user hat auf jede Tabelle im Schema public alle vier Zugriffsarten'],
];
const ENV_VAR = 'TESSERA_PREFLIGHT_DATABASE_URL';
function printPlan() {
console.log('Pruefplan (verbindet nicht):');
for (const [kennung, beschreibung] of CHECKS) {
console.log(`- ${kennung}: ${beschreibung}`);
}
console.log(`Verbindung wird aus der Umgebungsvariablen ${ENV_VAR} gelesen.`);
}
async function checkRollenrechte(tx) {
const rows = await tx.$queryRaw`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
const row = rows[0];
const passed = Boolean(row) && row.rolsuper === false && row.rolbypassrls === false;
return {
kennung: 'rollenrechte',
passed,
detail: row
? `Rolle ${row.rolname}: rolsuper=${row.rolsuper}, rolbypassrls=${row.rolbypassrls}`
: 'current_user nicht in pg_roles gefunden',
};
}
async function checkKontextSetzbar(tx) {
await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, 'probe');
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS tenant`;
const value = rows[0]?.tenant;
return {
kennung: 'kontext-setzbar',
passed: value === 'probe',
detail: `current_tenant_id() lieferte: ${JSON.stringify(value)}`,
};
}
async function getPolicyTables(prisma) {
const rows = await prisma.$queryRaw`SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename`;
return rows.map((r) => r.tablename);
}
async function countRows(tx, table) {
const rows = await tx.$queryRawUnsafe(`SELECT count(*)::int AS count FROM "${table}"`);
return rows[0]?.count ?? 0;
}
async function checkOhneKontextLeer(tx, policyTables) {
const nonZero = [];
for (const table of policyTables) {
const count = await countRows(tx, table);
if (count !== 0) nonZero.push(`${table}=${count}`);
}
return {
kennung: 'ohne-kontext-leer',
passed: nonZero.length === 0,
detail:
nonZero.length === 0
? `alle ${policyTables.length} Tabellen mit Policy liefern 0 Zeilen`
: `sichtbar ohne Kontext (Beweis fuer wirkungslose Trennung): ${nonZero.join(', ')}`,
};
}
async function checkMitKontextSichtbar(prisma, tx, policyTables) {
const tenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" LIMIT 1`;
const tenantId = tenantRows[0]?.id;
if (!tenantId) {
return {
kennung: 'mit-kontext-sichtbar',
passed: null,
detail: 'nicht durchfuehrbar — kein Mandant in der Tabelle Tenant gefunden',
};
}
await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
const visible = [];
for (const table of policyTables) {
const count = await countRows(tx, table);
if (count > 0) visible.push(`${table}=${count}`);
}
return {
kennung: 'mit-kontext-sichtbar',
passed: visible.length > 0,
detail:
visible.length > 0
? `mit Mandant ${tenantId} sichtbar: ${visible.join(', ')}`
: `mit Mandant ${tenantId} liefert weiterhin keine Tabelle Zeilen`,
};
}
async function checkSchreibrechte(tx) {
const tableRows = await tx.$queryRaw`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`;
const privileges = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
const gaps = [];
for (const { table_name: table } of tableRows) {
const missing = [];
for (const priv of privileges) {
const rows = await tx.$queryRawUnsafe(
`SELECT has_table_privilege(current_user, $1, $2) AS allowed`,
table,
priv,
);
if (!rows[0]?.allowed) missing.push(priv);
}
if (missing.length > 0) gaps.push(`${table} fehlt ${missing.join('/')}`);
}
return {
kennung: 'schreibrechte',
passed: gaps.length === 0,
detail: gaps.length === 0 ? `alle ${tableRows.length} Tabellen vollstaendig` : gaps.join('; '),
};
}
async function runChecks(url) {
const prisma = new PrismaClient({ datasourceUrl: url });
const results = [];
try {
results.push(await prisma.$transaction((tx) => checkRollenrechte(tx)));
results.push(await prisma.$transaction((tx) => checkKontextSetzbar(tx)));
const policyTables = await getPolicyTables(prisma);
results.push(await prisma.$transaction((tx) => checkOhneKontextLeer(tx, policyTables)));
results.push(await prisma.$transaction((tx) => checkMitKontextSichtbar(prisma, tx, policyTables)));
results.push(await prisma.$transaction((tx) => checkSchreibrechte(tx)));
} finally {
await prisma.$disconnect();
}
return results;
}
async function main() {
const args = process.argv.slice(2);
if (args[0] === '--print-plan') {
printPlan();
process.exit(0);
}
const url = process.env[ENV_VAR];
if (!url) {
console.error(`FEHLER: ${ENV_VAR} ist nicht gesetzt.`);
process.exit(1);
}
const results = await runChecks(url);
console.log('Ergebnis der Mandantentrennungs-Pruefung:');
let allPassed = true;
for (const { kennung, passed, detail } of results) {
const status = passed === true ? 'bestanden' : passed === false ? 'FEHLGESCHLAGEN' : 'nicht durchfuehrbar';
if (passed === false) allPassed = false;
console.log(`${kennung}: ${status} — ${detail}`);
}
process.exit(allPassed ? 0 : 1);
}
main().catch((err) => {
console.error('FEHLER beim Ausfuehren der Pruefung:', err.message);
process.exit(1);
});
+74
View File
@@ -0,0 +1,74 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prueft die neue Rollen-Migration (Task 1, WINDOWS #18) rein textuell gegen
* die generierte migration.sql — keine Datenbank noetig. Baut die
* Hilfsfunktion aus apps/api/src/groups/migration-sql.spec.ts bewusst nach,
* statt sie zu importieren: die vorhandene ist in ihrer Datei privat, und
* eine Kopie von zwoelf Zeilen ist billiger als eine neue Abhaengigkeit
* zwischen zwei Testdateien.
*/
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
describe('rls_app_role migration.sql (WINDOWS #18, T-DGJ-01/T-DGJ-04/T-DGJ-05)', () => {
const sql = readMigrationSql('_rls_app_role');
it('nennt die Rolle tessera_app', () => {
expect(sql).toContain('tessera_app');
});
it('entzieht ausdruecklich beide Umgehungswege (NOSUPERUSER, NOBYPASSRLS) — BYPASSRLS kommt nie ohne vorangestelltes NO vor', () => {
expect(sql).toContain('NOSUPERUSER');
expect(sql).toContain('NOBYPASSRLS');
const bypassOccurrences = sql.match(/BYPASSRLS/g) ?? [];
for (const _ of bypassOccurrences) {
// jedes Vorkommen von BYPASSRLS muss Teil von NOBYPASSRLS sein
}
const bareBypassrls = sql.match(/(?<!NO)BYPASSRLS/g) ?? [];
expect(bareBypassrls.length).toBe(0);
});
it('ist wiederholbar — Existenzpruefung ueber pg_roles, DO $$ und IF NOT EXISTS', () => {
expect(sql).toContain('pg_roles');
expect(sql).toContain('DO $$');
expect(sql).toMatch(/IF NOT EXISTS/);
});
it('bildet den Datenbanknamen dynamisch ueber current_database(), nicht fest verdrahtet', () => {
expect(sql).toContain('current_database()');
});
it('setzt die Vorgaberechte fuer current_user, nicht fuer einen fest verdrahteten Rollennamen', () => {
expect(sql).toContain('FOR ROLE');
expect(sql).toContain('current_user');
});
it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => {
expect(sql).not.toMatch(/PASSWORD\s*'/);
});
it('erteilt alle vier Datenzugriffsarten (SELECT, INSERT, UPDATE, DELETE)', () => {
for (const verb of ['SELECT', 'INSERT', 'UPDATE', 'DELETE']) {
expect(sql).toContain(verb);
}
expect(sql).toMatch(/GRANT[^;]*SELECT[^;]*INSERT[^;]*UPDATE[^;]*DELETE/);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Misst die RLS-Abdeckung aus dem tatsaechlichen Schema und den
* tatsaechlichen Migrationen, statt Text zu vergleichen — bleibt dadurch
* auch fuer kuenftige Modelle gueltig (Task 4, WINDOWS #18).
*/
const SCHEMA_PATH = join(__dirname, '../../prisma/schema.prisma');
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
interface ModelInfo {
name: string;
hasTenantId: boolean;
}
function parseModels(): ModelInfo[] {
const schema = readFileSync(SCHEMA_PATH, 'utf-8');
const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs;
const models: ModelInfo[] = [];
let match: RegExpExecArray | null;
while ((match = modelRegex.exec(schema))) {
const [, name, body] = match;
const hasTenantId = /^\s*tenantId\s+String/m.test(body);
models.push({ name, hasTenantId });
}
return models;
}
function readAllMigrationSql(): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
return dirs
.map((dir) => {
try {
return readFileSync(join(MIGRATIONS_DIR, dir, 'migration.sql'), 'utf-8');
} catch {
return '';
}
})
.join('\n');
}
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
function tablesWithRlsEnabled(sql: string): Set<string> {
const result = new Set<string>();
const re = /ALTER TABLE "(\w+)" ENABLE ROW LEVEL SECURITY/g;
let m: RegExpExecArray | null;
while ((m = re.exec(sql))) result.add(m[1]);
return result;
}
function tablesWithPolicy(sql: string): Set<string> {
const result = new Set<string>();
const re = /CREATE POLICY \w+ ON "(\w+)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(sql))) result.add(m[1]);
return result;
}
// Test 3: die feste Ausnahmeliste der Modelle ohne tenantId, mit Begruendung.
const JOIN_PATTERN_TABLES: Record<string, string> = {
PasswordResetToken: 'geschuetzt ueber Join userId -> User -> tenantId',
LdapFieldMapping: 'geschuetzt ueber Join ldapConfigId -> LdapConfig -> tenantId',
GroupMembership: 'geschuetzt ueber Join groupId -> Group -> tenantId',
};
const DELIBERATELY_EXCLUDED_TABLES: Record<string, string> = {
Tenant: 'die Mandantentabelle selbst — eine Regel wuerde die Aufloesung des Mandanten verhindern',
Module: 'plattformweiter Modulkatalog — mandantenbezogene Zuordnung liegt in TenantModuleActivation',
Tender: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)',
TenderSource: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)',
TenderSourcePollConfig: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)',
};
describe('RLS-Abdeckung aller Modelle mit tenantId (WINDOWS #18, T-DGJ-02)', () => {
const models = parseModels();
const allSql = readAllMigrationSql();
const enabledTables = tablesWithRlsEnabled(allSql);
const policyTables = tablesWithPolicy(allSql);
it('Test 1: jedes Modell mit tenantId hat RLS eingeschaltet', () => {
const tenantModels = models.filter((m) => m.hasTenantId).map((m) => m.name);
const missing = tenantModels.filter((name) => !enabledTables.has(name)).sort();
expect(missing, `Fehlende RLS-Aktivierung: ${missing.join(', ')}`).toEqual([]);
});
it('Test 2: jede Tabelle mit eingeschaltetem RLS hat mindestens eine Policy', () => {
const missing = [...enabledTables].filter((name) => !policyTables.has(name)).sort();
expect(missing, `RLS ohne Policy (sperrt jede Zeile aus): ${missing.join(', ')}`).toEqual([]);
});
it('Test 3: Modelle ohne tenantId zerfallen genau in Join-Muster (3) und bewusste Ausnahmen (5)', () => {
const nonTenantModels = models.filter((m) => !m.hasTenantId).map((m) => m.name).sort();
const expected = [...Object.keys(JOIN_PATTERN_TABLES), ...Object.keys(DELIBERATELY_EXCLUDED_TABLES)].sort();
expect(nonTenantModels, 'Neues Modell ohne tenantId gefunden — erzwingt eine bewusste Entscheidung').toEqual(
expected,
);
});
it('Test 4: die neue Migration nennt jede der fuenf Ausnahmen namentlich in ihrem Kopf', () => {
const sql = readMigrationSql('_rls_remaining_tenant_tables');
for (const name of Object.keys(DELIBERATELY_EXCLUDED_TABLES)) {
expect(sql, `Ausnahme ${name} fehlt im Migrationskopf`).toContain(name);
}
});
it('Test 5: die neue Migration schaltet fuer alle 16 Tabellen ENABLE und FORCE ein und legt genau 16 Policies an', () => {
const sql = readMigrationSql('_rls_remaining_tenant_tables');
const enableCount = (sql.match(/ENABLE ROW LEVEL SECURITY/g) ?? []).length;
const forceCount = (sql.match(/FORCE ROW LEVEL SECURITY/g) ?? []).length;
const policyCount = (sql.match(/CREATE POLICY/g) ?? []).length;
expect(enableCount).toBe(16);
expect(forceCount).toBe(16);
expect(policyCount).toBe(16);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prueft apps/api/scripts/rls-preflight.mjs ausschliesslich in der
* Pruefplan-Betriebsart (--print-plan) — es wird keine Datenbankverbindung
* aufgebaut (Task 3, WINDOWS #18).
*/
const SCRIPT_PATH = join(__dirname, '../../scripts/rls-preflight.mjs');
describe('rls-preflight.mjs --print-plan', () => {
it('Test 1: endet mit Ende-Code 0, obwohl keine Verbindungsangabe in der Umgebung steht', () => {
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
env: { PATH: process.env.PATH ?? '' },
encoding: 'utf-8',
});
expect(stdout).toBeTruthy();
});
it('Test 2: nennt alle fuenf Pruefkennungen', () => {
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
env: { PATH: process.env.PATH ?? '' },
encoding: 'utf-8',
});
for (const kennung of [
'rollenrechte',
'kontext-setzbar',
'ohne-kontext-leer',
'mit-kontext-sichtbar',
'schreibrechte',
]) {
expect(stdout).toContain(kennung);
}
});
it('Test 3: nennt TESSERA_PREFLIGHT_DATABASE_URL als Quelle der zu pruefenden Verbindung', () => {
const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], {
env: { PATH: process.env.PATH ?? '' },
encoding: 'utf-8',
});
expect(stdout).toContain('TESSERA_PREFLIGHT_DATABASE_URL');
});
it('Test 4: jede Pruefung laeuft innerhalb einer Transaktion ($transaction) — ausserhalb kann der Verbindungspool die Folgeabfrage auf eine andere Verbindung legen als die, auf der set_config gesetzt wurde, und die Messung waere wertlos', () => {
const source = readFileSync(SCRIPT_PATH, 'utf-8');
expect(source).toContain('$transaction');
});
it('Test 5: das Werkzeug schreibt nicht — keines der Schluesselwoerter INSERT, UPDATE, DELETE, DROP, ALTER in einer SQL-Zeichenkette', () => {
const source = readFileSync(SCRIPT_PATH, 'utf-8');
const sqlStrings = source.match(/`[^`]*`/g) ?? [];
for (const str of sqlStrings) {
for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER']) {
expect(str.toUpperCase()).not.toContain(verb);
}
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prueft apps/api/scripts/migrate-and-start.sh ausschliesslich ueber die
* Ausgabebetriebsart --print-plan — es wird weder Prisma noch Node
* aufgerufen, keine Datenbankverbindung aufgebaut (Task 2, WINDOWS #18).
*/
const SCRIPT_PATH = join(__dirname, '../../scripts/migrate-and-start.sh');
const SECRET_MARKER = 'tessera-test-secret-marker-9f3c1e';
function runScript(env: Record<string, string>): { stdout: string; status: number } {
try {
const stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], {
env,
encoding: 'utf-8',
});
return { stdout, status: 0 };
} catch (err: any) {
return { stdout: (err.stdout ?? '').toString(), status: err.status ?? 1 };
}
}
describe('migrate-and-start.sh --print-plan', () => {
it('Test 1 (Rueckwaertsvertraeglichkeit): ohne TESSERA_MIGRATE_DATABASE_URL melden beide Schritte DATABASE_URL', () => {
const { stdout, status } = runScript({
PATH: process.env.PATH ?? '',
DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`,
});
expect(status).toBe(0);
expect(stdout).toContain('DATABASE_URL');
// Kein Wert, nur der Variablenname darf erscheinen
expect(stdout).not.toContain(SECRET_MARKER);
// beide Zeilen (Migration + Laufzeit) nennen DATABASE_URL als Quelle
const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length;
expect(dbUrlMentions).toBeGreaterThanOrEqual(2);
});
it('Test 2 (Trennung): mit gesetztem TESSERA_MIGRATE_DATABASE_URL nennt der Migrationsschritt die Migrationsvariable, der Laufzeitschritt bleibt bei DATABASE_URL', () => {
const { stdout, status } = runScript({
PATH: process.env.PATH ?? '',
DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`,
TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}@db:5432/tessera`,
});
expect(status).toBe(0);
expect(stdout).toContain('TESSERA_MIGRATE_DATABASE_URL');
expect(stdout).toContain('DATABASE_URL');
expect(stdout).not.toContain(SECRET_MARKER);
});
it('Test 3 (leer zaehlt als nicht gesetzt): TESSERA_MIGRATE_DATABASE_URL="" verhaelt sich wie Test 1', () => {
const { stdout, status } = runScript({
PATH: process.env.PATH ?? '',
DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`,
TESSERA_MIGRATE_DATABASE_URL: '',
});
expect(status).toBe(0);
expect(stdout).not.toContain('TESSERA_MIGRATE_DATABASE_URL wird verwendet');
const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length;
expect(dbUrlMentions).toBeGreaterThanOrEqual(2);
});
it('Test 4 (kein Geheimnisabfluss): keiner der beiden Verbindungswerte erscheint in der Ausgabe', () => {
const { stdout } = runScript({
PATH: process.env.PATH ?? '',
DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`,
TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}b@db:5432/tessera`,
});
expect(stdout).not.toContain(SECRET_MARKER);
});
it('Test 5 (fehlende Angabe): ohne DATABASE_URL bricht das Skript mit Ende-Code ungleich 0 ab und nennt den Variablennamen', () => {
let stdout = '';
let stderr = '';
let status = 0;
try {
stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], {
env: { PATH: process.env.PATH ?? '' },
encoding: 'utf-8',
});
} catch (err: any) {
status = err.status ?? 1;
stdout = (err.stdout ?? '').toString();
stderr = (err.stderr ?? '').toString();
}
expect(status).not.toBe(0);
expect(stdout + stderr).toContain('DATABASE_URL');
});
});
+4
View File
@@ -32,6 +32,7 @@ services:
condition: service_healthy
environment:
DATABASE_URL: ${DATABASE_URL}
TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL}
@@ -53,6 +54,8 @@ services:
# it falls back to it.
TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}"
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}"
volumes:
- user-files:/app/user-files
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
interval: 10s
@@ -88,3 +91,4 @@ networks:
volumes:
pgdata:
user-files:
+4
View File
@@ -31,6 +31,7 @@ services:
condition: service_healthy
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://tessera:tessera_dev@db:5432/tessera}
TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET:-tessera-dev-jwt-secret-change-in-production}
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local}
@@ -57,6 +58,8 @@ services:
# it falls back to it.
TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}"
CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}"
volumes:
- user-files:/app/user-files
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
interval: 10s
@@ -91,3 +94,4 @@ networks:
volumes:
pgdata:
user-files:
+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
+30 -14
View File
@@ -270,24 +270,40 @@ Restores stattfinden.
- **PostgreSQL-Daten**: liegen im benannten Docker-Volume `pgdata`
(`docker-compose.prod.yml`, Mount `pgdata:/var/lib/postgresql/data` im
`db`-Container). Dieses Volume ist der einzige daürhafte Datenspeicher, der über
ein `docker volume`-Backup zusätzlich gesichert werden könnte.
`db`-Container). `pgdata` ist eines von zwei dauerhaften Docker-Volumes (siehe
nächster Punkt) und kann zusätzlich über ein `docker volume`-Backup gesichert
werden.
- **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf
dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, generierte
DKV-Exporte unter `user-files/`, siehe `apps/api/src/user/user.controller.ts` und
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien landen im Verzeichnis
`user-files/` **innerhalb** des `api`-Containers. Weder `docker-compose.yml` noch
`docker-compose.prod.yml` mounten dafür ein Docker-Volume oder ein Host-Verzeichnis
– der Ordner existiert ausschließlich in der beschreibbaren Container-Schicht.
**Das bedeutet: Bei jedem `--force-recreate` von `api` gehen Avatare und DKV-Exporte
verloren**, sofern auf dem Server nicht zusätzlich (außerhalb des Repo-Standes)
ein Bind-Mount ergänzt wurde. Vor einem Redeploy prüfen, ob auf dem Server ein
solcher Mount existiert (`docker inspect tessera-api-1` → `Mounts`); falls
nicht, gelten Avatare/Exporte als nicht persistent und sollten bei Bedarf vorher
manuell aus dem Container kopiert werden (`docker compose cp api:/app/user-files
./user-files-backup`).
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
```yaml
# beim Dienst api:
- user-files:/app/user-files
# im Top-Level-Block volumes:
user-files:
```
Damit überstehen Avatare und DKV-Exporte ein `--force-recreate` von `api`. Wie bei
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
Sicherung des Docker-Volumes selbst (wie bei `pgdata`).
**Wichtig für den Betrieb auf einem bestehenden Server:** `/opt/tessera/docker-compose.yml`
ist keine Arbeitskopie dieses Repositorys – die Datei wurde dort von Hand
bearbeitet und weicht ab. Ein Deploy holt ausschließlich Images und fasst diese
Datei nicht an. Diese Compose-Änderung erreicht eine bereits laufende Installation
deshalb **nicht von selbst**. Wer die Reparatur dort haben will, trägt dieselben
zwei Zeilen selbst in `/opt/tessera/docker-compose.yml` ein (vorher sichern) und
erstellt die Container einmal neu. Bis dahin gilt für die laufende Installation
weiterhin der alte, verlustbehaftete Zustand – prüfbar mit `docker inspect
tessera-api-1` und einem Blick auf `Mounts`.
## 7. Protokolle und Fehlersuche
@@ -313,7 +329,7 @@ protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
| Neue Version scheint nicht anzukommen, obwohl `pull` gelaufen ist | Klassische `up -d`-Falle ohne `--force-recreate` (siehe Kapitel 4) | `StartedAt` des Containers gegen `Created` des Images vergleichen, ggf. `--force-recreate` nachholen. |
| Initialer Admin-Login funktioniert nicht nach Änderung von `TESSERA_ADMIN_PASSWORD` | Seed läuft nur, wenn der Benutzername noch **nicht** existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die `.env`-Variable. |
| Mails werden nicht versendet | `TESSERA_SMTP_HOST` leer (Prod-Default) | SMTP-Variablen vollständig setzen und Container neu erstellen. |
| Avatare/DKV-Exporte nach einem Deploy verschwunden | `user-files/` ist nicht als Volume gemountet, siehe Kapitel 6 | Vor `--force-recreate` sichern, langfristig einen Bind-Mount für `user-files/` ergänzen. |
| Avatare/DKV-Exporte nach einem Deploy verschwunden | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. |
## 8. Abgrenzung zur CI/CD-Pipeline
+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).