Files
tessera-ctl/.planning/quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/260909-dgj-PLAN.md
T
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

639 lines
46 KiB
Markdown

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