diff --git a/.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-PLAN.md b/.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-PLAN.md
new file mode 100644
index 0000000..858bac5
--- /dev/null
+++ b/.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-PLAN.md
@@ -0,0 +1,507 @@
+---
+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.
+
+
+