# Mandantentrennung — Etappe 3: Auftrag fuer die naechste Sitzung Geschrieben am 2026-09-11 am Ende der Sitzung, die Etappe 2 abgeschlossen hat. Zweck: Eine frische Sitzung soll Etappe 3 ohne Rueckfrage und ohne Neuermittlung der Grundlagen beginnen koennen. Alles hier ist gemessen, nicht erinnert. ## Randbedingungen vom User (2026-09-11) - **Dienstag, 2026-09-15, geht die erste voll funktionsfaehige Version live.** Live-Gehen braucht den Schalter NICHT — heute laeuft alpha mit dem BYPASSRLS-Stand und einem Mandanten, und das ist fuer den Betrieb unerheblich. Etappe 3 darf das Live-Gehen nicht blockieren. - **Nicht nachfragen.** Alles machen, was ohne den User geht. Was nicht ohne ihn geht, am Ende benennen. - **Beim Scharfschalten (Etappe 4) anhalten und fragen.** Das ist die einzige Ausnahme, und sie steht seit dem 2026-09-09. - **Nach jedem abgeschlossenen Durchlauf pushen** (`git push` genuegt, die Push-URL zeigt auf localhost:3002). ## Stand beim Einstieg - Etappe 2 abgeschlossen, alle zwoelf Bereiche gebunden, jeder einzeln verifiziert. Endstand 994 Tests / 62 Dateien, 137 Live-Pruefungen, Klassifikation 65 Paare / 68 ungebunden / 178 gebunden. - WINDOWS #27 (Relations-Blindstelle der Bestandsaufnahme) ist als Quick-Task `260911-mkj` in Arbeit oder abgeschlossen — `git log` und `.planning/quick/260911-mkj-*/` pruefen. Nach dessen Abschluss: 72 Paare. - Der Schalter ist AUS. `DATABASE_URL` zeigt auf Rolle `tessera`. ## Die zwei Produktentscheidungen (User, 2026-09-10) 1. **Anmeldenamen pro Mandant eindeutig**, nicht plattformweit. `m.schmidt` darf es bei Firma A und Firma B geben. 2. **Kollegen derselben Firma strikt getrennt.** Jeder sieht nur seine eigenen gespeicherten Suchen, Favoriten, Dashboard-Anordnung. ## Etappe 3 in drei Teilen — empfohlene Reihenfolge ### 3b zuerst: Benutzerdimension in den Regeln **Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser Aufgabe):** Migration `20260911120000_rls_user_dimension_personal_tables` bringt `current_user_id()` und die Benutzerdimension in die Regeln der zehn persoenlichen Tabellen; `forTenant(prisma, tenantId, userId?)` bekommt den optionalen dritten Parameter, 34 Nutzer-CRUD-Aufrufstellen in acht Diensten reichen ihn durch. Gemessene Zahl der umgedrehten Loch-Pruefungen: SECHS, nicht drei wie unten noch angenommen. Endzahlen: Tests 1020/62 Dateien, Werkzeug `rls-scratch-check.mjs` 203/203 bestanden (Baseline vor diesem Lauf: 137). Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Der ursprüngliche Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage). Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als "Policy hat keine Benutzerdimension" festgehalten wurde. Gemessene Grundlagen: - `forTenant(prisma, tenantId)` in `apps/api/src/prisma/prisma-tenant.extension.ts:111` setzt genau EINE Sitzungsvariable: `set_config('app.current_tenant', ..., true)`. `current_tenant_id()` ist in `20260618112133_rls_policies` definiert. - Es gibt KEIN `app.current_user` und KEIN `current_user_id()` — nirgends in Migrationen oder Quelltext. - **14 Modelle tragen eine `userId`-Spalte:** CalendarSource, DashboardLayout, FavoriteLink, GroupMembership, ModuleGrant, PasswordResetToken, SearchProvider, TenderEmailConfig, TenderMatch, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance. NICHT alle davon sind "persoenliche Daten": GroupMembership und ModuleGrant sind Verwaltungsobjekte (ein Admin darf sie fuer andere sehen), PasswordResetToken ist ein Anmelde-Artefakt, TenderMatch wird vom Hintergrunddienst je Treffer geschrieben. Die Benutzerdimension gehoert auf die ZEHN echten Nutzerobjekte; welche das sind, ist je Modell zu entscheiden und am Ort zu begruenden — Vorgabe aus den Bereichs-Kritiken: CalendarSource, DashboardLayout, FavoriteLink, SearchProvider (persoenliche Zeilen), TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource (persoenliche Zeilen), TenderSavedSearch, TenderTriage, WidgetInstance. - Der Anwendungscode trennt heute bereits korrekt nach Benutzer (in jedem Bereich stichprobenartig belegt, Besitzpruefungen in ldap/dkv/dashboard/ calendar/favorites/auth als Tests festgenagelt). Die Datenbank tut es nicht. Das zweite Netz fehlt. Bauform, an der es sich zu orientieren gilt: - Zweite Sitzungsvariable `app.current_user`, Funktion `current_user_id()` nach dem Muster von `current_tenant_id()`. - `forTenant()` bekommt einen optionalen dritten Parameter `userId` (oder ein Schwesterhelfer `forTenantAndUser()` — Entscheidung im Plan, mit Begruendung; Praezedenz fuer "Helfer erweitern statt zweiten bauen" ist `withTenantTransaction()`). Hintergrunddienste und Verwaltungswege rufen weiter ohne userId; nur die Nutzer-CRUD-Wege setzen ihn. - Regeln der zehn Tabellen: Lesen `tenantId = current_tenant_id() AND (current_user_id() IS NULL OR userId = current_user_id())` — damit ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) weiter alles des Mandanten sieht. Schreiben analog. Das IS-NULL-Muster ist die Form aus der #19-Loesung (260910-jab), dort fuer plattformweite Zeilen. - Messen, nicht annehmen: `rls-scratch-check.mjs` bekommt einen Abschnitt je umgestellter Tabelle, ueber den GENERIERTEN Client, mit schemagleicher Wegwerf-Tabelle (Spaltenvergleich zur Laufzeit — `createdAt`/`updatedAt`-Falle aus 260910-krx). - Die drei loch-behauptenden Pruefungen aus `tenders` und `dashboard` ("Policies haben keine Benutzerdimension", z. B. `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar`) UMDREHEN, nicht loeschen — Muster aus 260910-jab. ### 3a danach: Anmeldenamen pro Mandant Warum danach: aendert das Schema UND den Anmeldeweg, ist der riskanteste Teil, und braucht 3b nicht. Gemessene Grundlagen: - `User.username String @unique` und `User.email String? @unique` (`schema.prisma:31-32`) — plattformweit. - Die drei SECURITY-DEFINER-Funktionen in `20260909160000_auth_lookup_functions`: `auth_lookup_user_by_username(p_username)`, `auth_lookup_user_by_email(p_email)`, `auth_lookup_reset_token(p_token)`. Jede sucht ueber EINE Gleichheitsbedingung mit `LIMIT 1`. Der Mandant kommt erst AUS der gefundenen Zeile. - Aufrufer: `auth.service.ts:95,110,218` und `user.service.ts:33`. - Bewusst ungebundene Stellen, die genau an dieser plattformweiten Eindeutigkeit haengen und mit 3a fallen: `ldap.service.ts` `resolveEmailForWrite` (260909-ipc, Befund A), die P2002-Kollisionskette in `tenders`/`user` (unsichtbare Zeile -> falsches "frei" -> harter Fehler), `user.service.ts` `findByUsername` (null Aufrufer, 260910-das). Bauform: - Schema: `@@unique([tenantId, username])`, `@@unique([tenantId, email])` statt `@unique`. Migration mit Datenpruefung davor (heute ein Mandant, also keine Kollision moeglich — trotzdem messen). - Der Anmeldeweg muss den Mandanten kennen, BEVOR er die Zeile sucht. Zwei uebliche Wege: (i) eigene Adresse je Mandant (Subdomain `firma-a.tessera.ctl.de` -> Mandant aus dem Host), (ii) Mandantenwahl beim Login. **Das ist eine Produktfrage, die der User NICHT beantwortet hat.** Empfehlung fuer die Planung: (i), weil Tessera hinter Nginx Proxy Manager laeuft und Subdomains dort trivial sind, und weil (ii) den Anmeldenamen als Geheimnis schwaecht. Wenn die Planung das anders sieht, ist es einer der Punkte, die am Ende dem User genannt werden. - Die drei Funktionen bekommen eine zweite Gleichheitsbedingung (`p_tenant_id`) — ENGER, nicht weiter. Die Kopfkommentare in `docs/mandantentrennung-datenbankrolle.md` nachziehen. - Bis der Mandant vor der Suche bekannt ist, laesst sich 3a NICHT scharfschalten. Deshalb ist 3a fuer Dienstag NICHT noetig: mit einem Mandanten ist plattformweit = pro Mandant. ### 3c zuletzt: Systemkontext fuer die Hintergrunddienste **Erledigt (260914-eym, 3d64567/6e2a641 plus der Dokumentationscommit dieser Aufgabe):** Migration `20260914120000_rls_system_context_read` bringt `is_system_context()` (COALESCE, STABLE) und je eine zusaetzliche, NUR lesende Regel `system_read_policy ... FOR SELECT` auf FUENF Tabellen — DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch (nicht sechs: SmtpConfig traegt keine, weil der Mail-Startpfad ENTFERNT und nicht umgestellt wurde). Helfer `forSystem(prisma)` als Schwesterhelfer von `forTenant()` (setzt `app.system_context = 'true'` und die beiden anderen Variablen ausdruecklich leer; `forTenant()`/`withTenantTransaction()` setzen umgekehrt `app.system_context = ''`). Die sechs Faelle: DKV-Planer je Mandant (Auftrag `dkv-inbox-poll:`, WINDOWS #21 geschlossen); Mail-Transport je Versand nach Mandant des Empfaengers, Startpfad und Mailer-Fabrik geloescht (WINDOWS #30 geschlossen); ldap mit ZWEI Systemkontext-Lesern (`getAllActiveConfigs`, Nachverschluesselung — die Schreibzeile je Altzeile gebunden); digest und matching ueber den Systemkontext, Schleifen gebunden; admin-seed nur dokumentiert (liest ausserhalb der Schleife nur `Tenant`, keine Regel, Datei unveraendert). Detektor mit fuenfter Erkennungsform und Erlaubnisliste (4 Dateien, 5 Aufrufe, exakt). Endzahlen: Tests 1054/64 Dateien, Typpruefung sauber, Werkzeug `rls-scratch-check.mjs` 253/253 bestanden (Baseline vor diesem Lauf: 203). Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Systemkontext (Etappe 3c, 260914-eym)" mit (y1)-(y5). Der urspruengliche Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage). Sechs Faelle, alle im Abschnitt "Der Hintergrunddienst als Falle" der Klassifikation und in den Bereichs-Kritiken: - `dkv-scheduler` / `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — heute bereits falsch (willkuerlicher Mandant). - `mail.module` / `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30) — dieselbe Form. - `ldap-config.service` `getAllActiveConfigs()` / `onApplicationBootstrap` — heute korrekt, verstummt spaeter. - `tender-digest.scheduler`, `tender-matching.service` — uebergreifende Haelften an Etappe 3 uebergeben (260909-laa). - `admin-seed.service` `ensureDefaultGroupsForAllTenants` — Schleife ueber alle Mandanten, Rumpf bereits gebunden. Bauform: Ein benannter Systemkontext (z. B. `forSystem(prisma)`), der eine DRITTE Sitzungsvariable `app.system_context = 'true'` setzt, und Regeln, die diesen Kontext fuer LESENDE Zugriffe zulassen (`OR current_setting( 'app.system_context', true) = 'true'`). Schreibzugriffe innerhalb der Schleife bleiben je Mandant gebunden. Der DKV- und der SMTP-Startpfad werden dann zu "einmal-abfragen-viele-bedienen" — das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung und der einzige echte Funktionsausbau in Etappe 3. ## Was NICHT ohne den User geht - **3a, Weg (i) vs. (ii):** wie der Mandant beim Login bestimmt wird. - **Etappe 4:** Scharfschalten. Ausdruecklich. ## Werkzeuge und Fallen (aus Etappe 2, jede mindestens einmal erlebt) - `git status` ist die Wahrheit, nicht der Agentenbericht. Zwei Agenten brachen am Sitzungslimit NACH getaner Arbeit ab. - Ein `grep -c` ist eine Behauptung. Vier Kopfzahlen schrumpften beim Hineinsehen. - Eine nicht committete Messung ist kein Beleg (zweimal passiert). - Zusammenfassungen behaupten N, wo N-1 geliefert ist (zweimal passiert). - Handgepflegte Dokumentstellen werden uebersprungen — Gates ABLEITEN. - Roh-SQL ist nicht der generierte Client (Wegwerf-Tabelle ohne `createdAt`/`updatedAt`). - Ein Plan-Pruefer, der "plausibel" sagt, hat nicht geprueft. - `git add` mit mehreren Pfaden, einer davon geloescht, scheitert still. - Der Datenbank-Container hat keinen Host-Port: IP per `docker inspect` frisch ermitteln, `tessera:tessera_dev`. Prisma-Binary aus `apps/api/node_modules/.bin/prisma`, NICHT `npx prisma` (zieht Prisma 8). - Kein `mailhog` lokal — `ENOTFOUND mailhog` ist Umgebung, kein Defekt. - Backticks in Heredoc-Python werden von der Shell ausgewertet — Skripte in eine Datei schreiben, dann ausfuehren. - Eine Mock-Fabrik ohne den neuen Export wirft erst beim ZUGRIFF (vitest-Proxy) — jede Spec, deren Pruefling `forSystem` importiert, braucht den Export im Mock (260914-eym: sechs Spec-Dateien). - Ein Gate mit `grep -rh ... | grep -v spec` filtert KEINE Spec-Dateien (`-h` laesst den Dateinamen weg, `spec` steht nicht im Zeilentext) — Proben in einer Spec zaehlen mit; Empfaengernamen in Proben deshalb anders waehlen als im Produktivcode (260914-eym, `sysPrisma`). ## Einstieg `/gsd-resume-work`, dann `/gsd-quick` fuer 3b mit `--validate`. Planer, Plan-Pruefer, Executor, Verifizierer — die Kette vollstaendig, jede Lieferung wurde in Etappe 2 vom jeweils naechsten Schritt gefangen, nie vom eigenen.