--- phase: quick-260909-eor plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/api/src/prisma/prisma-tenant.extension.ts - apps/api/src/prisma/prisma-tenant.extension.spec.ts - apps/api/scripts/rls-scratch-check.mjs - apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql - apps/api/src/prisma/auth-lookup-functions.spec.ts - apps/api/src/auth/auth.service.ts - apps/api/src/auth/auth.service.spec.ts - apps/api/src/prisma/rls-access-inventory.spec.ts - docs/mandantentrennung-zugriffsklassifikation.md - docs/mandantentrennung-datenbankrolle.md - .planning/WINDOWS.md autonomous: false requirements: [WINDOWS-18, WINDOWS-19] estimate: tokens: 95000 raw_tokens: 95000 tasks: 4 confidence: low # keine Kalibrierungsstichproben fuer dieses Repository vorhanden; Faktor 1,0 angesetzt must_haves: truths: - "forTenant() setzt den Mandantenkontext und fuehrt die Abfrage auf DERSELBEN Datenbankverbindung aus — gemessen ueber pg_backend_pid(), nicht behauptet." - "Unter einer Rolle ohne BYPASSRLS sieht ein forTenant(A)-Lesezugriff ausschliesslich Zeilen von Mandant A und niemals Zeilen von Mandant B." - "Unter derselben Rolle findet die Benutzersuche des Anmeldewegs den passenden Benutzer weiterhin — die Anmeldung bleibt moeglich." - "Unter derselben Rolle liefert ein gewoehnlicher, ungebundener SELECT auf \"User\" null Zeilen. Die Ausnahme ist die Funktion, nicht die Tabelle." - "Jede this.prisma.*-Fundstelle in apps/api/src traegt eine schriftliche Einstufung mit Begruendung, und eine Maschine prueft die Vollstaendigkeit." - "DATABASE_URL zeigt am Ende dieser Etappe unveraendert auf die bisherige Rolle — es wird nichts scharf geschaltet." artifacts: - apps/api/src/prisma/prisma-tenant.extension.ts - apps/api/src/prisma/prisma-tenant.extension.spec.ts - apps/api/scripts/rls-scratch-check.mjs - apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql - apps/api/src/prisma/auth-lookup-functions.spec.ts - apps/api/src/prisma/rls-access-inventory.spec.ts - docs/mandantentrennung-zugriffsklassifikation.md key_links: - "prisma-tenant.extension.ts: die Array-Form von $transaction bindet set_config und die Abfrage an eine Verbindung — die interaktive Callback-Form ist genau das, was heute bricht." - "auth.service.ts validateUser -> auth_lookup_user_by_username: der einzige verbleibende Lesezugriff auf \"User\" vor bekanntem Mandanten." - "rls-access-inventory.spec.ts -> docs/mandantentrennung-zugriffsklassifikation.md: die Spec haelt das Dokument waehrend Etappe 2/3 wahr, waehrend Fundstellen umgebaut werden." - "auth_lookup_*-Funktionen -> GRANT EXECUTE ausschliesslich an tessera_app: die Rolle, die spaeter tatsaechlich verbindet, ist die einzige, die die Ausnahme nutzen darf." --- Etappe 1 der Mandantentrennung: das Fundament belastbar machen, bevor irgendein Zugriff umgebaut wird. Drei Dinge werden hier abschliessend geklaert. Erstens: `forTenant()` ist defekt — gemessen, nicht vermutet — und wird repariert. Zweitens: der Anmeldeweg bekommt eine bewusst schmale, begruendete Ausnahme, damit die Umstellung spaeter nicht in einer Anwendung endet, in die sich niemand mehr einloggen kann. Drittens: alle Datenbankzugriffe werden klassifiziert und die Klassifikation maschinell gegen den Quelltext abgesichert, damit die folgenden Etappen eine Landkarte mit Mengenangaben haben. Purpose: Ohne ein funktionierendes `forTenant()` waere jeder Umbau in Etappe 2 wertlos — er wuerde Aufrufe auf einen Mechanismus umstellen, der nichts bewirkt. Ohne die Anmelde-Ausnahme waere das Scharfschalten in Etappe 4 ein garantierter Totalausfall. Output: repariertes `forTenant()` mit Live-Nachweis, drei eng geschnittene Anmelde-Funktionen in der Datenbank samt Verdrahtung, ein maschinell geprueftes Klassifikationsdokument ueber alle 232 Fundstellen. **Ausdruecklich NICHT in dieser Etappe:** `DATABASE_URL` wird nicht auf `tessera_app` umgestellt. Der Server 192.168.13.12 wird nicht angefasst. Migrationen laufen ausschliesslich gegen eine lokale Wegwerf-Datenbank. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/STATE.md @.planning/WINDOWS.md @docs/mandantentrennung-datenbankrolle.md @apps/api/src/prisma/prisma-tenant.extension.ts @apps/api/src/prisma/prisma.service.ts @apps/api/src/auth/auth.service.ts @apps/api/src/prisma/rls-app-role.spec.ts @apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql @apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql Alle Zahlen des Auftrags wurden am 2026-09-09 nachgemessen. Zwei weichen ab und gelten in dieser korrigierten Fassung: | Groesse | Auftrag | Nachgemessen | Befehl | |---|---|---|---| | `this.prisma.*`-Fundstellen (ohne Specs) | 228 | **232**, verteilt auf **32 Dateien** | `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src --include=*.ts \| grep -v "\.spec\.ts" \| wc -l` | | tenders | 61 | **62** | dito, je Verzeichnis | | groups | 34 | **37** | dito | | ldap / dkv | 21 / 21 | 21 / 21 | dito | | user / module-registry | 17 / 17 | 17 / 17 | dito | | dashboard / auth | 13 / 13 | 13 / 13 | dito | | calendar / tenant | 12 / 8 | 12 / 8 | dito | | favorites / settings | 7 / 4 | 7 / 4 | dito | Die **36** Treffer auf `forTenant`/`tenantPrisma` sind irrefuehrend: die Mehrzahl sind Kommentare, die begruenden, warum an dieser Stelle *kein* `forTenant()` steht. Tatsaechliche `forTenant(`-Aufrufe: **6** (`ldap.service.ts:762,905,1179,1342`, `tenant.middleware.ts:44`, `tenant.guard.ts:41`). Tatsaechliche Abfragen ueber einen mandantengebundenen Client: **9**, alle in `ldap.service.ts`. `req.tenantPrisma` wird von `tenant.middleware.ts` und `tenant.guard.ts` gesetzt, im gesamten `apps/api/src` aber von keinem Controller gelesen — die Verdrahtung laeuft ins Leere. Das ist als Befund in Aufgabe 3 festzuhalten. Weiter nachgemessen: - 28 Prisma-Modelle. **8 ohne `tenantId`**: `Tenant`, `PasswordResetToken`, `LdapFieldMapping`, `Module`, `GroupMembership`, `Tender`, `TenderSource`, `TenderSourcePollConfig`. Achtung: `PasswordResetToken` und `GroupMembership` tragen dennoch RLS — ueber einen Unterabfrage-Join auf `User` bzw. `Group` (`20260618112133_rls_policies/migration.sql:18-21`). "Ohne `tenantId`" ist also nicht deckungsgleich mit "ohne Regel". - 2 Modelle mit nullbarem `tenantId`: `SearchProvider` (`schema.prisma:218`) und `TenderRssFeedSource` (`schema.prisma:579`) — das ist WINDOWS #19. - 16 `CREATE POLICY` in `20260909140000_rls_remaining_tenant_tables`; zusammen mit den frueheren Migrationen 20 abgedeckte Tabellen. - Lokale Datenbank erreichbar unter `172.19.0.2:5432`, Rolle `tessera` / `tessera_dev` (Container `tessera-ctl-db-1`, `postgres:16-alpine`, kein Host-Port). - Installiertes Prisma: **6.19.3** (nicht 7.x wie in CLAUDE.md behauptet). Die Extension-API dieser Fassung ist massgeblich. ## Der entscheidende Befund: forTenant() ist defekt Nicht gelesen, sondern gemessen. Ein Nachbau des exakten Musters aus `prisma-tenant.extension.ts` gegen die lokale Datenbank ergab: ``` inside tx : {"pid":254999,"t":"TENANT-A"} actual qry : {"pid":255000,"t":null} SAME CONNECTION? false TENANT VISIBLE TO ACTUAL QUERY? null ``` `set_config('app.current_tenant', ..., true)` laeuft auf Backend 254999. Die eigentliche Abfrage laeuft auf Backend 255000 und sieht den Mandantenkontext als NULL. Ursache: `query(args)` in `$allOperations` fuehrt die Operation auf dem **aeusseren** Client aus, nicht auf `tx`; die interaktive Transaktion haelt eine eigene Verbindung, die Einstellung ist transaktionslokal. Konsequenz: Alle heutigen `forTenant()`-Aufrufe sind stillschweigend ungebunden. Unter einer Rolle ohne BYPASSRLS wuerden sie nicht etwa zu viel liefern, sondern **null Zeilen** — die Policy `"tenantId" = current_tenant_id()` vergleicht gegen NULL. Der AD-Abgleich in `ldap.service.ts` wuerde beim Scharfschalten wortlos leerlaufen und, im Loeschzweig ab Zeile 1559, potenziell Gruppen als "im Verzeichnis verschwunden" behandeln. Aufgabe 1: forTenant() auf eine Verbindung zwingen und die Wirkung live nachweisen apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/prisma-tenant.extension.spec.ts, apps/api/scripts/rls-scratch-check.mjs Reine Codeaenderung an einer Datei plus zwei neue Pruefdateien; kein Schemawechsel, kein Betriebsschalter. - Der Mandantenkontext und die eigentliche Abfrage teilen sich eine Datenbankverbindung: die von `pg_backend_pid()` gemeldete Kennung ist bei beiden gleich. - `current_setting('app.current_tenant', true)` ist waehrend der eigentlichen Abfrage auf den uebergebenen Mandanten gesetzt, nicht NULL. - Unter einer Rolle ohne BYPASSRLS liefert ein `forTenant(A)`-Lesezugriff auf eine Tabelle mit Policy genau die Zeilen von A. - Derselbe Lesezugriff liefert null Zeilen von Mandant B. - Ein ungebundener Lesezugriff derselben Rolle auf dieselbe Tabelle liefert null Zeilen. Ersetze in `prisma-tenant.extension.ts` die interaktive Callback-Form durch die Array-Form von `$transaction`. Der Kern: `$allOperations` gibt das Ergebnis eines `prisma.$transaction([ setConfigPromise, query(args) ])` zurueck und entnimmt den zweiten Eintrag. Prisma fuehrt die Array-Form als eine Transaktion auf einer Verbindung aus, weshalb die transaktionslokale Einstellung fuer die Abfrage sichtbar wird. Das ist genau das Muster, das Prisma selbst fuer RLS ueber Client-Extensions vorsieht. Der Mandantenwert MUSS parametrisiert bleiben — nutze ein getaggtes `$executeRaw`-Template mit interpoliertem Wert, nicht `$executeRawUnsafe` mit zusammengebautem Text. Die Injektionsfestigkeit aus T-02-05 ist eine bestehende Zusage und darf bei diesem Umbau nicht verloren gehen. Halte im Kopfkommentar der Datei fest, warum die Array-Form Pflicht ist und die Callback-Form nicht funktioniert, mit den gemessenen Backend-Kennungen als Beleg. Formuliere die Begruendung so, dass sie ohne diesen Plan verstaendlich bleibt. Pruefe beim Umbau ausdruecklich die Grenzfaelle und dokumentiere das Ergebnis im Kommentar: Was passiert, wenn der aufrufende Code auf dem mandantengebundenen Client selbst `$transaction` aufruft, und was passiert bei `$queryRaw`. Falls eine dieser Nutzungen mit der Array-Form nicht mehr traegt, ist das ein benannter Vorbehalt fuer Etappe 2 und gehoert in die SUMMARY — nicht stillschweigend uebergangen. Lege `apps/api/scripts/rls-scratch-check.mjs` an. Das Werkzeug richtet sich eine eigene Wegwerf-Datenbank ein (Vorschlag: `tessera_rls_scratch`), legt darin eine kleine Tabelle mit Mandantenspalte samt Policy und aktiviertem `FORCE ROW LEVEL SECURITY` an, legt eine Rolle ohne BYPASSRLS an, befuellt zwei Mandanten mit unterscheidbaren Zeilen und misst dann die fuenf oben genannten Verhaltensweisen. Am Ende raeumt es die Wegwerf-Datenbank wieder ab. Es darf die Datenbank `tessera` weder lesen noch veraendern — der Datenbankname gehoert fest ins Werkzeug, nicht in eine Umgebungsvariable, damit ein Tippfehler nicht in der echten Datenbank landet. Verbindungsangaben kommen ueber `TESSERA_SCRATCH_ADMIN_URL`; ohne diese Variable bricht das Werkzeug mit einer Anleitung ab, statt eine Vorgabe zu raten. Das Werkzeug meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald eine Pruefung scheitert. Es gibt kein Kennwort und keine vollstaendige Verbindungszeichenkette aus. Lege `prisma-tenant.extension.spec.ts` an. Die Spec prueft ohne laufende Datenbank die **Form** des Aufrufs, nicht seinen mit Produktionscode erzeugten Inhalt — die Lehre aus dem tautologischen Test in STATE.md: ein vorgetaeuschter Client zeichnet auf, dass `$transaction` mit einem Feld aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite Eintrag ist, und dass `query` waehrend des Aufbaus dieses Feldes genau einmal beruehrt wird. Ergaenze eine Spec-Zusicherung, dass der Quelltext der Extension `$executeRawUnsafe` nicht mehr verwendet. npm --prefix apps/api run test -- src/prisma/prisma-tenant.extension.spec.ts && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs Die Spec laeuft gruen und `rls-scratch-check.mjs` meldet alle fuenf Pruefungen bestanden, darunter ausdruecklich gleiche Backend-Kennung, gesetzter Mandantenkontext, null Fremdzeilen und null Zeilen ohne Kontext. Aufgabe 2: Den Anmeldeweg ueber drei eng geschnittene Datenbankfunktionen mandantenfaehig machen apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql, apps/api/src/prisma/auth-lookup-functions.spec.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/scripts/rls-scratch-check.mjs Aufgabe 1 ist abgeschlossen — die Nachweise dieser Aufgabe verlassen sich darauf, dass `forTenant()` tatsaechlich bindet. Eine Migration, die dauerhafte SECURITY-DEFINER-Funktionen anlegt. Ruecknehmbar nur ueber eine Folgemigration mit DROP FUNCTION, und die Funktionen sind eine Sicherheitsflaeche — die Schnittbreite ist nachtraeglich teuer zu korrigieren. - Die Benutzersuche nach Benutzername liefert unter einer Rolle ohne BYPASSRLS weiterhin genau den passenden Benutzer. - Ein gewoehnlicher SELECT ueber "User" liefert unter derselben Rolle null Zeilen. - Die Suche nach einem nicht vorhandenen Benutzernamen liefert nichts und wirft nicht. - Ein Benutzername in abweichender Gross-/Kleinschreibung findet denselben Benutzer wie bisher. - Die Token-Suche liefert unter derselben Rolle den passenden Rueckstell-Datensatz samt zugehoerigem Benutzer. - Nach gefundenem Benutzer laufen alle Schreibzugriffe des Anmeldewegs mandantengebunden. **Die Entscheidung und ihre Begruendung.** Von den drei erwogenen Wegen faellt die Wahl auf SECURITY-DEFINER-Funktionen. Eine zusaetzliche Policy auf `User` scheidet aus, weil eine Policy ein Zeilenpraedikat ist und nicht die Form der Abfrage einschraenken kann: eine Regel, die eine Suche nach Benutzername erlaubt, erlaubt zwangslaeufig auch das Auslesen aller Zeilen. Eine zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil sie einen zweiten Verbindungspool und einen zweiten Prisma-Client verlangt und auf `User` ohnehin dieselbe Breite haette. Eine Funktion dagegen bindet die Ausnahme an eine feste Abfrage mit festem Spaltensatz, fester Gleichheitsbedingung und `LIMIT 1` — eine kompromittierte Abfrage kann damit einen einzelnen Benutzernamen erraten, aber die Tabelle nicht ausleeren. Halte diese Begruendung im Kopf der Migration fest. **Die Migration.** Lege drei Funktionen an, jede `SECURITY DEFINER`, jede `STABLE`, jede mit fest angeheftetem Suchpfad auf `public, pg_temp`, jede mit `LIMIT 1`: - `auth_lookup_user_by_username(text)` — Gleichheitsvergleich gegen den kleingeschriebenen Benutzernamen, wie es `auth.service.ts:39-41` heute tut. Liefert nur die Felder, die der Anmeldeweg wirklich braucht: Kennung, Benutzername, Mandant, Kennwort-Hash, LDAP-DN, Aktiv-Merkmal, Rolle, Anzeigename, Kennwortwechsel-Merkmal. - `auth_lookup_user_by_email(text)` — dieselbe Bauart fuer `requestPasswordReset` (`auth.service.ts:144-146`). Liefert Kennung, Mandant, E-Mail und Aktiv-Merkmal; keinen Kennwort-Hash, denn dieser Pfad prueft kein Kennwort. - `auth_lookup_reset_token(text)` — Gleichheitsvergleich gegen den Token, liefert den Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers. Das Token ist eine `randomUUID` und nicht erratbar. Keine dieser Funktionen schreibt. Nach der Anlage jeweils saemtliche Rechte von PUBLIC entziehen und danach ausschliesslich `tessera_app` das Ausfuehrungsrecht erteilen. Die Migration muss wiederholbar sein — halte dich an das Muster aus `20260909130000_rls_app_role/migration.sql`, das die Existenz der Rolle prueft, bevor es Rechte vergibt, und mit einer verstaendlichen Anleitung scheitert statt still zu ueberspringen. Der feste Suchpfad ist bei SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition den Tabellenbezug in der Funktion umlenken und der Aufrufer erbte die Rechte des Eigentuemers. **Die Verdrahtung.** Ersetze in `auth.service.ts` genau die drei Lesezugriffe, die vor bekanntem Mandanten stattfinden, durch Aufrufe dieser Funktionen. Das sind `validateUser` (Zeile 39), `requestPasswordReset` (Zeile 144) und `resetPassword` (Zeile 178). Alle uebrigen Prisma-Aufrufe der Datei arbeiten mit einer bereits bekannten Benutzerkennung und damit bekanntem Mandanten: stelle die Schreibzugriffe in `validateUser` (Zeilen 71 und 84), `requestPasswordReset` (Zeile 161) sowie `resetPassword` (Zeilen 199 und 208) auf den mandantengebundenen Client aus Aufgabe 1 um, gebunden an den Mandanten des soeben gefundenen Benutzers. Damit ist `auth.service.ts` am Ende dieser Aufgabe vollstaendig umstellungsfaehig. `getMe`, `changePassword` und `adminResetPassword` suchen ueber die Benutzerkennung aus dem bereits ausgestellten Sitzungsnachweis — dort ist der Mandant bekannt. Sie sind damit gewoehnliche mandantengebundene Zugriffe und gehoeren in den Umbau der Etappe 2, nicht in diese Ausnahme. Fasse sie hier nicht an, sondern trage sie in Aufgabe 3 entsprechend ein. **Die Nachweise.** Erweitere `rls-scratch-check.mjs` um einen zweiten Abschnitt, der die Migration in die Wegwerf-Datenbank einspielt, zwei Benutzer in zwei Mandanten anlegt und unter der Rolle ohne BYPASSRLS misst: Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf `User` liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts. Lege `auth-lookup-functions.spec.ts` nach dem Vorbild von `rls-app-role.spec.ts` an: sie liest den Migrationstext und sichert die Eigenschaften, die die Schnittbreite ausmachen — angehefteter Suchpfad, `STABLE`, `LIMIT 1` bei allen drei Funktionen, Rechteentzug von PUBLIC vor der Rechtevergabe, Ausfuehrungsrecht ausschliesslich fuer `tessera_app`, kein Kennwort im Text. Zaehlbedingungen ueber den Migrationstext muessen Kommentarzeilen vorher herausfiltern, sonst zaehlt die Begruendung im Kopf der Datei als Treffer mit. Erweitere `auth.service.spec.ts` um die Verhaltensfaelle oben — mit vorgetaeuschtem Client, ohne laufende Datenbank. npm --prefix apps/api run test -- src/prisma/auth-lookup-functions.spec.ts src/auth/auth.service.spec.ts && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run type-check Beide Specs gruen, `type-check` sauber, und das Wegwerf-Werkzeug belegt unter der Rolle ohne BYPASSRLS: Anmeldesuche findet den Benutzer, gewoehnlicher SELECT auf "User" liefert null Zeilen. Aufgabe 3: Alle 232 Datenbankzugriffe klassifizieren und die Klassifikation maschinell absichern docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md - Die Bestandsaufnahme aus dem Quelltext und die Eintraege im Dokument decken sich vollstaendig. - Eine neu hinzugefuegte, nicht eingetragene Fundstelle laesst die Pruefung scheitern. - Eine im Dokument gefuehrte, im Quelltext verschwundene Datei laesst die Pruefung scheitern. Lege `docs/mandantentrennung-zugriffsklassifikation.md` an — deutschsprachig, im Ton der bestehenden `docs/`-Anleitungen, gerichtet an dieselben Leser wie `mandantentrennung-datenbankrolle.md`. Ermittle die Bestandsaufnahme zuerst maschinell aus dem Quelltext, damit sie nicht von Hand zusammengeschrieben und dabei unvollstaendig wird. Ordne dann jede Fundstelle genau einer der drei Klassen zu: - **muss mandantengebunden werden** — beruehrt auf Rechnung genau eines Mandanten eine Tabelle mit `tenantId`. - **bewusst uebergreifend** — muss ueber Mandanten hinweg sehen. Jede solche Einstufung braucht einen ausgeschriebenen Grund, nicht nur das Etikett. - **betrifft keine mandantengebundene Tabelle** — die acht Modelle ohne `tenantId`. Achtung, hier ist eine Falle: `PasswordResetToken` und `GroupMembership` haben kein eigenes `tenantId`, tragen aber ueber einen Join auf `User` bzw. `Group` sehr wohl eine Regel. Sie gehoeren deshalb nicht pauschal in diese dritte Klasse — pruefe je Fundstelle und begruende die Einordnung. Bekannte Kandidaten fuer "bewusst uebergreifend", jeweils zu pruefen und nicht ungeprueft zu uebernehmen: der Anmeldeweg aus Aufgabe 2, die Mandantenverwaltung selbst, der Modulkatalog, die plattformweit gehaltenen Ausschreibungsdaten nach D-03, die Erstanlage des Administrators beim ersten Start sowie die Hintergrunddienste. Der Hintergrunddienst ist die klassische Falle und verdient im Dokument einen eigenen Absatz: ein Planer, der ueber alle Mandanten iteriert, liest voellig zu Recht uebergreifend — muss aber *innerhalb* der Schleife je Mandant binden. Solche Stellen sind damit **beides** und gehoeren als solche gekennzeichnet, sonst faellt in Etappe 3 die eine Haelfte unter den Tisch. Betroffen sind mindestens der AD-Abgleich, der Ausschreibungs-Digest und die Ausschreibungs-Sofortmeldung. Nimm zwei belegte Befunde ausdruecklich mit auf: Erstens: `req.tenantPrisma` wird von `tenant.middleware.ts:44` und `tenant.guard.ts:41` gesetzt, im gesamten `apps/api/src` aber von keiner Stelle gelesen. Die Verdrahtung besteht, wird aber nicht genutzt. Fuer Etappe 2 ist damit zu entscheiden, ob die Controller kuenftig darueber gehen oder ob der Weg entfaellt — halte den Befund fest, entscheide ihn hier nicht. Zweitens: WINDOWS #19. `SearchProvider` und `TenderRssFeedSource` haben ein nullbares `tenantId`; die vorhandene Regel `tenantId = current_tenant_id()` blendet Zeilen mit leerem Mandanten fuer *jeden* Mandanten aus. Das sind genau die von der Administration gepflegten plattformweiten Eintraege. Vermerke es als benannten Blocker fuer die spaetere Etappe. Gib am Ende eine Tabelle mit den Mengen je Bereich und Klasse aus, damit die folgenden Etappen eine Groessenordnung haben. Der aktuelle Stand je Bereich ist im Abschnitt `measured_baseline` dieses Plans nachgemessen hinterlegt — die Summen im Dokument muessen dazu passen. Lege `rls-access-inventory.spec.ts` an. Die Spec ermittelt die Fundstellen erneut aus dem Quelltext und vergleicht sie gegen die im Dokument gefuehrten Eintraege. Sie scheitert, sobald eine Fundstelle ohne Eintrag existiert oder ein Eintrag ohne Fundstelle. Waehle die Vergleichsschluessel so, dass sie das Verschieben einer Zeile ueberleben — Datei und Modellname tragen, eine Zeilennummer nicht. Die Spec ist der Grund, warum dieses Dokument die naechsten beiden Etappen ueberlebt, statt nach dem ersten Umbau falsch zu werden. Verlinke das neue Dokument aus `docs/mandantentrennung-datenbankrolle.md` und korrigiere dort die inzwischen ueberholte Zahl 182 auf den nachgemessenen Stand. Ergaenze in `WINDOWS.md` die Eintraege #18 und #19 um einen Hinweis auf diesen Plan und auf den in Aufgabe 1 gemessenen `forTenant()`-Defekt, der beim Anlegen der Eintraege noch nicht bekannt war. npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts Die Bestandsaufnahme-Spec ist gruen, das Dokument fuehrt jede Fundstelle mit Klasse und — bei "bewusst uebergreifend" — mit ausgeschriebenem Grund, und die Mengentabelle je Bereich stimmt mit der nachgemessenen Verteilung ueberein. Aufgabe 4: Anmeldung lokal gegenpruefen Aufgabe 2 hat den Anmeldeweg umgebaut. Er laeuft weiterhin unter der bisherigen Rolle, aber er laeuft ueber neuen Code — deshalb ist eine echte Anmeldung im Browser noetig, bevor diese Etappe als fertig gilt. Ein gruener Testlauf belegt das nicht: die Anmeldung wurde in dieser Sitzung nie mit einem echten Browser gegen den neuen Code gesehen. Beachte die Lehre aus STATE.md: ein laufender Container mit altem Abbild taeuscht Fertigstellung vor. Baue die Dienste lokal neu, bevor du pruefst. Und beachte den Browser-Fallstrick aus dem Gedaechtnis: nicht per `fetch` aus der Seite heraus messen, sondern die Anmeldung wirklich durchklicken. Der Server 192.168.13.12 wird dabei nicht angefasst. 1. Lokale Dienste mit dem neuen Stand neu bauen und starten. 2. Auf der Anmeldeseite mit einem lokalen Benutzer anmelden — die Anmeldung gelingt und das Portal laedt. 3. Abmelden und mit falschem Kennwort erneut versuchen — die Anmeldung wird abgelehnt, ohne zu verraten, welches Feld falsch war. 4. Die Kennwort-vergessen-Seite aufrufen und eine Anfrage abschicken — die Seite antwortet wie bisher, ohne Fehler im Protokoll des API-Containers. Der Benutzer bestaetigt, dass Anmeldung, Fehlversuch und Kennwort-Anfrage sich unveraendert verhalten. Bei Abweichung wird diese Etappe nicht abgeschlossen, sondern Aufgabe 2 nachgebessert. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser -> API (Anmeldeformular) | Benutzername, E-Mail und Rueckstell-Token sind unvertraute Eingaben und erreichen die neuen Datenbankfunktionen direkt. | | API -> PostgreSQL (Rolle `tessera_app`) | Die kuenftige Anwendungsrolle ist der eigentliche Sicherheitsanker. Alles, was sie darf, kann ein kompromittierter Prozess auch. | | Mandant A -> Mandant B (innerhalb einer Datenbank) | Die Grenze ist heute rein anwendungsseitig; dieser Plan bereitet ihre Durchsetzung in der Datenbank vor. | | SECURITY-DEFINER-Funktion -> Tabelleneigentuemer | Innerhalb dieser Funktionen gelten die Rechte des Eigentuemers, nicht die des Aufrufers. Das ist eine bewusst geoeffnete, eng zu haltende Tuer. | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-EOR-01 | Elevation of Privilege | `auth_lookup_*`-Funktionen (Aufgabe 2) | critical | mitigate | Suchpfad fest auf `public, pg_temp` angeheftet, damit kein untergeschobenes Schema den Tabellenbezug umlenkt; alle Rechte von PUBLIC entzogen, Ausfuehrungsrecht ausschliesslich an `tessera_app`; Funktionen sind `STABLE` und schreiben nicht. Jede Eigenschaft ist in `auth-lookup-functions.spec.ts` zugesichert. | | T-EOR-02 | Information Disclosure | `auth_lookup_user_by_username` / `_by_email` | high | mitigate | Fester Spaltensatz statt Sternchen, Gleichheitsbedingung statt Mustervergleich, `LIMIT 1`. Ein Aufrufer kann einen einzelnen Namen erraten, aber die Benutzertabelle nicht auslesen. Im Wegwerf-Nachweis wird ausdruecklich gemessen, dass ein gewoehnlicher SELECT auf "User" unter derselben Rolle null Zeilen liefert. | | T-EOR-03 | Information Disclosure | `forTenant()` fuehrt die Abfrage auf einer fremden Verbindung aus | critical | mitigate | Gemessen am 2026-09-09: `set_config` auf Backend 254999, Abfrage auf Backend 255000, Kontext dort NULL. Aufgabe 1 bindet beides an eine Verbindung; `rls-scratch-check.mjs` misst Verbindungsgleichheit und Fremdmandanten-Sichtbarkeit dauerhaft nach. | | T-EOR-04 | Tampering | `auth_lookup_reset_token` als Weg zur Kennwortuebernahme | high | mitigate | Nur Gleichheitsvergleich gegen ein nicht erratbares `randomUUID`-Token, eine Zeile, lesend. Alle Pruefungen auf Ablauf und Einmaligkeit sowie beide Schreibzugriffe bleiben in der Anwendung und laufen nach Aufgabe 2 mandantengebunden. | | T-EOR-05 | Repudiation | Klassifikationsdokument driftet vom Quelltext ab | medium | mitigate | `rls-access-inventory.spec.ts` ermittelt die Fundstellen bei jedem Testlauf neu und scheitert bei jeder Abweichung in beide Richtungen. Ohne diese Spec waere das Dokument nach dem ersten Umbau der Etappe 2 falsch, wuerde aber weiter als Landkarte gelesen. | | T-EOR-06 | Denial of Service | Plattformweite Zeilen mit leerem `tenantId` (WINDOWS #19) | high | accept | Ausserhalb dieser Etappe. Wird in Aufgabe 3 als benannter Blocker dokumentiert und in Etappe 3 geloest. Heute ohne Wirkung, weil der Schalter aus bleibt — die Annahme ist ausdruecklich an "Schalter bleibt aus" gebunden. | | T-EOR-07 | Denial of Service | Wegwerf-Werkzeug trifft die echte Datenbank | high | mitigate | Der Name der Wegwerf-Datenbank ist im Werkzeug fest verdrahtet und nicht ueber eine Umgebungsvariable steuerbar; das Werkzeug legt sie an und raeumt sie ab und beruehrt `tessera` nicht. Ohne `TESSERA_SCRATCH_ADMIN_URL` bricht es mit Anleitung ab, statt eine Vorgabe zu raten. | | T-EOR-SC | Tampering | Paketinstallationen | — | n/a | Dieser Plan installiert kein npm-, pip- oder cargo-Paket. Alle Bausteine stammen aus dem vorhandenen Bestand. Kein Legitimitaets-Halt noetig. | Diese Etappe gilt als erfuellt, wenn folgendes gleichzeitig zutrifft: 1. `npm --prefix apps/api run test` laeuft vollstaendig gruen — die bestehende Testsuite ist durch den Umbau von `forTenant()` und `auth.service.ts` nicht beschaedigt worden. 2. `npm --prefix apps/api run type-check` ist sauber. 3. `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen bestanden, inklusive der beiden Anmelde-Nachweise aus Aufgabe 2. 4. `docs/mandantentrennung-zugriffsklassifikation.md` existiert und `rls-access-inventory.spec.ts` bestaetigt seine Vollstaendigkeit. 5. `git diff` zeigt keine Aenderung an `DATABASE_URL` in `docker-compose.yml`, `docker-compose.prod.yml` oder einer `.env`. Der Schalter bleibt aus. 6. Aufgabe 4 ist vom Benutzer bestaetigt. **Wie die Rot-Vorbedingung festgestellt wurde.** Am 2026-09-09, vor jeder Aenderung: - `prisma-tenant.extension.spec.ts`, `auth-lookup-functions.spec.ts`, `rls-access-inventory.spec.ts` und `apps/api/scripts/rls-scratch-check.mjs` existieren nicht — `vitest run` bricht bei einem Dateifilter ohne Treffer mit Rueckgabewert ungleich null ab. - Das Verhalten von Aufgabe 1 wurde gegen die laufende lokale Datenbank gemessen und ist rot: gleiche Verbindung `false`, Mandantenkontext der eigentlichen Abfrage `null`. Eine Zusicherung darauf scheitert heute. - `auth.service.ts:39` ruft heute `this.prisma.user.findUnique` auf; eine Zusicherung auf den Aufruf der Datenbankfunktion scheitert damit ebenfalls. - Das Verzeichnis `apps/api/prisma/migrations/20260909160000_auth_lookup_functions` existiert nicht, `docs/mandantentrennung-zugriffsklassifikation.md` existiert nicht. - `forTenant()` bindet nachweislich: gleiche Backend-Kennung, gesetzter Kontext, null Fremdzeilen. - Unter einer Rolle ohne BYPASSRLS ist die Anmeldung moeglich und die Benutzertabelle trotzdem nicht auslesbar — beides in derselben Messung belegt. - Alle 232 Fundstellen sind eingestuft, die "bewusst uebergreifend"-Faelle begruendet, die Mengen je Bereich beziffert. - Die Klassifikation ist maschinell gegen den Quelltext abgesichert und ueberlebt die folgenden Etappen. - `DATABASE_URL` ist unveraendert; der Server wurde nicht angefasst. ## Die folgenden Etappen Diese Etappe hat das Fundament gelegt und die Landkarte gezeichnet. Der eigentliche Umbau steht noch aus. Erwartete Zuschnitte und Groessen, zu praezisieren anhand der in Aufgabe 3 ermittelten Mengentabelle: **Etappe 2 — Umbau der mandantengebundenen Zugriffe.** Der Hauptteil. Nach heutiger Zaehlung sind 232 Fundstellen einzustufen; der Grossteil duerfte in diese Klasse fallen. Sinnvolle Reihenfolge ist nach Bereich und Groesse: `tenders` (62), `groups` (37), `ldap` (21), `dkv` (21), `user` (17), `module-registry` (17), `dashboard` (13), `calendar` (12), `favorites` (7), `settings` (4). Der Bereich `auth` (13) ist durch diese Etappe bereits erledigt, `tenant` (8) faellt weitgehend unter Etappe 3. Erwartet: fuenf bis acht Plaene, je Bereich einer, jeweils mit einem Nachweis, dass ein Fremdmandant nichts mehr sieht. Dabei ist zu entscheiden, ob die Controller kuenftig ueber das heute gesetzte, aber nirgends gelesene `req.tenantPrisma` gehen. **Etappe 3 — Benannter Systemkontext fuer die uebergreifenden Zugriffe.** Die Stellen, die rechtmaessig ueber Mandanten hinweg lesen, brauchen keinen Mandantenkontext, aber eine sichtbare Kennzeichnung — heute sind sie von einem vergessenen Filter nicht zu unterscheiden. Betrifft die Hintergrunddienste mit ihrem Fan-out ueber alle Mandanten, die Mandantenverwaltung, den Modulkatalog, die plattformweiten Ausschreibungsdaten nach D-03 und die Erstanlage des Administrators. Hier gehoert auch WINDOWS #19 hinein: die beiden Regeln fuer `SearchProvider` und `TenderRssFeedSource` muessen plattformweite Zeilen beim Lesen ausdruecklich einschliessen, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Erwartet: ein bis zwei Plaene. **Etappe 4 — Scharfschalten.** Kennwort fuer `tessera_app` vergeben, `TESSERA_MIGRATE_DATABASE_URL` und `DATABASE_URL` umstellen, `rls-preflight.mjs` gruen, dann neu starten. Der Plan muss die Vorher-Pruefung und einen dokumentierten Rueckweg enthalten — beides steht bereits in `docs/mandantentrennung-datenbankrolle.md`, Abschnitte 4 bis 6, und ist dort nur noch um den in Etappe 1 gemessenen `forTenant()`-Befund zu ergaenzen. Erwartet: ein Plan mit einem blockierenden Halt vor der Umstellung und einem zweiten nach der ersten erfolgreichen Anmeldung unter der neuen Rolle. Erstelle `.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-SUMMARY.md`, wenn alle vier Aufgaben abgeschlossen sind. Nimm darin ausdruecklich auf: - die tatsaechlich gemessenen Ergebnisse von `rls-scratch-check.mjs` (nicht "bestanden", sondern die Zahlen), - die Mengentabelle je Bereich und Klasse aus Aufgabe 3 als Arbeitsvorrat fuer Etappe 2, - jeden in Aufgabe 1 gefundenen Vorbehalt zur Array-Form von `$transaction`.