From e7778027494c7c8eda43cb8913be82a80aca8449 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 10:43:24 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU --- .../260909-eor-PLAN.md | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 .planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-PLAN.md 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. + + + +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`. +