Files
schalli e777802749 docs(quick-260909-eor): Etappe 1 der Mandantentrennung geplant
Drei Punkte werden in dieser Etappe abschliessend geklaert: der gemessene
forTenant()-Defekt, die schmale Ausnahme fuer den Anmeldeweg und die
maschinell abgesicherte Klassifikation aller 232 Datenbankzugriffe.

Der Befund zu forTenant() wurde vor der Planung empirisch belegt: set_config
laeuft auf Backend 254999, die eigentliche Abfrage auf 255000, der
Mandantenkontext ist dort NULL. Alle heutigen forTenant()-Aufrufe sind damit
stillschweigend ungebunden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:43:24 +02:00

35 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-260909-eor 01 execute 1
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
false
WINDOWS-18
WINDOWS-19
tokens raw_tokens tasks confidence
95000 95000 4 low
truths artifacts key_links
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.
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
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

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

<measured_baseline> 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. </measured_baseline>

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.

<threat_model>

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.
</threat_model>
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.

<success_criteria>

  • 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. </success_criteria>

<next_stages>

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. </next_stages>

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