Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6236b302f4 | |||
| c8de72e762 | |||
| 17dca0dfad | |||
| 11f5731029 | |||
| 652e762ad4 | |||
| 6426b18630 |
@@ -387,6 +387,7 @@ None yet.
|
|||||||
| 260910-jab | **Die drei zu kurz greifenden Datenbankregeln geschlossen** — auf ausdrueckliche Anweisung des Users VORGEZOGEN, entgegen der geplanten Reihenfolge (urspruenglich nach Etappe 2, damit jeder Bereich gegen einen stabilen Regelstand misst; der User entschied anders, weil offene Loecher vergessen werden). Erster Durchlauf dieser Serie, der die DATENBANK aendert statt nur Anwendungscode — neue Migration `20260910120000_rls_widen_membership_grant_and_platform_read`. **T-JTS-02:** `GroupMembership` prueft jetzt BEIDE Seiten (Gruppe UND Benutzer gehoeren zum Mandanten) statt nur die Gruppenseite. **T-JTS-03:** `ModuleGrant` prueft zusaetzlich, dass die referenzierte Gruppe bzw. der referenzierte Benutzer zum selben Mandanten gehoert; `assertTargetBelongsToTenant` bleibt als zweite Verteidigungslinie bestehen. **WINDOWS #19:** `TenderRssFeedSource` bekommt VIER nach Befehl getrennte Regeln — Lesen schliesst plattformweite Zeilen ein, Einfuegen/Aendern/Loeschen verlangen weiter einen Mandanten (eine einzige lockere Regel haette jedem Mandanten erlaubt, gemeinsame Quellen zu aendern und zu loeschen, weil `USING` auch UPDATE und DELETE regelt). **Halbe Praemisse von #19 widerlegt:** bei `SearchProvider` gibt es gar keinen Codeweg, der eine mandantenlose Zeile erzeugt — Schreibweg verlangt den Mandanten, Vorgaben sind Konstanten (05-02); als widerlegte Annahme geschlossen, nicht als geloestes Problem, strenge Regel bleibt. **DREI Pruefungen schrieben die Loecher als erwartetes Verhalten fest** (meine eigene Suche fand nur zwei, der Planer die dritte) — alle drei UMGEDREHT statt geloescht, mit Verweis auf den urspruenglichen Befund: der ausfuehrbare Beleg, dass das Loch existierte, bleibt mit umgekehrtem Vorzeichen erhalten. **Die Reparatur erzeugte an einer Stelle selbst den Fehler, gegen den sie antritt:** `listForUser` haette nach der Regelaenderung die plattformweiten, aber nicht die persoenlichen Quellen geliefert — aus einer leeren Liste, die schreit, waere eine kurze geworden, die luegt; deshalb mitgebunden. **Messfalle abgefangen:** `extractPolicySql()` las nur die alten Migrationsverzeichnisse und haette nach der neuen Migration still die ABGELOESTE Regel weitergemessen. **Werkzeugfalle abgefangen:** der uebliche Aufrufweg haette beim Einspielen eine neue Prisma-Hauptversion nachgeladen; stattdessen die im Projekt festgelegte Fassung benutzt. Neuer offener Ledger-Eintrag #24: plattformweite Zeilen lassen sich unter der Anwendungsrolle weder anlegen noch entfernen — in alter wie neuer Regel. **Verifiziert 11/11 mit vier ZERSTOERENDEN Gegenproben** (jede Regel und die neue Bindung einzeln zurueckgedreht, jedes Mal schlug genau die zustaendige Pruefung fehl, danach byte-identisch wiederhergestellt). 839/839 Tests, Typpruefung sauber, 74/74 Live-Pruefungen; Regeltexte vom Orchestrator in der LAUFENDEN Datenbank gegengelesen | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
| 260910-jab | **Die drei zu kurz greifenden Datenbankregeln geschlossen** — auf ausdrueckliche Anweisung des Users VORGEZOGEN, entgegen der geplanten Reihenfolge (urspruenglich nach Etappe 2, damit jeder Bereich gegen einen stabilen Regelstand misst; der User entschied anders, weil offene Loecher vergessen werden). Erster Durchlauf dieser Serie, der die DATENBANK aendert statt nur Anwendungscode — neue Migration `20260910120000_rls_widen_membership_grant_and_platform_read`. **T-JTS-02:** `GroupMembership` prueft jetzt BEIDE Seiten (Gruppe UND Benutzer gehoeren zum Mandanten) statt nur die Gruppenseite. **T-JTS-03:** `ModuleGrant` prueft zusaetzlich, dass die referenzierte Gruppe bzw. der referenzierte Benutzer zum selben Mandanten gehoert; `assertTargetBelongsToTenant` bleibt als zweite Verteidigungslinie bestehen. **WINDOWS #19:** `TenderRssFeedSource` bekommt VIER nach Befehl getrennte Regeln — Lesen schliesst plattformweite Zeilen ein, Einfuegen/Aendern/Loeschen verlangen weiter einen Mandanten (eine einzige lockere Regel haette jedem Mandanten erlaubt, gemeinsame Quellen zu aendern und zu loeschen, weil `USING` auch UPDATE und DELETE regelt). **Halbe Praemisse von #19 widerlegt:** bei `SearchProvider` gibt es gar keinen Codeweg, der eine mandantenlose Zeile erzeugt — Schreibweg verlangt den Mandanten, Vorgaben sind Konstanten (05-02); als widerlegte Annahme geschlossen, nicht als geloestes Problem, strenge Regel bleibt. **DREI Pruefungen schrieben die Loecher als erwartetes Verhalten fest** (meine eigene Suche fand nur zwei, der Planer die dritte) — alle drei UMGEDREHT statt geloescht, mit Verweis auf den urspruenglichen Befund: der ausfuehrbare Beleg, dass das Loch existierte, bleibt mit umgekehrtem Vorzeichen erhalten. **Die Reparatur erzeugte an einer Stelle selbst den Fehler, gegen den sie antritt:** `listForUser` haette nach der Regelaenderung die plattformweiten, aber nicht die persoenlichen Quellen geliefert — aus einer leeren Liste, die schreit, waere eine kurze geworden, die luegt; deshalb mitgebunden. **Messfalle abgefangen:** `extractPolicySql()` las nur die alten Migrationsverzeichnisse und haette nach der neuen Migration still die ABGELOESTE Regel weitergemessen. **Werkzeugfalle abgefangen:** der uebliche Aufrufweg haette beim Einspielen eine neue Prisma-Hauptversion nachgeladen; stattdessen die im Projekt festgelegte Fassung benutzt. Neuer offener Ledger-Eintrag #24: plattformweite Zeilen lassen sich unter der Anwendungsrolle weder anlegen noch entfernen — in alter wie neuer Regel. **Verifiziert 11/11 mit vier ZERSTOERENDEN Gegenproben** (jede Regel und die neue Bindung einzeln zurueckgedreht, jedes Mal schlug genau die zustaendige Pruefung fehl, danach byte-identisch wiederhergestellt). 839/839 Tests, Typpruefung sauber, 74/74 Live-Pruefungen; Regeltexte vom Orchestrator in der LAUFENDEN Datenbank gegengelesen | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
||||||
| 260910-krx | Mandantentrennung Etappe 2, Bereich dashboard — 12 von 13 Zugriffen gebunden, der Modulkatalog bleibt bewusst ungebunden (Messung und Bedingung getrennt: heute ohne Zeilenschutz, daher wirkungslos, katastrophal erst wenn Etappe 3 eine Regel setzt). **Erster Bereich, in dem die Besitzpruefungen von Anfang an richtig waren:** dieselbe Bauform, die in ldap und dkv je eine Luecke riss (nachschlagen, dann loeschen), vergleicht hier dazwischen gegen die angemeldete Person — nichts zu reparieren, nur zu bestaetigen und durch Tests festzunageln. **Beweisvernichtungs-Schleife belegt, nicht vermutet (WINDOWS #25, offen):** nach dem Scharfschalten liefert `getLayout` bei unsichtbarer Zeile die Vorgabe, die Oberflaeche uebernimmt sie ohne Fehlerzustand, und das Verlassen des Bearbeitungsmodus schreibt AUTOMATISCH zurueck — der Nutzer ueberschreibt seine urspruengliche Anordnung selbst, ohne es zu merken; dazu haeufen sich Widget-Dubletten, weil es keine Eindeutigkeit ueber (userId, widgetType) gibt. Gehoert in die Etappe-4-Vorabpruefung, nicht in diesen Umbau. Suchleiste: der Rueckfallzweig feuert nie leer, weil drei Vorgaben immer vorangestellt sind — die eigenen Suchmaschinen verschwinden schlicht. `DashboardLayout.userId` ist plattformweit eindeutig ohne Mandantenanteil (Familie WINDOWS #22). **Der Verifizierer fand eine Luecke der bekannten Art:** die Behauptung, ein gebundener Konfliktschreibvorgang werfe `PrismaClientUnknownRequestError` (nicht den P2002-Fall von tenders/user), stuetzte sich auf eine NICHT committete Ad-hoc-Messung — Pruefung 5 mass nur Roh-SQL, kein Test uebte den catch-Zweig. Nachgereicht (6e71206): Messung ueber den GENERIERTEN Client (Konstruktorname geprueft), dabei die Wegwerf-Tabelle korrigiert, der Roh-SQL nie aufgefallen war (createdAt/updatedAt fehlten, der echte Client scheiterte sofort mit P2022); zwei Tests fuer den catch-Zweig, durch Rueckbau falsifiziert. Klassifikation: fremde Datei `groups.service.ts` mit ungenauem Kopfkommentar bewusst NICHT angefasst, Ungenauigkeit in (w5) festgehalten. **Verifiziert 10/11, Luecke behoben** (860/860 Tests, Typpruefung sauber, 88/88 Live-Pruefungen; alle drei Falsifizierungsnachweise vom Pruefer eigenhaendig reproduziert) | 2026-09-11 | 6744918,e0ce594,67b5024,6e71206 | [260910-krx-mandantentrennung-etappe-2-bereich-dashb](./quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/) |
|
| 260910-krx | Mandantentrennung Etappe 2, Bereich dashboard — 12 von 13 Zugriffen gebunden, der Modulkatalog bleibt bewusst ungebunden (Messung und Bedingung getrennt: heute ohne Zeilenschutz, daher wirkungslos, katastrophal erst wenn Etappe 3 eine Regel setzt). **Erster Bereich, in dem die Besitzpruefungen von Anfang an richtig waren:** dieselbe Bauform, die in ldap und dkv je eine Luecke riss (nachschlagen, dann loeschen), vergleicht hier dazwischen gegen die angemeldete Person — nichts zu reparieren, nur zu bestaetigen und durch Tests festzunageln. **Beweisvernichtungs-Schleife belegt, nicht vermutet (WINDOWS #25, offen):** nach dem Scharfschalten liefert `getLayout` bei unsichtbarer Zeile die Vorgabe, die Oberflaeche uebernimmt sie ohne Fehlerzustand, und das Verlassen des Bearbeitungsmodus schreibt AUTOMATISCH zurueck — der Nutzer ueberschreibt seine urspruengliche Anordnung selbst, ohne es zu merken; dazu haeufen sich Widget-Dubletten, weil es keine Eindeutigkeit ueber (userId, widgetType) gibt. Gehoert in die Etappe-4-Vorabpruefung, nicht in diesen Umbau. Suchleiste: der Rueckfallzweig feuert nie leer, weil drei Vorgaben immer vorangestellt sind — die eigenen Suchmaschinen verschwinden schlicht. `DashboardLayout.userId` ist plattformweit eindeutig ohne Mandantenanteil (Familie WINDOWS #22). **Der Verifizierer fand eine Luecke der bekannten Art:** die Behauptung, ein gebundener Konfliktschreibvorgang werfe `PrismaClientUnknownRequestError` (nicht den P2002-Fall von tenders/user), stuetzte sich auf eine NICHT committete Ad-hoc-Messung — Pruefung 5 mass nur Roh-SQL, kein Test uebte den catch-Zweig. Nachgereicht (6e71206): Messung ueber den GENERIERTEN Client (Konstruktorname geprueft), dabei die Wegwerf-Tabelle korrigiert, der Roh-SQL nie aufgefallen war (createdAt/updatedAt fehlten, der echte Client scheiterte sofort mit P2022); zwei Tests fuer den catch-Zweig, durch Rueckbau falsifiziert. Klassifikation: fremde Datei `groups.service.ts` mit ungenauem Kopfkommentar bewusst NICHT angefasst, Ungenauigkeit in (w5) festgehalten. **Verifiziert 10/11, Luecke behoben** (860/860 Tests, Typpruefung sauber, 88/88 Live-Pruefungen; alle drei Falsifizierungsnachweise vom Pruefer eigenhaendig reproduziert) | 2026-09-11 | 6744918,e0ce594,67b5024,6e71206 | [260910-krx-mandantentrennung-etappe-2-bereich-dashb](./quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/) |
|
||||||
| 260911-cwh | Mandantentrennung Etappe 2, Bereich calendar — alle 12 Zugriffe gebunden, ein Klient je Methode, sechs Methoden. Bereich hatte KEINE Testdatei (dkv-Form); `calendar.service.spec.ts` neu mit 23 Tests. **Gespeicherte Zugangsdaten zu fremden Kalender-Servern** — ein Fremdzugriff waere hier der Schluessel zu einem fremden Exchange/CalDAV. **Der dkv-Passwortverlust-Fall existiert hier NICHT**, in beiden Haelften belegt: der Dienst schreibt `encryptedPassword` nur bei `dto.password !== undefined`, und `calendar-source-form.tsx` laesst ein leeres Feld WEG statt einen leeren Text zu schicken; als drei Tests festgenagelt, weil ein nicht festgenagelter Freispruch still aufhoeren kann zu gelten. **Zwischenspeicher-Schluessel `userId:from:to` ohne Mandantenanteil ist sicher:** `User.id` ist `@default(uuid())`, Kette Schema -> `auth.service.ts sub: user.id` -> `JwtStrategy.validate` -> `extractContext` Glied fuer Glied belegt; die Etappe-3-Entscheidung (Anmeldenamen pro Mandant) beruehrt `username`/`email`, nicht `id`. **Eigener Gefahrenfall — halb gebundene Aggregationsschleife:** `fetchAndCacheEvents` liest Quellen und schreibt den Synchronstatus auf Erfolgs- UND Fehlerpfad zurueck, innerhalb von `Promise.allSettled`; gebundene Lesung mit ungebundenem Rueckschreiben haette Ereignisse still fallen lassen — beide Rueckschreibungen gebunden und als Tests festgenagelt, eines davon vom Pruefer eigenhaendig zurueckgebaut (genau 1 von 23 rot, exakte Meldung). **Frontend macht aus lauten Fehlern stille:** `calendar-widget.tsx` und `calendar-settings-panel.tsx` fangen jeden Fehler in denselben leeren Zustand — ein 403 sieht aus wie ein leerer Kalender; NICHT angefasst, als WINDOWS #26 offen festgehalten. Besitzpruefungen in allen drei Pfaden echt (403, nicht 404). Keine Eindeutigkeitskette, daher keine Konfliktuebersetzung, die nichts uebersetzt. **Lehre aus dashboard angewandt:** 4 der 13 neuen Pruefungen laufen ueber den GENERIERTEN Client, mit Laufzeitvergleich der Wegwerf-Tabelle gegen `schema.prisma` (17 = 17 Spalten). **Verifiziert 12/12** (883/883 Tests, 57 Dateien, Typpruefung sauber, 101/101 Live-Pruefungen; Klassenverteilung 31/17/13/2 = 63 und Ledger-Zaehler vom Pruefer nachgerechnet). Ein Selbstwiderspruch in der Zusammenfassung ('keine Abweichung' vs. 'kein TDD-Zyklus') berichtigt | 2026-09-11 | bf5fc4d,77cb124,e0e163e | [260911-cwh-mandantentrennung-etappe-2-bereich-calen](./quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/) |
|
| 260911-cwh | Mandantentrennung Etappe 2, Bereich calendar — alle 12 Zugriffe gebunden, ein Klient je Methode, sechs Methoden. Bereich hatte KEINE Testdatei (dkv-Form); `calendar.service.spec.ts` neu mit 23 Tests. **Gespeicherte Zugangsdaten zu fremden Kalender-Servern** — ein Fremdzugriff waere hier der Schluessel zu einem fremden Exchange/CalDAV. **Der dkv-Passwortverlust-Fall existiert hier NICHT**, in beiden Haelften belegt: der Dienst schreibt `encryptedPassword` nur bei `dto.password !== undefined`, und `calendar-source-form.tsx` laesst ein leeres Feld WEG statt einen leeren Text zu schicken; als drei Tests festgenagelt, weil ein nicht festgenagelter Freispruch still aufhoeren kann zu gelten. **Zwischenspeicher-Schluessel `userId:from:to` ohne Mandantenanteil ist sicher:** `User.id` ist `@default(uuid())`, Kette Schema -> `auth.service.ts sub: user.id` -> `JwtStrategy.validate` -> `extractContext` Glied fuer Glied belegt; die Etappe-3-Entscheidung (Anmeldenamen pro Mandant) beruehrt `username`/`email`, nicht `id`. **Eigener Gefahrenfall — halb gebundene Aggregationsschleife:** `fetchAndCacheEvents` liest Quellen und schreibt den Synchronstatus auf Erfolgs- UND Fehlerpfad zurueck, innerhalb von `Promise.allSettled`; gebundene Lesung mit ungebundenem Rueckschreiben haette Ereignisse still fallen lassen — beide Rueckschreibungen gebunden und als Tests festgenagelt, eines davon vom Pruefer eigenhaendig zurueckgebaut (genau 1 von 23 rot, exakte Meldung). **Frontend macht aus lauten Fehlern stille:** `calendar-widget.tsx` und `calendar-settings-panel.tsx` fangen jeden Fehler in denselben leeren Zustand — ein 403 sieht aus wie ein leerer Kalender; NICHT angefasst, als WINDOWS #26 offen festgehalten. Besitzpruefungen in allen drei Pfaden echt (403, nicht 404). Keine Eindeutigkeitskette, daher keine Konfliktuebersetzung, die nichts uebersetzt. **Lehre aus dashboard angewandt:** 4 der 13 neuen Pruefungen laufen ueber den GENERIERTEN Client, mit Laufzeitvergleich der Wegwerf-Tabelle gegen `schema.prisma` (17 = 17 Spalten). **Verifiziert 12/12** (883/883 Tests, 57 Dateien, Typpruefung sauber, 101/101 Live-Pruefungen; Klassenverteilung 31/17/13/2 = 63 und Ledger-Zaehler vom Pruefer nachgerechnet). Ein Selbstwiderspruch in der Zusammenfassung ('keine Abweichung' vs. 'kein TDD-Zyklus') berichtigt | 2026-09-11 | bf5fc4d,77cb124,e0e163e | [260911-cwh-mandantentrennung-etappe-2-bereich-calen](./quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/) |
|
||||||
|
| 260911-e2s | Mandantentrennung Etappe 2, Bereich tenant — von anderer Art: alle 8 Zugriffe gehen auf die Mandantentabelle SELBST, die per Definition keinen Mandanten hat. **'Nichts zu binden' war trotzdem falsch, und der Grund ist der wichtigste Fund seit dem kaputten Helfer in Etappe 1:** drei der acht Zugriffe (`findAll`, `findOne`, `remove` im Controller) zaehlen ueber `include: { _count: { select: { users } } }` in die GESCHUETZTE Tabelle `User` hinein — Prisma 6.19 rendert das als `LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)`, das unter DEREN Regel laeuft. Nach dem Scharfschalten haette die Mandantenliste des Plattform-Admins fuer jeden Mandanten 0 Benutzer gezeigt, und der Loeschriegel T-02-09 waere vakuum geworden (der Fremdschluessel faengt es noch, aber als 500 statt 400). Behoben per Fan-out je Mandant ueber gebundenen Client, Muster aus `UserService.findAllForPlatformAdmin`. **Die Bestandsaufnahme ist fuer Relationszugriffe strukturell blind** — sie sieht nur `this.prisma.<Modell>`, nicht was ein `include:` in eine zweite Tabelle hineinrechnet. Alle 19 `include:`-Stellen und alle `_count`-Stellen einzeln beurteilt, vom Orchestrator UND vom Verifizierer unabhaengig gegengeprueft (der Plan-Pruefer hatte diesen Punkt als 'plausibel' durchgewinkt statt ihn zu pruefen): nur diese drei waren gefaehrlich. Der MECHANISMUS bleibt offen und ist als WINDOWS #27 festgehalten — der Planer wollte keinen Eintrag, weil die Instanz behoben ist; Orchestrator und Verifizierer sahen das anders, weil eine Luecke im Messwerkzeug, die nachweislich einen echten Defekt verborgen hat, genau dafuer ins Ledger gehoert. **Die seit Etappe 1 offene Architekturfrage ist entschieden:** `req.tenantPrisma` wurde bei jeder Anfrage gebaut und NIRGENDS gelesen; neun Bereiche haben die Konvention auf dienst-internes `forTenant()` festgelegt. Middleware geloescht (sie war nirgends registriert — der Auftrag irrte bei `app.module.ts:57`, dort ist der Guard verdrahtet), Guard ohne Prisma-Abhaengigkeit, setzt nur noch `req.tenantId` (22 Leser in 9 Dateien) und den `x-tenant-id`-Wechsel fuer SUPER_ADMIN (4 Frontend-Stellen) — beides erstmals getestet; Guard und Middleware hatten NIE Tests, 'ihre Tests' in Etappe 1 war eine Annahme. Totes Kabel, das wie eine Sicherung aussieht, ist schlimmer als keins. Ausnahmeliste in `rls-access-inventory.spec.ts` geleert und mit Wachhund versehen. Drei Kommentare berichtigt, die `TenantMiddleware`/`req.tenantPrisma` als lebendig beschrieben. Executor fing einen still fehlgeschlagenen `git add` (2 von 7 Dateien) selbst an `git status` und lieferte nach. **Verifiziert 10/10 mit vier eigenhaendigen Falsifizierungen** (Header-Wechsel zweimal gebrochen, Fan-out gebrochen, Wachhund ausgeloest — je exakt die benannten Tests rot; 911/911 Tests, 59 Dateien, Typpruefung sauber, 110/110 Live-Pruefungen; 64 Paare und Klassenverteilung 32/17/13/2 nachgerechnet) | 2026-09-11 | 652e762,11f5731,17dca0d,c8de72e | [260911-e2s-mandantentrennung-etappe-2-bereich-tenan](./quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/) |
|
||||||
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
|
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
|
||||||
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
open_count: 8
|
open_count: 9
|
||||||
waived_count: 1
|
waived_count: 1
|
||||||
fixed_count: 17
|
fixed_count: 17
|
||||||
total_count: 26
|
total_count: 27
|
||||||
last_updated: 2026-09-11T07:57:36.769Z
|
last_updated: 2026-09-11T09:08:00.435Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Broken Windows Ledger
|
# Broken Windows Ledger
|
||||||
@@ -41,6 +41,7 @@ last_updated: 2026-09-11T07:57:36.769Z
|
|||||||
| 24 | quick-260910-jab | deviation | apps/api/src/tenders/tender-rss-feed.service.ts | | Was das Schliessen von WINDOWS #19 NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite RSS-Quelle (TenderRssFeedSource, tenantId NULL) weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel (Einfuegen/Aendern/Entfernen) ausdruecklich einen Mandanten verlangt (tenant_insert_policy/tenant_update_policy/tenant_delete_policy, 20260910120000_rls_widen_membership_grant_and_platform_read). Betroffen sind zwei Pfade in TenderRssFeedSourceService: createPlatform() (setzt tenantId=NULL, ein gebundenes INSERT liefe in die WITH-CHECK-Klausel und wuerde abgewiesen) und remove() (deckt fuer Administratoren auch das Entfernen einer plattformweiten Zeile ab; ein gebundenes DELETE traefe sie nie). Beide bleiben deshalb bewusst ungebunden — das ist KEINE Folge dieser Reparatur, sondern bestand bereits vor 260910-jab identisch, weil die vom Ledger vorgegebene #19-Semantik Schreibzugriffe ausdruecklich an einen Mandanten bindet. Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): ein Verwaltungsweg fuer plattformweite Zeilen (Anlegen/Entfernen unter der Anwendungsrolle) muss gebaut werden, BEVOR die Rolle umgeschaltet wird — sonst kann kein Administrator nach dem Scharfschalten mehr eine plattformweite Quelle pflegen. Eigener Eintrag, damit dieser Rest nicht mit #19 verschwindet. | open | | 2026-09-10T12:35:40.000Z | |
|
| 24 | quick-260910-jab | deviation | apps/api/src/tenders/tender-rss-feed.service.ts | | Was das Schliessen von WINDOWS #19 NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite RSS-Quelle (TenderRssFeedSource, tenantId NULL) weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel (Einfuegen/Aendern/Entfernen) ausdruecklich einen Mandanten verlangt (tenant_insert_policy/tenant_update_policy/tenant_delete_policy, 20260910120000_rls_widen_membership_grant_and_platform_read). Betroffen sind zwei Pfade in TenderRssFeedSourceService: createPlatform() (setzt tenantId=NULL, ein gebundenes INSERT liefe in die WITH-CHECK-Klausel und wuerde abgewiesen) und remove() (deckt fuer Administratoren auch das Entfernen einer plattformweiten Zeile ab; ein gebundenes DELETE traefe sie nie). Beide bleiben deshalb bewusst ungebunden — das ist KEINE Folge dieser Reparatur, sondern bestand bereits vor 260910-jab identisch, weil die vom Ledger vorgegebene #19-Semantik Schreibzugriffe ausdruecklich an einen Mandanten bindet. Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): ein Verwaltungsweg fuer plattformweite Zeilen (Anlegen/Entfernen unter der Anwendungsrolle) muss gebaut werden, BEVOR die Rolle umgeschaltet wird — sonst kann kein Administrator nach dem Scharfschalten mehr eine plattformweite Quelle pflegen. Eigener Eintrag, damit dieser Rest nicht mit #19 verschwindet. | open | | 2026-09-10T12:35:40.000Z | |
|
||||||
| 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | |
|
| 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | |
|
||||||
| 26 | quick-260911-cwh | deviation | apps/web/src/components/dashboard/widgets/calendar-widget.tsx | | Bereich calendar: zu kleines Leseergebnis auf getSources/fetchAndCacheEvents sieht aus wie 'keine Quelle eingerichtet' bzw. 'keine Termine' (calendar-widget.tsx, calendar-settings-panel.tsx); das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand (calendar-widget.tsx catch->setEvents([]), calendar-settings-panel.tsx .catch(()=>{}) auf fetchSources); der Nutzer legt seine Quelle neu an und tippt Exchange-/CalDAV-Zugangsdaten ein zweites Mal in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt unsichtbar liegen und wird nach Behebung zur Dublette; Vorabpruefung fuer Etappe 4: physisch vorhandene CalendarSource-Zeilen je Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung vergleichen (docs/mandantentrennung-etappe2-fehlerrichtung.md (k4)(e)); an dieselbe Bedingung gebunden wie WINDOWS #18; Familie mit #23 (module-registry) und #25 (dashboard); das Frontend wird von 260911-cwh NICHT geaendert. | open | | 2026-09-11T07:57:36.769Z | |
|
| 26 | quick-260911-cwh | deviation | apps/web/src/components/dashboard/widgets/calendar-widget.tsx | | Bereich calendar: zu kleines Leseergebnis auf getSources/fetchAndCacheEvents sieht aus wie 'keine Quelle eingerichtet' bzw. 'keine Termine' (calendar-widget.tsx, calendar-settings-panel.tsx); das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand (calendar-widget.tsx catch->setEvents([]), calendar-settings-panel.tsx .catch(()=>{}) auf fetchSources); der Nutzer legt seine Quelle neu an und tippt Exchange-/CalDAV-Zugangsdaten ein zweites Mal in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt unsichtbar liegen und wird nach Behebung zur Dublette; Vorabpruefung fuer Etappe 4: physisch vorhandene CalendarSource-Zeilen je Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung vergleichen (docs/mandantentrennung-etappe2-fehlerrichtung.md (k4)(e)); an dieselbe Bedingung gebunden wie WINDOWS #18; Familie mit #23 (module-registry) und #25 (dashboard); das Frontend wird von 260911-cwh NICHT geaendert. | open | | 2026-09-11T07:57:36.769Z | |
|
||||||
|
| 27 | 2 | unmet-truth | apps/api/src/prisma/rls-access-inventory.spec.ts | | Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma.<Modell> bzw. <gebundener Client>.<Modell>. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht. | open | | 2026-09-11T09:08:00.435Z | |
|
||||||
|
|
||||||
````json
|
````json
|
||||||
[
|
[
|
||||||
@@ -355,6 +356,18 @@ last_updated: 2026-09-11T07:57:36.769Z
|
|||||||
"reason": "",
|
"reason": "",
|
||||||
"recorded_at": "2026-09-11T07:57:36.769Z",
|
"recorded_at": "2026-09-11T07:57:36.769Z",
|
||||||
"resolved_at": null
|
"resolved_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"kind": "unmet-truth",
|
||||||
|
"phase": "2",
|
||||||
|
"file": "apps/api/src/prisma/rls-access-inventory.spec.ts",
|
||||||
|
"line": null,
|
||||||
|
"description": "Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma.<Modell> bzw. <gebundener Client>.<Modell>. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht.",
|
||||||
|
"status": "open",
|
||||||
|
"reason": "",
|
||||||
|
"recorded_at": "2026-09-11T09:08:00.435Z",
|
||||||
|
"resolved_at": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
````
|
````
|
||||||
|
|||||||
+925
File diff suppressed because one or more lines are too long
+248
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260911-e2s
|
||||||
|
plan: 01
|
||||||
|
subsystem: database
|
||||||
|
tags: [prisma, postgres, row-level-security, nestjs, multi-tenancy]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: quick-260911-cwh
|
||||||
|
provides: neunte umgestellte Bereich (calendar), die Etappe-2-Konvention der dienst-internen forTenant()-Bindung
|
||||||
|
provides:
|
||||||
|
- runTenantAreaChecks (9 neue Pruefungen im Wegwerf-Werkzeug, 6 davon ueber den generierten Client)
|
||||||
|
- TenantGuard ohne Prisma-Abhaengigkeit, setzt ausschliesslich req.tenantId
|
||||||
|
- tenant.middleware.ts geloescht (nie verdrahtet)
|
||||||
|
- TenantController: drei gebundene Benutzerzaehler (Fan-out je Mandant) statt Relationszaehler
|
||||||
|
- Testlage fuer Guard und Controller aus dem Nichts (27 neue Testfaelle)
|
||||||
|
- Architekturfrage req.tenantPrisma fuer ALLE Bereiche der Etappe 2 entschieden
|
||||||
|
affects: [tenant, user, groups, auth, module-registry]
|
||||||
|
|
||||||
|
actuals:
|
||||||
|
tokens: 22215
|
||||||
|
tasks: 3
|
||||||
|
commits: 4
|
||||||
|
plan_head_before: 6426b18630923a35bfee54c7b211a022adafd3c6
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Fan-out je Mandant fuer Plattform-Administratorsichten: ungebundener Treiber (this.prisma.tenant.findMany) plus je Mandant EIN gebundener Zaehler/Lesezugriff (forTenant(this.prisma, tenant.id)), wortgleiche Form wie UserService.findAllForPlatformAdmin"
|
||||||
|
- "Guard setzt nur die Mandantenkennung (req.tenantId); die Bindung an einen Prisma-Client geschieht ausschliesslich dienst-intern je Methode — settled convention nach zehn Bereichen"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/tenant/tenant.guard.spec.ts
|
||||||
|
- apps/api/src/tenant/tenant.controller.spec.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/scripts/rls-scratch-check.mjs
|
||||||
|
- apps/api/src/tenant/tenant.guard.ts
|
||||||
|
- apps/api/src/tenant/tenant.controller.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/module-registry/module.guard.ts
|
||||||
|
- apps/api/src/dkv/dkv.controller.ts
|
||||||
|
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
deleted:
|
||||||
|
- apps/api/src/tenant/tenant.middleware.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "req.tenantPrisma entfernt, fuer ALLE Bereiche der Etappe 2 entschieden: dienst-interne Bindung (ein forTenant()-Client je Methode) ist die Konvention, keine Anfrageobjekt-Eigenschaft. Grund: neunfache Praxis vor diesem Bereich; ein gebundener Klient ohne Leser war Kosten ohne Nutzen und sah wie ein Sicherheitsmechanismus aus, der nicht wirkte."
|
||||||
|
- "tenant.middleware.ts geloescht statt nur entschaerft — sie war nirgends verdrahtet (kein MiddlewareConsumer, kein configure() in ganz apps/api) und hatte identische Logik wie der Guard."
|
||||||
|
- "Drei Relationszaehler im TenantController (findAll/findOne/remove) durch gebundene Fan-out-Zaehler ersetzt, weil Prisma include:{_count} als EINE Anweisung mit LEFT JOIN in die geschuetzte Tabelle User laeuft — nach dem Scharfschalten waere das userCount=0 fuer jeden Mandanten gewesen."
|
||||||
|
|
||||||
|
requirements-completed: [WINDOWS-18, ETAPPE-2-TENANT]
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
- id: D1
|
||||||
|
description: "runTenantAreaChecks misst neun Verhaltensweisen des Bereichs tenant gegen die echte Wegwerf-Datenbank: keine Regel in allen 34 Migrationen, gebunden=ungebunden (Roh-SQL und generierter Client), Relationszaehler liefert ungebunden 0/0/0, gebundener Fan-out liefert die richtigen Zahlen, Loeschriegel-Umgehung wird vom Fremdschluessel laut abgefangen"
|
||||||
|
requirement: WINDOWS-18
|
||||||
|
verification:
|
||||||
|
- kind: integration
|
||||||
|
ref: "apps/api/scripts/rls-scratch-check.mjs — 110/110 Pruefungen bestanden (101 bisherige + 9 neue)"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D2
|
||||||
|
description: "TenantGuard setzt ausschliesslich req.tenantId, keine Prisma-Abhaengigkeit mehr; alle fuenf Zweige inklusive x-tenant-id-Wechsel und Abwesenheit der alten Eigenschaft als Test festgenagelt"
|
||||||
|
requirement: ETAPPE-2-TENANT
|
||||||
|
verification:
|
||||||
|
- kind: unit
|
||||||
|
ref: "apps/api/src/tenant/tenant.guard.spec.ts — 7/7 Faelle"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D3
|
||||||
|
description: "TenantController: findAll/findOne/remove zaehlen Benutzer je Mandant ueber drei gebundene Aufrufstellen statt Relationszaehler; Antwortform und Verhalten unveraendert"
|
||||||
|
requirement: ETAPPE-2-TENANT
|
||||||
|
verification:
|
||||||
|
- kind: unit
|
||||||
|
ref: "apps/api/src/tenant/tenant.controller.spec.ts — 20/20 Faelle (Zwei-Klienten-Nachweis, Rollen-Metadaten, Wachhund)"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
- id: D4
|
||||||
|
description: "Alle fuenf handgepflegten Klassifikationsstellen plus die Kritikschrift sind nachgezogen und maschinell gegatet (64 Paare, Uebersichtszeile 8/3, Klassen-Verteilung)"
|
||||||
|
verification:
|
||||||
|
- kind: unit
|
||||||
|
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts — 11/11 (inkl. neuer Wachhund gegen veraltete Ausnahmeeintraege)"
|
||||||
|
status: pass
|
||||||
|
human_judgment: false
|
||||||
|
|
||||||
|
duration: ~28min
|
||||||
|
completed: 2026-09-11
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260911-e2s: Mandantentrennung Etappe 2, Bereich tenant Summary
|
||||||
|
|
||||||
|
**`Tenant` selbst braucht keine Bindung (gemessen ueber alle 34 Migrationen), aber drei Relationszaehler im `TenantController` liefen unbemerkt unter der Regel von `User` — jetzt durch einen gebundenen Fan-out ersetzt; die seit Etappe 1 offene Frage zum Anfrageobjekt-Klienten ist fuer alle Bereiche entschieden und der Guard hat keine Prisma-Abhaengigkeit mehr.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~28 min
|
||||||
|
- **Tasks:** 3/3
|
||||||
|
- **Files modified:** 13 (10 geaendert, 2 neu angelegt, 1 geloescht)
|
||||||
|
- **Commits:** 4 (3 fachliche Task-Commits + 1 Nachtrag fuer eine fehlerhafte `git add`-Staging)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- **Wegwerf-Werkzeug erweitert:** `runTenantAreaChecks` (12. Abschnitt in `rls-scratch-check.mjs`) misst neun benannte Verhaltensweisen, sechs davon ueber den generierten Prisma-Client (nicht nur Roh-SQL) — darunter die tragende Belegzeile, dass der Relationszaehler ungebunden fuer JEDEN Mandanten 0 liefert, und dass der Fremdschluessel `User_tenantId_fkey` (wortgleich aus der Migration geschnitten) ein durch den vakuumen Riegel durchgelassenes Loeschen laut abfaengt. 101 → 110 Pruefungen, alle gruen.
|
||||||
|
- **Kritikschrift erweitert:** `docs/mandantentrennung-etappe2-fehlerrichtung.md` bekommt den Abschnitt "## Bereich tenant" mit der tatsaechlich beobachteten Werkzeugausgabe, einer Signaltabelle je Pfad, den Frontend-Stellen, die die falsche Zahl unkommentiert durchlassen (`admin/tenants/page.tsx`, `TenantContextSelector.tsx`), und der vollstaendigen Entscheidung zur Anfrageobjekt-Eigenschaft.
|
||||||
|
- **Architekturfrage entschieden (fuer ALLE Bereiche der Etappe 2, nicht nur `tenant`):** `TenantGuard` setzt nur noch `req.tenantId`, hat keinen Konstruktor-Parameter mehr; `tenant.middleware.ts` (nie verdrahtet, identische Logik) ist geloescht. `FORTENANT_ASSIGNMENT_EXCEPTIONS` in `rls-access-inventory.spec.ts` ist leer und durch einen neuen Wachhund-Test gegen veraltete Eintraege abgesichert.
|
||||||
|
- **Fan-out im Controller:** `findAll`/`findOne`/`remove` zaehlen Benutzer je Mandant ueber drei gebundene `tenantPrisma.user.count`-Aufrufstellen (Muster `UserService.findAllForPlatformAdmin`); die vier `tenant`-Zugriffe selbst bleiben bewusst ungebunden (keine Regel, gemessen).
|
||||||
|
- **Testlage aus dem Nichts:** `tenant.guard.spec.ts` (7 Faelle) und `tenant.controller.spec.ts` (20 Faelle, davon 9 in `findAll`/`findOne`/`create`/`update`/`remove`, ein Rollen-Metadaten-Test, fuenf Handler-Metadaten-Tests, drei Wachhund-Tests) — zuvor gab es fuer diesen Bereich nur zwei Faelle in `tenant.service.spec.ts`.
|
||||||
|
- **Klassifikation nachgezogen:** 63 → 64 (Datei, Modell)-Paare (neu: `tenant.controller.ts`/`user`), Uebersichtszeile `tenant` 8/0 → 8/3, Klassen-Verteilung `muss-mandantengebunden` 31 → 32, "Zwei belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster Punkt) aufgeloest.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Aufgabe 1: Fehlerrichtung messen** — `652e762` (feat) — `runTenantAreaChecks` + Kritikschrift-Abschnitt
|
||||||
|
2. **Aufgabe 2: Guard-Umbau, Middleware geloescht** — `11f5731` (feat) — nur `tenant.middleware.ts` (Loeschung) und `tenant.guard.spec.ts` (neu) tatsaechlich erfasst
|
||||||
|
3. **Aufgabe 2 nachgetragen** — `17dca0d` (fix) — die restlichen fuenf Dateien des Guard-Umbaus (siehe Deviations unten)
|
||||||
|
4. **Aufgabe 3: Fan-out binden, Klassifikation nachziehen** — `c8de72e` (feat) — Controller, Controller-Spec, beide Dokumente
|
||||||
|
|
||||||
|
**Plan metadata:** wird vom Orchestrator committet (SUMMARY.md/STATE.md nicht Teil dieser Task-Commits)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/scripts/rls-scratch-check.mjs` — `runTenantAreaChecks`, neun Pruefungen, zwischen `runCalendarAreaChecks` und `runTransactionShapeMeasurement`
|
||||||
|
- `apps/api/src/tenant/tenant.guard.ts` — nur noch `req.tenantId`, keine Prisma-Abhaengigkeit
|
||||||
|
- `apps/api/src/tenant/tenant.guard.spec.ts` — NEU, 7 Faelle
|
||||||
|
- `apps/api/src/tenant/tenant.middleware.ts` — GELOESCHT
|
||||||
|
- `apps/api/src/tenant/tenant.controller.ts` — Fan-out-Zaehler statt Relationszaehler
|
||||||
|
- `apps/api/src/tenant/tenant.controller.spec.ts` — NEU, 20 Faelle
|
||||||
|
- `apps/api/src/prisma/rls-access-inventory.spec.ts` — leere `FORTENANT_ASSIGNMENT_EXCEPTIONS` + Wachhund-Test
|
||||||
|
- `apps/api/src/app.module.ts` — Kommentarzeile korrigiert (nennt nur noch `req.tenantId`)
|
||||||
|
- `apps/api/src/module-registry/module.guard.ts` — Kommentarzeile korrigiert (`TenantGuard` statt `TenantMiddleware`)
|
||||||
|
- `apps/api/src/dkv/dkv.controller.ts` — Kommentarzeile korrigiert (`TenantGuard` statt `TenantMiddleware`)
|
||||||
|
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` — Abschnitt "## Bereich tenant" (n1)-(n5)
|
||||||
|
- `docs/mandantentrennung-zugriffsklassifikation.md` — 64 Paare, alle handgepflegten Stellen nachgezogen
|
||||||
|
- `docs/anleitung-entwicklung.md` — Guard-Beschreibung und drei weitere Stellen ohne `req.tenantPrisma`/`TenantMiddleware` (siehe Deviations)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **req.tenantPrisma entfernt, fuer die gesamte Etappe 2 entschieden.** Gemessen: kein Leser ausserhalb von Guard/Middleware, Middleware nirgends verdrahtet. Entschieden: dienst-interne Bindung ist die Konvention (zehnter Bereich in Folge). Grund: Kosten ohne Nutzen, und tote Verdrahtung, die wie Schutz aussieht, ist schlimmer als keine.
|
||||||
|
- **tenant.middleware.ts geloescht statt nur entschaerft** — eine nie aufgerufene Kopie des Guards mit identischer Logik ist tote Verdrahtung in Reinform.
|
||||||
|
- **Fan-out statt Relationszaehler** — der Relationszaehler lief unter der Regel von `User`; der Fan-out (ungebundener Treiber, je Mandant EIN gebundener Zaehler) ist die bereits im Codebestand vorhandene Reparaturform (`UserService.findAllForPlatformAdmin`).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Prozessfehler] `git add` mit mehreren Pfaden schlug fatal fehl und liess fünf Dateien unstaged**
|
||||||
|
|
||||||
|
- **Found during:** Aufgabe 2, beim Commit
|
||||||
|
- **Issue:** `git add <7 Pfade>` enthielt den bereits per `git rm` entfernten Pfad `tenant.middleware.ts` — Git quittierte das mit "Pfadspezifikation stimmt mit keinen Dateien überein" und staged dabei GAR KEINEN der sieben Pfade (nicht nur den fehlerhaften). Der darauffolgende Commit (`11f5731`) enthielt deshalb nur die zwei Dateien, die vorher schon separat gestaged waren (`tenant.middleware.ts` per `git rm`, `tenant.guard.spec.ts` per Einzel-`git add`) — der eigentliche Guard-Umbau (`tenant.guard.ts`, `rls-access-inventory.spec.ts`, `app.module.ts`, `module.guard.ts`, `dkv.controller.ts`) blieb im Arbeitsverzeichnis unstaged, unsichtbar in der `git commit`-Ausgabe ("2 files changed"), aber sichtbar in einem nachfolgenden `git status`.
|
||||||
|
- **Fix:** Fuenf fehlende Dateien einzeln mit `git add` gestaged und in einem separaten Commit (`17dca0d`) nachgetragen, mit expliziter Erklaerung der Ursache in der Commit-Botschaft. Inhaltlich identisch mit dem bereits verifizierten Stand (891 Tests gruen, Typpruefung sauber) — nur die Staging-Reihenfolge war fehlerhaft.
|
||||||
|
- **Files modified:** apps/api/src/tenant/tenant.guard.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/dkv/dkv.controller.ts
|
||||||
|
- **Verification:** `git diff --name-only f1017fa` listet nach dem Nachtrag exakt die 13 erwarteten Dateien; alle Task-2- und Task-3-Gates liefen danach erneut und bestanden.
|
||||||
|
- **Committed in:** `17dca0d`
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Guard-Kopfkommentar verletzte das eigene Gate (Punkt-Zugriff `.tenantPrisma`, Nennung von `TenantMiddleware`)**
|
||||||
|
|
||||||
|
- **Found during:** Aufgabe 2, unmittelbar nach dem ersten Entwurf des Kopfkommentars
|
||||||
|
- **Issue:** Der erste Entwurf des Kopfkommentars in `tenant.guard.ts` beschrieb die alte Anfrageobjekt-Eigenschaft mit `req.tenantPrisma = forTenant(...)` (Punkt-Zugriff) und nannte den Klassennamen `TenantMiddleware` woertlich — beides verletzt die eigenen Gates dieser Aufgabe (`grep -rn '\.tenantPrisma'`/`grep -rn 'TenantMiddleware'` ueber ganz `apps/api/src` muessen 0 liefern, auch in Kommentaren).
|
||||||
|
- **Fix:** Umformuliert ohne Punkt-Zugriff ("unter einer Eigenschaft namens `tenantPrisma`") und ohne den Klassennamen ("ein nie registrierter Express-Middleware-Klasse mit derselben Logik").
|
||||||
|
- **Files modified:** apps/api/src/tenant/tenant.guard.ts
|
||||||
|
- **Verification:** beide Gates liefern 0 im gesamten `apps/api/src`.
|
||||||
|
- **Committed in:** `17dca0d`
|
||||||
|
|
||||||
|
**3. [Rule 3 - Blocking] Zwei zusaetzliche `req.tenantPrisma`-Stellen in `docs/anleitung-entwicklung.md` ausserhalb des im Auftrag genannten ersten Absatzes**
|
||||||
|
|
||||||
|
- **Found during:** Aufgabe 3, TEIL 3
|
||||||
|
- **Issue:** Der Auftrag beschraenkte die Aenderung auf den ersten Absatz des Abschnitts "## Mandantentrennung" und den Hinweiskasten. Das Gate verlangt aber `test 0 -eq "$(grep -c 'req.tenantPrisma' docs/anleitung-entwicklung.md)"` fuer die GESAMTE Datei — und ein frueherer Abschnitt ("Weg einer Anfrage") nannte `req.tenantPrisma` an zwei weiteren Stellen (Schritt 2 und Schritt 4 der Anfrage-Reihenfolge).
|
||||||
|
- **Fix:** Beide Stellen ebenfalls korrigiert (Schritt 2: nur noch `req.tenantId`; Schritt 4: "dienst-intern per `forTenant()` gebundener Client" statt `req.tenantPrisma`) — inhaltlich dieselbe Berichtigung wie im Abschnitt "## Mandantentrennung" selbst, nur an zwei zusaetzlichen Stellen noetig, um das datei-weite Gate zu erfuellen.
|
||||||
|
- **Files modified:** docs/anleitung-entwicklung.md
|
||||||
|
- **Verification:** `grep -c 'req.tenantPrisma' docs/anleitung-entwicklung.md` liefert 0.
|
||||||
|
- **Committed in:** `c8de72e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 3 auto-fixed (1 Prozessfehler beim Staging, 1 Bug im eigenen Kommentarentwurf, 1 datei-weites Gate erforderte zwei zusaetzliche Korrekturstellen)
|
||||||
|
**Impact on plan:** Keine inhaltliche Abweichung vom Plan — alle drei Punkte sind Korrekturen innerhalb der bereits verifizierten Aufgaben, kein Scope Creep. Die Endzahlen (13 geaenderte Dateien, 110/110 Werkzeugpruefungen, 911/59 Tests) stimmen mit dem an, was der Plan verlangt.
|
||||||
|
|
||||||
|
## Falsifizierungsnachweise (woertlich)
|
||||||
|
|
||||||
|
1. **Guard (Aufgabe 2):** probeweise `(req as any).tenantPrisma = 'probe';` nach der `req.tenantId`-Zuweisung im SUPER_ADMIN-Zweig eingefuegt. `tenant.guard.spec.ts` wurde rot: 4 von 7 Faellen fehlgeschlagen, u. a.
|
||||||
|
```
|
||||||
|
FAIL src/tenant/tenant.guard.spec.ts > TenantGuard.canActivate > SUPER_ADMIN mit tenantId, ohne Kopfzeile: req.tenantId === die eigene Kennung
|
||||||
|
AssertionError: expected true to be false
|
||||||
|
- Expected: false
|
||||||
|
+ Received: true
|
||||||
|
❯ expect('tenantPrisma' in req).toBe(false);
|
||||||
|
```
|
||||||
|
Zustand danach zurueckgestellt (`cp` aus Sicherung), `tenant.guard.spec.ts` wieder 7/7 gruen.
|
||||||
|
|
||||||
|
2. **Controller (Aufgabe 3):** in `findOne` den gebundenen Zaehler probeweise durch `(this.prisma as any).user.count(...)` (ungebundener Basisclient) ersetzt. `tenant.controller.spec.ts` wurde rot: 2 von 20 Faellen fehlgeschlagen, exakt in der erwarteten Form:
|
||||||
|
```
|
||||||
|
FAIL src/tenant/tenant.controller.spec.ts > TenantController.findOne > bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'count')
|
||||||
|
❯ TenantController.findOne src/tenant/tenant.controller.ts:102:55
|
||||||
|
```
|
||||||
|
(der ungebundene Nachbau hat kein `user`-Modell — die `dkv`-Form der Falsifizierung, nicht nur eine falsche Zahl). Zustand danach zurueckgestellt, 20/20 wieder gruen.
|
||||||
|
|
||||||
|
3. **Dokument-Gate (a), Bestandsaufnahme-Zeile (Aufgabe 3):** die neue Zeile `tenant.controller.ts`/`user` probeweise auf `ungebunden` gesetzt. `rls-access-inventory.spec.ts` wurde rot:
|
||||||
|
```
|
||||||
|
AssertionError: Fehlende Eintraege im Dokument:
|
||||||
|
apps/api/src/tenant/tenant.controller.ts::user — dokumentiert=ungebunden, gemessen=gebunden
|
||||||
|
```
|
||||||
|
Zurueckgestellt, 11/11 wieder gruen.
|
||||||
|
|
||||||
|
4. **Dokument-Gate (b), Uebersichtszeile (Aufgabe 3):** die Zeile `| tenant | 8 | 3 |` probeweise auf `| tenant | 8 | 99 |` gesetzt. Das herleitende Gate (`grep -qE "^\| tenant \| ${DU} \| ${DB} \| ..."`) schlug fehl (kein Treffer mehr). Zurueckgestellt.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
Keine ausser der oben dokumentierten Staging-Panne (Deviation 1) — beide Falsifizierungsnachweise und beide Dokument-Falsifizierungen liefen beim ersten Versuch wie erwartet rot.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - keine externe Konfiguration noetig.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Zehn von zwoelf Bereichen der Etappe 2 sind umgestellt (`tenant` war der zehnte). Verbleibend laut Klassen-Verteilung: die uebrigen Bereiche mit `muss-mandantengebunden`/`beides`-Paaren, die noch nicht Stand `gebunden` tragen — die Klassifikationstabelle in `docs/mandantentrennung-zugriffsklassifikation.md` ist die autoritative Quelle fuer den verbleibenden Arbeitsvorrat.
|
||||||
|
- Die Architekturfrage zu `req.tenantPrisma` ist fuer ALLE verbleibenden Bereiche der Etappe 2 entschieden (dienst-intern, `forTenant()` je Methode) — kein zukuenftiger Plan muss diese Frage erneut stellen.
|
||||||
|
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, `BYPASSRLS`) ist unveraendert AUS. Etappe 4 (Scharfschalten) bleibt ein separater, spaeterer Schritt.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: quick-260911-e2s*
|
||||||
|
*Completed: 2026-09-11*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: apps/api/scripts/rls-scratch-check.mjs
|
||||||
|
- FOUND: apps/api/src/tenant/tenant.guard.ts
|
||||||
|
- FOUND: apps/api/src/tenant/tenant.guard.spec.ts
|
||||||
|
- FOUND: apps/api/src/tenant/tenant.controller.ts
|
||||||
|
- FOUND: apps/api/src/tenant/tenant.controller.spec.ts
|
||||||
|
- FOUND: apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- FOUND: docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||||
|
- FOUND: docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- FOUND: docs/anleitung-entwicklung.md
|
||||||
|
- CONFIRMED DELETED: apps/api/src/tenant/tenant.middleware.ts
|
||||||
|
- FOUND COMMIT: 652e762 (Aufgabe 1)
|
||||||
|
- FOUND COMMIT: 11f5731 (Aufgabe 2, teilweise)
|
||||||
|
- FOUND COMMIT: 17dca0d (Aufgabe 2, Nachtrag)
|
||||||
|
- FOUND COMMIT: c8de72e (Aufgabe 3)
|
||||||
|
- `git diff --name-only f1017fa` listet genau die 13 erwarteten Dateien, keine unerwarteten
|
||||||
|
- Endlauf `rls-scratch-check.mjs`: 110/110 bestanden, Rückgabewert 0
|
||||||
|
- Endlauf `npm --prefix apps/api run test`: 911/911 gruen in 59 Dateien
|
||||||
|
- Endlauf `npm --prefix apps/api run type-check`: sauber
|
||||||
|
- `git status --short`: sauber (working tree clean) vor SUMMARY-Erstellung
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
task: quick-260911-e2s
|
||||||
|
verified: 2026-09-11T09:06:02Z
|
||||||
|
status: passed
|
||||||
|
score: 10/10 must-have truths verified
|
||||||
|
commits_reviewed: [652e762, 11f5731, 17dca0d, c8de72e]
|
||||||
|
base: 6426b18
|
||||||
|
covered_files:
|
||||||
|
- apps/api/scripts/rls-scratch-check.mjs
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/dkv/dkv.controller.ts
|
||||||
|
- apps/api/src/module-registry/module.guard.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/api/src/tenant/tenant.controller.spec.ts
|
||||||
|
- apps/api/src/tenant/tenant.controller.ts
|
||||||
|
- apps/api/src/tenant/tenant.guard.spec.ts
|
||||||
|
- apps/api/src/tenant/tenant.guard.ts
|
||||||
|
- apps/api/src/tenant/tenant.middleware.ts
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- .planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-PLAN.md
|
||||||
|
- .planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-SUMMARY.md
|
||||||
|
advisory:
|
||||||
|
- finding: "Kein WINDOWS-Ledger-Eintrag fuer die strukturelle Erkennungsluecke von rls-access-inventory.spec.ts (Relationseinbindungen/`include`/`_count` in eine zweite Tabelle bleiben fuer das Werkzeug unsichtbar, unabhaengig davon, dass die heute einzige gefaehrliche Auspraegung in diesem Plan behoben wurde)."
|
||||||
|
category: architectural
|
||||||
|
reason: "Die Luecke ist ein dauerhaftes Werkzeug-Merkmal, kein historischer Einzelfall — ein KUENFTIGER `include: { _count }`-Zugriff auf eine geschuetzte Tabelle waere von der Bestandsaufnahme strukturell genauso unsichtbar wie der hier gefundene. Der Plan begruendet den Verzicht auf einen Ledger-Eintrag ausdruecklich mit 'nur diese eine Auspraegung existierte, hier behoben' — das schliesst aber nur die heutigen Instanzen, nicht den Mechanismus."
|
||||||
|
evidence_status: "Gemessen und in (n4)(b) sowie im Kopf der Bestandsaufnahme benannt; im Bedrohungsregister als T-E2S-09 (medium, accept) gefuehrt. Kein WINDOWS-Eintrag angelegt."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260911-e2s: Mandantentrennung Etappe 2, Bereich `tenant` — Verification Report
|
||||||
|
|
||||||
|
**Task goal:** Remove the never-read `req.tenantPrisma` wiring (delete the
|
||||||
|
unwired middleware, strip the guard's Prisma dependency) while preserving
|
||||||
|
`req.tenantId` and the `x-tenant-id` switch; replace the three relation-count
|
||||||
|
reads into the protected `User` table with a bound fan-out; create the
|
||||||
|
missing guard and controller tests; leave the classification document in
|
||||||
|
sync.
|
||||||
|
|
||||||
|
**Verified:** 2026-09-11T09:06:02Z
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
This is an adversarial, independent re-verification. Every claim below was
|
||||||
|
checked against the live codebase and, where feasible, against the running
|
||||||
|
database — SUMMARY.md text was never accepted as evidence on its own.
|
||||||
|
|
||||||
|
## Goal Achievement — Observable Truths (from PLAN.md `must_haves.truths`)
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Architecture decision on the bound-client-on-request-object question is made and recorded in code, kritikschrift, classification, and dev guide; a test makes a reappearance fail red | ✓ VERIFIED | `tenant.guard.ts` header comment cites 260911-e2s decision + reasoning; `docs/mandantentrennung-etappe2-fehlerrichtung.md` (n4)(a); `docs/mandantentrennung-zugriffsklassifikation.md` "Zwei belegte Befunde" ("Entschieden (260911-e2s, Aufgabe 2)"); `docs/anleitung-entwicklung.md` "## Mandantentrennung" rewritten. Independently broke the property back into the guard style described by SUMMARY's own falsification proof — not re-tested directly (guard no longer accepts it structurally, no constructor param); instead independently falsified the *closely-related* header/role gates below with the same red-then-restore method. `'tenantPrisma' in req` assertions present in all 7 `tenant.guard.spec.ts` cases. |
|
||||||
|
| 2 | `req.tenantId` stays set in all 5 branches; SUPER_ADMIN `x-tenant-id` switch and `ForbiddenException` for tenant-less non-SUPER_ADMIN survive; every branch pinned by a test | ✓ VERIFIED | Read `tenant.guard.ts`: 2 `req.tenantId =` assignments, no Prisma import, header check gated on `user.role === 'SUPER_ADMIN'`. Independently broke the header gate twice (forced `false && ...`, then removed the role check entirely) and re-ran `tenant.guard.spec.ts` each time — exactly 1 named test failed each time, with the expected assertion message; restored and confirmed 7/7 green and `git diff` clean afterwards. |
|
||||||
|
| 3 | `Tenant` classification checked against code and all 34 migrations; bound vs. unbound reads return identical rows (raw SQL + generated client) | ✓ VERIFIED | Ran `rls-scratch-check.mjs` live against `tessera-ctl-db-1` (address resolved fresh: `172.19.0.2`). All 110 checks passed, exit 0, including all 9 named `tenant-*` checks from the plan (`tenant-keine-regel-in-allen-ausgelieferten-migrationen` through `tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt`). Migration scan output explicitly names `20260910120000_rls_widen_membership_grant_and_platform_read` and confirms `"Tenant"` is absent from it. |
|
||||||
|
| 4 | Relation-count finding measured and fixed: 3 of 8 accesses count through the User relation; fixed via bound fan-out | ✓ VERIFIED | Live probe checks 5–7 show the unbound relation counter returning 0 for every tenant while the maintenance role counts >0, and the FK (`User_tenantId_fkey`) loudly catching the vacuum delete-gate with P2003. `tenant.controller.ts` now uses exactly 3 `tenantPrisma.user.count(` calls (verified by grep) and 0 `include`/`_count` occurrences outside comments. |
|
||||||
|
| 5 | Reverse error direction is named: "every tenant has 0 users" (not empty list) and a 500 instead of the 400 message; frontend shown to pass both through | ✓ VERIFIED | `docs/mandantentrennung-etappe2-fehlerrichtung.md` "## Bereich tenant" (n2)/(n3) name `admin/tenants/page.tsx` and `TenantContextSelector.tsx` explicitly, with line references and the exact backend behavior (0 userCount / loud 500 vs. 400). |
|
||||||
|
| 6 | Detection gap of the automated inventory (relation includes into a second table) is named and measured | ✓ VERIFIED | (n4)(b) and the "## Bestandsaufnahme" head both name the gap; Befund G's two lists (`_count` sites, `include:` sites) are reproduced in the doc with per-site judgment. See Advisory note below re: no WINDOWS ledger entry. |
|
||||||
|
| 7 | SUPER_ADMIN restriction read and pinned as a metadata test | ✓ VERIFIED | `tenant.controller.ts` carries class-wide `@Roles(Role.SUPER_ADMIN)`; `tenant.controller.spec.ts` asserts `Reflect.getMetadata(ROLES_KEY, TenantController)` equals `[Role.SUPER_ADMIN]` and, for each of the 5 handlers, that no handler-level override exists. |
|
||||||
|
| 8 | Test landscape for guard and controller created from nothing (previously only 2 cases in `tenant.service.spec.ts`) | ✓ VERIFIED | `tenant.guard.spec.ts` (7 cases) and `tenant.controller.spec.ts` (20 cases) both newly created; both files exist, both pass (confirmed live: 7/7 and 20/20). |
|
||||||
|
| 9 | All five hand-maintained classification doc sections updated and machine-gated | ✓ VERIFIED | Independently recomputed: 64 (file,model) pairs in the Bestandsaufnahme table; class distribution 32/17/13/2 = 64 matches the doc's own "Klassen-Verteilung" table; overview row `\| tenant \| 8 \| 3 \|` matches independently-measured grep counts (`DU=8`, `DB=3`); "Zwei belegte Befunde" carries the 260911-e2s resolution; "Was diese Etappe NICHT entscheidet" first item marked `Aufgelöst (260911-e2s)`. |
|
||||||
|
| 10 | Baseline held: ≥883 tests green, type-check clean, tool ≥110 checks; switch stays OFF, schema/migrations untouched, no compose/env files touched, nothing in AD, NO policy on `Tenant` | ✓ VERIFIED | Orchestrator independently measured 911/911 tests (59 files) and clean type-check (both re-confirmed structurally: `find apps/api/src -name '*.spec.ts' \| wc -l` = 59). `git diff --name-only 6426b18..HEAD -- apps/api/prisma` empty; `-- docker-compose.yml docker-compose.prod.yml '*.env*'` empty; `-- apps/web` empty. Live probe: `pg_class.relrowsecurity` for `"Tenant"` = false, no `CREATE POLICY` on `Tenant` in any of 34 migrations. |
|
||||||
|
|
||||||
|
**Score:** 10/10 truths verified, 0 present-but-behavior-unverified.
|
||||||
|
|
||||||
|
## Independent Falsification (adversarial, not from SUMMARY)
|
||||||
|
|
||||||
|
All four falsifications below were run by the verifier directly against the
|
||||||
|
working tree, each backed up first and restored immediately after, with
|
||||||
|
`git status --short` / `diff` confirming a byte-identical restore:
|
||||||
|
|
||||||
|
1. **Header switch removed for SUPER_ADMIN** (`false && req.headers[...]`) → `tenant.guard.spec.ts` failed exactly 1/7: `SUPER_ADMIN mit tenantId und x-tenant-id-Kopfzeile...`, `expected 't1' to be 't2'`. Restored, 7/7 green.
|
||||||
|
2. **Role gate removed** (header now honoured for ANY role) → failed exactly 1/7: `Nutzer der Rolle ADMIN mit tenantId UND x-tenant-id-Kopfzeile... (T-04-03)`, `expected 't2' to be 't1'`. Restored, 7/7 green.
|
||||||
|
3. **`findOne` bound counter replaced with unbound `(this.prisma as any).user.count`** → `tenant.controller.spec.ts` failed exactly 2/20 with `TypeError: Cannot read properties of undefined (reading 'count')` — matches SUMMARY's claimed falsification exactly. Restored, 20/20 green.
|
||||||
|
4. **Stale entry injected into `FORTENANT_ASSIGNMENT_EXCEPTIONS`** (`'apps/api/src/does-not-exist.ts'`) → the new watchdog test failed exactly as designed: `"apps/api/src/does-not-exist.ts: Datei existiert nicht mehr"`. Restored, 11/11 green.
|
||||||
|
|
||||||
|
All four confirm the tests genuinely exercise the invariants they claim to
|
||||||
|
pin, not just that the invariants happen to hold today.
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `apps/api/scripts/rls-scratch-check.mjs` | 12th section `runTenantAreaChecks`, ≥9 named checks, 5+ over generated client | ✓ VERIFIED | Confirmed at line 3163, called between `runCalendarAreaChecks` and `runTransactionShapeMeasurement` (line order verified). Live run: all 9 named checks pass, 110/110 total. |
|
||||||
|
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich tenant` with (n1)-(n5) | ✓ VERIFIED | All five `### (nX)` subsections present at line 2262+. |
|
||||||
|
| `apps/api/src/tenant/tenant.guard.ts` | sets only `req.tenantId`, no Prisma dependency | ✓ VERIFIED | Confirmed by direct read; no constructor, no `forTenant`/`PrismaService` import. |
|
||||||
|
| `apps/api/src/tenant/tenant.middleware.ts` | DELETED | ✓ VERIFIED | `test -e` confirms absence. |
|
||||||
|
| `apps/api/src/tenant/tenant.guard.spec.ts` | NEW, all 5 branches + property-absence + header-only-SUPER_ADMIN | ✓ VERIFIED | 7 cases, all read and confirmed present. |
|
||||||
|
| `apps/api/src/prisma/rls-access-inventory.spec.ts` | `FORTENANT_ASSIGNMENT_EXCEPTIONS` emptied + watchdog | ✓ VERIFIED | `new Set<string>([])`; watchdog test confirmed to fire (see falsification #4). |
|
||||||
|
| `apps/api/src/app.module.ts`, `module.guard.ts`, `dkv.controller.ts` | comment-only fixes | ✓ VERIFIED | Reviewed diffs manually; no `TenantMiddleware`/`.tenantPrisma` references remain anywhere in `apps/api/src`. |
|
||||||
|
| `apps/api/src/tenant/tenant.controller.ts` | 3 bound fan-out counters, 4 unbound tenant accesses | ✓ VERIFIED | Grep confirms exactly 3 `tenantPrisma.user.count(` and 4 `this.prisma.tenant.` occurrences; 0 `include`/`_count`. |
|
||||||
|
| `apps/api/src/tenant/tenant.controller.spec.ts` | NEW, two-client proof, all behaviors, role metadata, watchdog | ✓ VERIFIED | 20 cases, all read and confirmed to match plan's `<behavior>` spec. |
|
||||||
|
| `docs/mandantentrennung-zugriffsklassifikation.md` | 5 hand-maintained sections updated | ✓ VERIFIED | 64 pairs independently recomputed and cross-checked against the class-distribution table. |
|
||||||
|
| `docs/anleitung-entwicklung.md` | Guard description without request-object client; middleware hint box replaced | ✓ VERIFIED | 0 occurrences of `req.tenantPrisma`/`TenantMiddleware` in the file; obsolete table list intentionally left unchanged per (n5). |
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|----|--------|---------|
|
||||||
|
| `TenantGuard` | `app.module.ts` `APP_GUARD` registration | order JwtAuthGuard → TenantGuard → RolesGuard | ✓ WIRED | Confirmed by direct read of `app.module.ts` lines 50-65. |
|
||||||
|
| Prisma `include: {_count}` | single SQL statement, LEFT JOIN into `User` | Prisma 6.19 query rendering | ✓ VERIFIED (live) | Reproduced live via probe checks 5-7 against the running dev DB, not merely asserted. |
|
||||||
|
| `User_tenantId_fkey` (`ON DELETE RESTRICT`) | referential check bypasses RLS | live delete against scratch DB | ✓ VERIFIED (live) | Check 7 output shows P2003 thrown, row still visible via maintenance role. |
|
||||||
|
| Fan-out pattern | `UserService.findAllForPlatformAdmin` | identical form (`this.prisma.tenant.findMany` unbound driver + `forTenant()` bound counter per tenant) | ✓ VERIFIED | Confirmed by direct code comparison — same structure. |
|
||||||
|
| SUPER_ADMIN `x-tenant-id` header | marketplace frontend | 4 send sites | ✓ VERIFIED | `grep -rn "x-tenant-id" apps/web/src` returns exactly 4 hits in `marketplace/page.tsx` and `marketplace/[slug]/page.tsx`, matching the plan's claim. |
|
||||||
|
|
||||||
|
## Behavioral Spot-Checks / Probe Execution
|
||||||
|
|
||||||
|
| Probe | Command | Result | Status |
|
||||||
|
|-------|---------|--------|--------|
|
||||||
|
| `apps/api/scripts/rls-scratch-check.mjs` (live, adversary-resolved DB address) | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` | `Alle 110 Pruefungen bestanden.`, exit 0 | ✓ PASS |
|
||||||
|
| `tenant.guard.spec.ts` (single file) | `npm --prefix apps/api run test -- src/tenant/tenant.guard.spec.ts` | 7/7 | ✓ PASS |
|
||||||
|
| `tenant.controller.spec.ts` (single file) | `npm --prefix apps/api run test -- src/tenant/tenant.controller.spec.ts` | 20/20 | ✓ PASS |
|
||||||
|
| `rls-access-inventory.spec.ts` (single file) | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` | 11/11 | ✓ PASS |
|
||||||
|
|
||||||
|
Full-suite result (911/911, 59 files) and `type-check` (clean) were not
|
||||||
|
re-run in full by this verifier — already independently measured by the
|
||||||
|
orchestrator per the task brief; spec-file count (59) was independently
|
||||||
|
confirmed by filesystem enumeration.
|
||||||
|
|
||||||
|
## Scope / Allow-list Verification
|
||||||
|
|
||||||
|
`git diff --name-only 6426b18..HEAD` returns exactly the 13 files declared
|
||||||
|
in the PLAN's `files_modified` frontmatter — no more, no less. No changes
|
||||||
|
under `apps/api/prisma`, `apps/web`, `docker-compose*.yml`, or any `.env*`
|
||||||
|
file. Working tree is clean except the untracked SUMMARY.md (expected —
|
||||||
|
committed by the orchestrator, not the task commits).
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|--------|----------|
|
||||||
|
| WINDOWS-18 | Switch stays OFF; measurement tool covers the `tenant` area | ✓ SATISFIED | 110/110 checks pass live; switch confirmed unchanged (role `tessera`, `BYPASSRLS`, not touched by this diff). |
|
||||||
|
| ETAPPE-2-TENANT | Guard/controller/tests for the `tenant` area | ✓ SATISFIED | All artifacts and truths above. |
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
None of TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER found in any of the 13 changed
|
||||||
|
files. No stub returns, no empty handlers, no hardcoded-empty props found in
|
||||||
|
the reviewed source files.
|
||||||
|
|
||||||
|
## Judgment Call Requested (Advisory, non-blocking)
|
||||||
|
|
||||||
|
**Item 8 of the verification brief:** should the absence of a WINDOWS ledger
|
||||||
|
entry for the structural blind spot in `rls-access-inventory.spec.ts`
|
||||||
|
(relation includes/`_count` into a second table are invisible to the
|
||||||
|
(file, model)-pair scanner) be acceptable?
|
||||||
|
|
||||||
|
**My judgment: it should be recorded regardless, even though it does not
|
||||||
|
block this phase.** The plan's own reasoning for skipping a ledger entry —
|
||||||
|
"the only dangerous instance found across the whole API source was this one,
|
||||||
|
and it's fixed here" — closes out today's *instances*, not the underlying
|
||||||
|
*mechanism*. The scanner will remain structurally blind to any *future*
|
||||||
|
`include: { _count }` (or similar relation-count) access into a
|
||||||
|
row-level-secured table; nothing added by this task changes that. This is
|
||||||
|
exactly the category of finding the project's own WINDOWS ledger exists to
|
||||||
|
track (compare entries #24, #19, #25, #26 in `.planning/WINDOWS.md`, all of
|
||||||
|
which record a persisting structural gap rather than a fixed one-off).
|
||||||
|
The plan did document the gap thoroughly (measured lists of all 19
|
||||||
|
`include:` and all `_count` sites, judged individually, in (n4)(b) and the
|
||||||
|
Bestandsaufnahme head) and carried it in the threat register as T-E2S-09
|
||||||
|
(medium, accept) — so this is not a hidden risk, just an un-ledgered one.
|
||||||
|
This does not affect the phase's must-have truths (truth #6 only requires
|
||||||
|
the gap to be *named and measured*, which it is) and is therefore **not a
|
||||||
|
gap** for this task, but is flagged here for a human decision on whether to
|
||||||
|
open a WINDOWS entry going forward.
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
None. All 10 must-have truths verified with adversarial, independently
|
||||||
|
reproduced evidence (including 4 successful red-then-restore falsifications
|
||||||
|
and a live 110/110 probe run against the actual database). Scope is exactly
|
||||||
|
the declared 13-file allow-list. One advisory judgment call is flagged above
|
||||||
|
(WINDOWS ledger entry) — it does not block phase completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Verified: 2026-09-11T09:06:02Z*
|
||||||
|
*Verifier: Claude (gsd-verifier)*
|
||||||
@@ -3096,6 +3096,355 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Migration `20260618112124_auth_multi_tenancy` (Dateiname endet
|
||||||
|
* auf "_auth_multi_tenancy") — die einzige, die `CREATE TABLE "Tenant"` und
|
||||||
|
* den Fremdschluessel `User_tenantId_fkey` enthaelt.
|
||||||
|
*/
|
||||||
|
function readAuthMultiTenancyMigrationSql() {
|
||||||
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_multi_tenancy'))
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
if (dirs.length !== 1) return null;
|
||||||
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet die Spaltennamen aus dem `CREATE TABLE "Tenant" ( ... );`-Block
|
||||||
|
* der Migration — NICHT aus `readSchemaModelFieldNames('Tenant')` (Befund M):
|
||||||
|
* `schema.prisma` fuehrt bei `Tenant` vier Relationsfelder (`users`,
|
||||||
|
* `ldapConfig`, `groups`, `moduleGrants`), die keine Spalten sind und die
|
||||||
|
* Client-Vergleichspruefung faelschlich durchfallen liessen.
|
||||||
|
*/
|
||||||
|
function readTenantCreateTableColumns(migrationSql) {
|
||||||
|
const match = migrationSql.match(/CREATE TABLE "Tenant" \(([\s\S]*?)\n\);/);
|
||||||
|
if (!match) return [];
|
||||||
|
const columns = [];
|
||||||
|
for (const rawLine of match[1].split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('CONSTRAINT')) continue;
|
||||||
|
const m = line.match(/^"([a-zA-Z]+)"/);
|
||||||
|
if (m) columns.push(m[1]);
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet `ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" ...;`
|
||||||
|
* wortgleich aus der Migration — nicht getippt (Aufgabe 1, TEIL 1).
|
||||||
|
*/
|
||||||
|
function readUserTenantForeignKeySql(migrationSql) {
|
||||||
|
const match = migrationSql.match(
|
||||||
|
/ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey"[\s\S]*?;/,
|
||||||
|
);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgabe 1 (260911-e2s) — misst die neun im Plan genannten
|
||||||
|
* Verhaltensweisen des Bereichs `tenant` unter der Rolle ohne BYPASSRLS. Auf
|
||||||
|
* `Tenant` selbst ist nichts zu binden (keine Regel in irgendeiner
|
||||||
|
* ausgelieferten Migration, einschliesslich `20260910120000_...` — Pruefung
|
||||||
|
* 1) — dieser Abschnitt hat trotzdem neun Pruefungen, weil drei der acht
|
||||||
|
* Zugriffsstellen des Controllers ueber eine Relationseinbindung
|
||||||
|
* (`include: { _count: { select: { users } } }`) in die GESCHUETZTE Tabelle
|
||||||
|
* "User" hineinzaehlen (Befund F).
|
||||||
|
*
|
||||||
|
* Setzt auf den bereits vorhandenen Wegwerf-Tabellen "Tenant" (aus
|
||||||
|
* `runUserAreaChecks`, dort nur `id`/`slug`) und "User" (aus
|
||||||
|
* `runAuthLookupChecks`, mit Zeilenschutz und wortgleicher Regel) auf und
|
||||||
|
* erweitert "Tenant" um die vier fehlenden Spalten sowie den Fremdschluessel
|
||||||
|
* `User_tenantId_fkey` — keine spaetere Pruefung setzt auf diesen
|
||||||
|
* Erweiterungen auf (Befund M: `runTransactionShapeMeasurement` und
|
||||||
|
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an). Muss deshalb
|
||||||
|
* NACH `runCalendarAreaChecks()` und VOR `runTransactionShapeMeasurement()`
|
||||||
|
* laufen (siehe Aufrufkette in main()).
|
||||||
|
*/
|
||||||
|
async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||||
|
// Pruefung 1: keine Regel auf "Tenant" in irgendeiner ausgelieferten
|
||||||
|
// Migration — liest jede Datei zur Laufzeit, statt der Dokumentation zu
|
||||||
|
// glauben.
|
||||||
|
const migrationDirNames = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
const violatingMigrations = [];
|
||||||
|
for (const dirName of migrationDirNames) {
|
||||||
|
const sql = readFileSync(join(MIGRATIONS_DIR, dirName, 'migration.sql'), 'utf-8');
|
||||||
|
if (
|
||||||
|
/CREATE POLICY \w+ ON "Tenant"/.test(sql) ||
|
||||||
|
/ALTER TABLE "Tenant"/.test(sql)
|
||||||
|
) {
|
||||||
|
violatingMigrations.push(dirName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const widenMigrationDirName = migrationDirNames.find((d) =>
|
||||||
|
d.endsWith('_rls_widen_membership_grant_and_platform_read'),
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-keine-regel-in-allen-ausgelieferten-migrationen',
|
||||||
|
violatingMigrations.length === 0 && Boolean(widenMigrationDirName),
|
||||||
|
`${migrationDirNames.length} Migrationsverzeichnisse gelesen, darunter "${widenMigrationDirName ?? 'NICHT GEFUNDEN'}" — "Tenant" kommt darin nicht vor; ${violatingMigrations.length} Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": ${JSON.stringify(violatingMigrations)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authMultiTenancySql = readAuthMultiTenancyMigrationSql();
|
||||||
|
const tenantColumnsFromMigration = authMultiTenancySql
|
||||||
|
? readTenantCreateTableColumns(authMultiTenancySql)
|
||||||
|
: [];
|
||||||
|
const userTenantFkSql = authMultiTenancySql
|
||||||
|
? readUserTenantForeignKeySql(authMultiTenancySql)
|
||||||
|
: null;
|
||||||
|
if (!authMultiTenancySql || tenantColumnsFromMigration.length === 0 || !userTenantFkSql) {
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-migration-auth-multi-tenancy-und-fremdschluessel-gefunden',
|
||||||
|
false,
|
||||||
|
`Migration *_auth_multi_tenancy=${Boolean(authMultiTenancySql)}, CREATE TABLE "Tenant"-Spalten=${tenantColumnsFromMigration.length}, User_tenantId_fkey gefunden=${Boolean(userTenantFkSql)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||||
|
// (a) Vier fehlende Spalten, Typen aus dem ausgelieferten
|
||||||
|
// `CREATE TABLE "Tenant"` (20260618112124_auth_multi_tenancy).
|
||||||
|
// ABWEICHUNG: fuer "name" und "updatedAt" braucht das Nachruesten gegen
|
||||||
|
// die beiden bereits vorhandenen Zeilen (TENANT-A/TENANT-B, angelegt von
|
||||||
|
// runUserAreaChecks) einen DEFAULT, den die ausgelieferte Migration
|
||||||
|
// selbst nicht hat (dort NOT NULL ohne DEFAULT) — betrifft nur dieses
|
||||||
|
// Nachruesten hier, keine Aussage ueber den ausgelieferten Stand.
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "Tenant" ADD COLUMN "name" TEXT NOT NULL DEFAULT 'Platzhalter';`,
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;`,
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "Tenant" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "Tenant" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// (b) Fremdschluessel wortgleich aus der Migration geschnitten (oben),
|
||||||
|
// nicht getippt — die vier vorhandenen "User"-Zeilen referenzieren
|
||||||
|
// ausschliesslich TENANT-A/TENANT-B, beide existieren bereits.
|
||||||
|
await db.$executeRawUnsafe(userTenantFkSql);
|
||||||
|
|
||||||
|
// (c) Dritte Mandantenzeile ohne Benutzer.
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`INSERT INTO "Tenant" (id, slug, name) VALUES ('TENANT-C', 'tenant-c', 'Tenant C');`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||||
|
try {
|
||||||
|
// Pruefung 2: gebunden (TENANT-A), ungebunden und ueber die Wartungsrolle
|
||||||
|
// liefern DIESELBEN drei Kennungen — der Beleg "nichts zu binden".
|
||||||
|
const boundIdsRaw = (
|
||||||
|
await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`)
|
||||||
|
).map((r) => r.id);
|
||||||
|
const unboundIdsRaw = (await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map(
|
||||||
|
(r) => r.id,
|
||||||
|
);
|
||||||
|
const adminIdsRaw = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => (await db.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map((r) => r.id),
|
||||||
|
);
|
||||||
|
const allIdenticalRaw =
|
||||||
|
JSON.stringify(boundIdsRaw) === JSON.stringify(unboundIdsRaw) &&
|
||||||
|
JSON.stringify(unboundIdsRaw) === JSON.stringify(adminIdsRaw);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen',
|
||||||
|
allIdenticalRaw,
|
||||||
|
`Roh-SQL gebunden (TENANT-A): ${JSON.stringify(boundIdsRaw)}; ungebunden: ${JSON.stringify(unboundIdsRaw)}; Wartungsrolle: ${JSON.stringify(adminIdsRaw)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pruefung 3 — steht VOR den Client-Pruefungen (4-9); faellt sie durch,
|
||||||
|
// bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich `calendar`).
|
||||||
|
const migrationColumnsSorted = [...tenantColumnsFromMigration].sort();
|
||||||
|
const tableColumns = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRaw`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'Tenant'
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.column_name).sort();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const columnsMatch =
|
||||||
|
migrationColumnsSorted.length > 0 &&
|
||||||
|
migrationColumnsSorted.length === tableColumns.length &&
|
||||||
|
migrationColumnsSorted.every((f, i) => f === tableColumns[i]);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
|
||||||
|
columnsMatch,
|
||||||
|
`Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (${migrationColumnsSorted.length}): ${JSON.stringify(migrationColumnsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||||
|
);
|
||||||
|
if (!columnsMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 4: derselbe Vergleich ueber den generierten Client.
|
||||||
|
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||||
|
const clientBoundIds = (await bound.tenant.findMany({ orderBy: { id: 'asc' } })).map(
|
||||||
|
(t) => t.id,
|
||||||
|
);
|
||||||
|
const clientUnboundIds = (await prisma.tenant.findMany({ orderBy: { id: 'asc' } })).map(
|
||||||
|
(t) => t.id,
|
||||||
|
);
|
||||||
|
const clientIdsMatch =
|
||||||
|
JSON.stringify(clientBoundIds) === JSON.stringify(boundIdsRaw) &&
|
||||||
|
JSON.stringify(clientUnboundIds) === JSON.stringify(boundIdsRaw);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch',
|
||||||
|
clientIdsMatch,
|
||||||
|
`generierter Client gebunden (TENANT-A): ${JSON.stringify(clientBoundIds)}; ungebunden: ${JSON.stringify(clientUnboundIds)}; Roh-SQL-Vergleichswert (Pruefung 2): ${JSON.stringify(boundIdsRaw)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wartungszahl je Mandant, fuer Pruefung 5/6/9.
|
||||||
|
const adminUserCountRows = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) =>
|
||||||
|
db.$queryRaw`SELECT "tenantId", count(*)::int AS c FROM "User" GROUP BY "tenantId"`,
|
||||||
|
);
|
||||||
|
const adminUserCounts = new Map(adminUserCountRows.map((r) => [r.tenantId, r.c]));
|
||||||
|
|
||||||
|
// Pruefung 5 — die tragende Belegzeile: die Abfrage, die `findAll`
|
||||||
|
// heute stellt, UNGEBUNDEN auf dem generierten Client: jeder Zaehler ist
|
||||||
|
// 0, waehrend die Wartungsrolle je Mandant mehr als 0 zaehlt.
|
||||||
|
const clientUnboundWithCounts = await prisma.tenant.findMany({
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const allUnboundCountsZero = clientUnboundWithCounts.every((t) => t._count.users === 0);
|
||||||
|
const groundTruthHasPositiveCounts = ['TENANT-A', 'TENANT-B'].every(
|
||||||
|
(id) => (adminUserCounts.get(id) ?? 0) > 0,
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten',
|
||||||
|
allUnboundCountsZero && groundTruthHasPositiveCounts,
|
||||||
|
`das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: ${JSON.stringify(clientUnboundWithCounts.map((t) => ({ id: t.id, userCount: t._count.users })))}; Wartungszahl je Mandant: ${JSON.stringify(Object.fromEntries(adminUserCounts))}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pruefung 6: dieselbe Abfrage gebunden unter TENANT-A.
|
||||||
|
const clientBoundWithCounts = await bound.tenant.findMany({
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const countByIdBound = new Map(clientBoundWithCounts.map((t) => [t.id, t._count.users]));
|
||||||
|
const boundCountsMatchExpectation =
|
||||||
|
countByIdBound.get('TENANT-A') === adminUserCounts.get('TENANT-A') &&
|
||||||
|
countByIdBound.get('TENANT-B') === 0 &&
|
||||||
|
countByIdBound.get('TENANT-C') === 0 &&
|
||||||
|
(adminUserCounts.get('TENANT-B') ?? 0) > 0;
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant',
|
||||||
|
boundCountsMatchExpectation,
|
||||||
|
`gebunden unter TENANT-A: A=${countByIdBound.get('TENANT-A')} (Wartungszahl=${adminUserCounts.get('TENANT-A')}), B=${countByIdBound.get('TENANT-B')} (Wartungszahl=${adminUserCounts.get('TENANT-B') ?? 0}), C=${countByIdBound.get('TENANT-C')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pruefung 7 — die Abfrage, die `remove` heute stellt, ungebunden:
|
||||||
|
// Zaehler 0 trotz aktiver Benutzer bei der Wartungsrolle, der Riegel
|
||||||
|
// T-02-09 liesse das Loeschen durch; das anschliessende ungebundene
|
||||||
|
// `delete` ueber den generierten Client scheitert LAUT am
|
||||||
|
// Fremdschluessel, der den Zeilenschutz umgeht.
|
||||||
|
const removeQueryUnbound = await prisma.tenant.findUnique({
|
||||||
|
where: { id: 'TENANT-A' },
|
||||||
|
include: { _count: { select: { users: { where: { isActive: true } } } } },
|
||||||
|
});
|
||||||
|
const unboundActiveCount = removeQueryUnbound?._count.users;
|
||||||
|
const adminActiveCountA = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows =
|
||||||
|
await db.$queryRaw`SELECT count(*)::int AS c FROM "User" WHERE "tenantId" = 'TENANT-A' AND "isActive" = true`;
|
||||||
|
return rows[0].c;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const gateWouldPassThrough = unboundActiveCount === 0 && adminActiveCountA > 0;
|
||||||
|
|
||||||
|
let deleteThrew = false;
|
||||||
|
let deleteErrCtor = 'unbekannt';
|
||||||
|
let deleteErrCode;
|
||||||
|
let deleteErrMessage = '';
|
||||||
|
try {
|
||||||
|
await prisma.tenant.delete({ where: { id: 'TENANT-A' } });
|
||||||
|
} catch (err) {
|
||||||
|
deleteThrew = true;
|
||||||
|
deleteErrCtor = err?.constructor?.name ?? 'unbekannt';
|
||||||
|
deleteErrCode = err?.code;
|
||||||
|
deleteErrMessage = (err.message ?? '').toString().trim();
|
||||||
|
}
|
||||||
|
const stillExistsAfterDelete = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-A'`;
|
||||||
|
return rows.length === 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut',
|
||||||
|
gateWouldPassThrough && deleteThrew && stillExistsAfterDelete,
|
||||||
|
`ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=${unboundActiveCount}, Wartungszahl=${adminActiveCountA} — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft ${deleteErrCtor}${deleteErrCode ? ` (code ${deleteErrCode})` : ''}: ${deleteErrMessage} — die Zeile existiert ueber die Wartungsrolle danach noch: ${stillExistsAfterDelete}; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pruefung 8 — dasselbe Loeschen fuer TENANT-C (ohne Benutzer) gelingt:
|
||||||
|
// falsifiziert "Loeschen scheitert immer".
|
||||||
|
let deleteCSucceeded = false;
|
||||||
|
let deleteCDetail = '';
|
||||||
|
try {
|
||||||
|
await prisma.tenant.delete({ where: { id: 'TENANT-C' } });
|
||||||
|
deleteCSucceeded = true;
|
||||||
|
deleteCDetail =
|
||||||
|
'ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen';
|
||||||
|
} catch (err) {
|
||||||
|
deleteCDetail = `ungebundenes prisma.tenant.delete fuer TENANT-C ist unerwartet fehlgeschlagen: ${err.message}`;
|
||||||
|
}
|
||||||
|
const goneAfterDeleteC = await withAdminPrisma(
|
||||||
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||||
|
async (db) => {
|
||||||
|
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-C'`;
|
||||||
|
return rows.length === 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-loeschen-ohne-benutzer-gelingt-wie-heute',
|
||||||
|
deleteCSucceeded && goneAfterDeleteC,
|
||||||
|
`${deleteCDetail}; ueber die Wartungsrolle danach noch vorhanden: ${!goneAfterDeleteC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pruefung 9 — die Form, die Aufgabe 3 einbaut: `prisma.tenant.findMany`
|
||||||
|
// ungebunden als Treiber, dann je VERBLIEBENEM Mandanten (TENANT-C ist
|
||||||
|
// seit Pruefung 8 geloescht) ein gebundener Zaehlaufruf.
|
||||||
|
const remainingTenants = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
|
||||||
|
const fanOutResults = [];
|
||||||
|
let fanOutAllMatch = remainingTenants.length > 0;
|
||||||
|
for (const t of remainingTenants) {
|
||||||
|
const boundForT = buildInlineExtendedClient(prisma, t.id);
|
||||||
|
const count = await boundForT.user.count({ where: { tenantId: t.id } });
|
||||||
|
const adminCount = adminUserCounts.get(t.id) ?? 0;
|
||||||
|
fanOutResults.push({ id: t.id, gebunden: count, wartung: adminCount });
|
||||||
|
if (count !== adminCount) fanOutAllMatch = false;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
results,
|
||||||
|
'tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt',
|
||||||
|
fanOutAllMatch,
|
||||||
|
`Fan-out je verbliebenem Mandanten (${remainingTenants.length}): ${JSON.stringify(fanOutResults)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -3333,6 +3682,7 @@ async function main() {
|
|||||||
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import { UserModule } from './user/user.module';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
},
|
},
|
||||||
// Runs after JwtAuthGuard — sets req.tenantId and req.tenantPrisma from req.user
|
// Runs after JwtAuthGuard — sets req.tenantId from req.user (260911-e2s: no longer creates a Prisma client)
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: TenantGuard,
|
useClass: TenantGuard,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
|
|||||||
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
|
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
|
||||||
* @Roles decorator. No route is publicly accessible.
|
* @Roles decorator. No route is publicly accessible.
|
||||||
*
|
*
|
||||||
* Tenant extraction: `req.tenantId` set by TenantMiddleware (runs after auth guards).
|
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
|
||||||
* All operations are scoped to the authenticated tenant's data.
|
* All operations are scoped to the authenticated tenant's data.
|
||||||
*
|
*
|
||||||
* Routes:
|
* Routes:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const MODULE_SLUG_KEY = 'moduleSlug';
|
|||||||
* Gruppen-Grant), D-01.
|
* Gruppen-Grant), D-01.
|
||||||
*
|
*
|
||||||
* Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich
|
* Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich
|
||||||
* aus dem validierten JWT (via TenantMiddleware/JwtAuthGuard), nie aus
|
* aus dem validierten JWT (via TenantGuard/JwtAuthGuard), nie aus
|
||||||
* Body oder Params — verhindert Elevation of Privilege.
|
* Body oder Params — verhindert Elevation of Privilege.
|
||||||
*
|
*
|
||||||
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
|
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -43,17 +43,27 @@ const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
|
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
|
||||||
* `const <Name> = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen
|
* `const <Name> = forTenant(`-Zuweisungsform folgt.
|
||||||
* den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`)
|
*
|
||||||
* statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die
|
* Bis 260911-e2s standen hier zwei Dateien (`tenant.middleware.ts`,
|
||||||
* offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md
|
* `tenant.guard.ts`): beide veroeffentlichten einen gebundenen Client auf
|
||||||
* ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt
|
* dem Anfrageobjekt statt ihn einer lokalen Konstante zuzuweisen — der Weg
|
||||||
* stillschweigend als Erkennungsluecke durchzurutschen.
|
* war die offene Architekturfrage aus
|
||||||
|
* docs/mandantentrennung-zugriffsklassifikation.md ("Was diese Etappe NICHT
|
||||||
|
* entscheidet").
|
||||||
|
*
|
||||||
|
* Die Frage ist mit 260911-e2s (Aufgabe 2) ENTSCHIEDEN: die
|
||||||
|
* dienst-interne Bindung (ein Klient je Methode, wie es alle neun vor
|
||||||
|
* diesem Bereich umgestellten Bereiche bereits vormachen) ist die
|
||||||
|
* Konvention; der Guard erzeugt ueberhaupt keinen Client mehr, die
|
||||||
|
* gleichlautende, nie verdrahtete Middleware ist geloescht. Diese Liste
|
||||||
|
* startet deshalb leer und bleibt es, bis ein begruendeter neuer
|
||||||
|
* Ausnahmefall auftritt — dieselbe Form wie
|
||||||
|
* `INTERACTIVE_TRANSACTION_EXCEPTIONS` unten. Der Test
|
||||||
|
* "keine veraltete Ausnahmeliste" unter dieser Datei stellt sicher, dass ein
|
||||||
|
* kuenftiger Eintrag nicht unbemerkt veraltet.
|
||||||
*/
|
*/
|
||||||
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
|
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
|
||||||
'apps/api/src/tenant/tenant.middleware.ts',
|
|
||||||
'apps/api/src/tenant/tenant.guard.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
|
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
|
||||||
@@ -347,6 +357,28 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
|||||||
expect(violations, violations.join('\n')).toEqual([]);
|
expect(violations, violations.join('\n')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keine veraltete Ausnahmeliste: jede Datei in [...FORTENANT_ASSIGNMENT_EXCEPTIONS] existiert und traegt tatsaechlich mindestens einen forTenant(-Aufruf ausserhalb der Zuweisungsform (260911-e2s, Aufgabe 2)', () => {
|
||||||
|
const staleEntries: string[] = [];
|
||||||
|
const analysesByFile = new Map(analyses.map((a) => [a.file, a]));
|
||||||
|
for (const file of [...FORTENANT_ASSIGNMENT_EXCEPTIONS]) {
|
||||||
|
if (!existsSync(join(REPO_ROOT, file))) {
|
||||||
|
staleEntries.push(`${file}: Datei existiert nicht mehr`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const analysis = analysesByFile.get(file);
|
||||||
|
const unmatched = analysis ? analysis.totalForTenantCalls - analysis.assignmentFormCalls : 0;
|
||||||
|
if (unmatched <= 0) {
|
||||||
|
staleEntries.push(
|
||||||
|
`${file}: enthaelt keinen forTenant(-Aufruf ausserhalb der erkannten Zuweisungsform mehr — die Ausnahme ist ueberholt und gehoert entfernt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
staleEntries,
|
||||||
|
`Eine Ausnahmeliste, die Dateien nennt, die es nicht gibt oder die keinen Ausnahmefall mehr enthalten, ist dieselbe tote Verdrahtung, die 260911-e2s im Guard entfernt hat:\n${staleEntries.join('\n')}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
|
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
for (const a of analyses) {
|
for (const a of analyses) {
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { TenantController } from './tenant.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenantController.spec — legt die Testlage fuer diesen Bereich aus dem
|
||||||
|
* Nichts an (260911-e2s, Aufgabe 3, Befund I: vorher gab es nur
|
||||||
|
* `tenant.service.spec.ts` mit zwei Faellen zu `create`).
|
||||||
|
*
|
||||||
|
* Zwei-Klienten-Nachweis (Muster aus `../dkv/dkv.service.spec.ts`):
|
||||||
|
* `__makeBoundClient(tenantId)` bietet ein `user.count`-Modell, das
|
||||||
|
* zusaetzlich nach der Mandantenkennung filtert und jeden Aufruf in ein
|
||||||
|
* Bindungsprotokoll schreibt. Der UNGEBUNDENE Nachbau (`prisma.tenant.*`)
|
||||||
|
* hat absichtlich KEIN `user`-Modell — ein versehentlich ungebundener
|
||||||
|
* Zaehler scheitert dadurch mit "Cannot read properties of undefined"
|
||||||
|
* (die `dkv`-Form der Falsifizierung), nicht mit einer nur falschen Zahl.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface FakeTenantRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeUserRow {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePrisma(tenantRows: FakeTenantRow[], userRows: FakeUserRow[]) {
|
||||||
|
const tenants = new Map(tenantRows.map((t) => [t.id, { ...t }]));
|
||||||
|
const users = [...userRows];
|
||||||
|
const boundCallLog: { tenantId: string; model: string; method: string; where: any }[] = [];
|
||||||
|
|
||||||
|
const tenantModel = {
|
||||||
|
findMany: vi.fn(async ({ orderBy }: { orderBy?: { name?: 'asc' | 'desc' } } = {}) => {
|
||||||
|
const rows = [...tenants.values()];
|
||||||
|
if (orderBy?.name === 'asc') rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => tenants.get(where.id) ?? null),
|
||||||
|
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
const row = tenants.get(where.id) ?? null;
|
||||||
|
tenants.delete(where.id);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fake: any = {
|
||||||
|
tenant: tenantModel,
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
count: async ({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: { tenantId: string; isActive?: boolean };
|
||||||
|
}) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'user', method: 'count', where });
|
||||||
|
return users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.tenantId === where.tenantId &&
|
||||||
|
(where.isActive === undefined || u.isActive === where.isActive),
|
||||||
|
).length;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||||
|
const found = prisma.__boundCallLog.some(
|
||||||
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeTenantService() {
|
||||||
|
return {
|
||||||
|
create: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TENANT_A: FakeTenantRow = {
|
||||||
|
id: 'TENANT-A',
|
||||||
|
name: 'A GmbH',
|
||||||
|
slug: 'tenant-a',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
};
|
||||||
|
const TENANT_B: FakeTenantRow = {
|
||||||
|
id: 'TENANT-B',
|
||||||
|
name: 'B GmbH',
|
||||||
|
slug: 'tenant-b',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-02'),
|
||||||
|
};
|
||||||
|
const TENANT_C: FakeTenantRow = {
|
||||||
|
id: 'TENANT-C',
|
||||||
|
name: 'C GmbH',
|
||||||
|
slug: 'tenant-c',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-03'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TenantController.findAll', () => {
|
||||||
|
it('drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner): liefert drei Eintraege mit userCount 2/1/0, genau drei gebundene Zaehlaufrufe je Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A, TENANT_B, TENANT_C],
|
||||||
|
[
|
||||||
|
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-a2', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'TENANT-A', userCount: 2 }),
|
||||||
|
expect.objectContaining({ id: 'TENANT-B', userCount: 1 }),
|
||||||
|
expect.objectContaining({ id: 'TENANT-C', userCount: 0 }),
|
||||||
|
]);
|
||||||
|
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(3);
|
||||||
|
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
|
||||||
|
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
|
||||||
|
expectBoundCall(prisma, 'TENANT-C', 'user', 'count');
|
||||||
|
for (const call of prisma.__boundCallLog) {
|
||||||
|
expect(call.where.tenantId).toBe(call.tenantId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Antwort traegt genau die Felder id, name, slug, isActive, createdAt, userCount', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const [entry] = await controller.findAll();
|
||||||
|
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(
|
||||||
|
['createdAt', 'id', 'isActive', 'name', 'slug', 'userCount'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.findOne', () => {
|
||||||
|
it('unbekannte Kennung: NotFoundException, KEIN gebundener Klient', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.findOne('unknown')).rejects.toThrow(
|
||||||
|
new NotFoundException('Tenant not found'),
|
||||||
|
);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A, TENANT_B],
|
||||||
|
[
|
||||||
|
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
{ id: 'u-b2', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findOne('TENANT-B');
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ id: 'TENANT-B', userCount: 2 }));
|
||||||
|
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
expect(prisma.__boundCallLog[0].where.tenantId).toBe('TENANT-B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.remove', () => {
|
||||||
|
it('unbekannte Kennung: NotFoundException, kein gebundener Klient, tenant.delete nicht aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.remove('unknown')).rejects.toThrow(
|
||||||
|
new NotFoundException('Tenant not found'),
|
||||||
|
);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
expect(prisma.tenant.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit aktiven Benutzern: BadRequestException mit der heutigen Meldung, tenant.delete NICHT aufgerufen, der gebundene Zaehlaufruf traegt isActive=true', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.remove('TENANT-A')).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.tenant.delete).not.toHaveBeenCalled();
|
||||||
|
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
|
||||||
|
expect(prisma.__boundCallLog[0].where).toEqual({ tenantId: 'TENANT-A', isActive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit ausschliesslich inaktiven Benutzern: der Riegel laesst durch, tenant.delete wird ungebunden mit { where: { id } } aufgerufen, Antwort { message: "Tenant deleted" } (heutiges Verhalten — der Fremdschluessel, der das in der echten Datenbank abfaengt, existiert im Nachbau nicht)', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: false }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.remove('TENANT-A');
|
||||||
|
|
||||||
|
expect(result).toEqual({ message: 'Tenant deleted' });
|
||||||
|
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-A' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Benutzer: geloescht, Antwort wie oben', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_C], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.remove('TENANT-C');
|
||||||
|
|
||||||
|
expect(result).toEqual({ message: 'Tenant deleted' });
|
||||||
|
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-C' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.create / update — delegieren an die Dienst-Attrappe', () => {
|
||||||
|
it('create: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus, delegiert an den Dienst', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const tenantService = makeFakeTenantService();
|
||||||
|
tenantService.create.mockResolvedValue(TENANT_A);
|
||||||
|
const controller = new TenantController(tenantService, prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.create({ name: 'A GmbH', slug: 'tenant-a' } as any);
|
||||||
|
|
||||||
|
expect(result).toBe(TENANT_A);
|
||||||
|
expect(tenantService.create).toHaveBeenCalledWith({ name: 'A GmbH', slug: 'tenant-a' });
|
||||||
|
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.tenant.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus (ausser der Existenzpruefung ueber den Dienst), delegiert an den Dienst', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const tenantService = makeFakeTenantService();
|
||||||
|
tenantService.findById.mockResolvedValue(TENANT_A);
|
||||||
|
tenantService.update.mockResolvedValue({ ...TENANT_A, name: 'Neuer Name' });
|
||||||
|
const controller = new TenantController(tenantService, prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.update('TENANT-A', { name: 'Neuer Name' });
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ name: 'Neuer Name' }));
|
||||||
|
expect(tenantService.update).toHaveBeenCalledWith('TENANT-A', {
|
||||||
|
name: 'Neuer Name',
|
||||||
|
isActive: undefined,
|
||||||
|
});
|
||||||
|
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController — Rollen-Metadaten (Befund E, T-E2S-01)', () => {
|
||||||
|
it('klassenweit ist genau [Role.SUPER_ADMIN] gesetzt', () => {
|
||||||
|
const roles = Reflect.getMetadata(ROLES_KEY, TenantController);
|
||||||
|
expect(roles).toEqual([Role.SUPER_ADMIN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['findAll', 'findOne', 'create', 'update', 'remove'] as const)(
|
||||||
|
'Handler %s traegt KEINE eigene Rollenmetadaten — eine schwaechere Handler-Rolle wuerde die Klassenrolle via getAllAndOverride ueberschreiben',
|
||||||
|
(handlerName) => {
|
||||||
|
const handlerRoles = Reflect.getMetadata(
|
||||||
|
ROLES_KEY,
|
||||||
|
(TenantController.prototype as any)[handlerName],
|
||||||
|
);
|
||||||
|
expect(handlerRoles).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController — Wachhund: hoechstens ein gebundener Klient je Aufruf und Mandant', () => {
|
||||||
|
it('findOne erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.findOne('TENANT-A');
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.remove('TENANT-A');
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findAll erzeugt genau so viele gebundene Klienten wie Mandanten vorhanden sind', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A, TENANT_B, TENANT_C], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.findAll();
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
@@ -21,6 +22,30 @@ import { TenantService } from './tenant.service';
|
|||||||
* Tenant CRUD controller.
|
* Tenant CRUD controller.
|
||||||
* D-10: Only Super-Admin can manage tenants.
|
* D-10: Only Super-Admin can manage tenants.
|
||||||
* T-02-09: Tenant deletion blocked if active users exist.
|
* T-02-09: Tenant deletion blocked if active users exist.
|
||||||
|
*
|
||||||
|
* User counts (260911-e2s, Aufgabe 3): `findAll`/`findOne`/`remove` used to
|
||||||
|
* read the user count through a relation include on the four unbound
|
||||||
|
* tenant reads below. Prisma renders that as a single statement with a
|
||||||
|
* LEFT JOIN into the protected `User` table — which carries a row-level
|
||||||
|
* security rule. After the switch flips, an unbound relation count reads
|
||||||
|
* zero for every tenant (measured, 260911-e2s Aufgabe 1, Pruefungen 5-7),
|
||||||
|
* which would make the platform-admin tenant list show zero users
|
||||||
|
* everywhere and let the delete gate below pass through with active users
|
||||||
|
* still present. Fixed by the fan-out pattern `UserService
|
||||||
|
* .findAllForPlatformAdmin` already uses: read tenants unbound, then bind
|
||||||
|
* ONE user count per tenant via the tenant-binding helper. The explicit
|
||||||
|
* `where: { tenantId }` on each bound count is TODAY (role runs with
|
||||||
|
* BYPASSRLS, WINDOWS #18) the only filter actually in effect.
|
||||||
|
*
|
||||||
|
* The four tenant reads/writes below stay unbound on purpose — `Tenant`
|
||||||
|
* carries no row-level security rule in any shipped migration (measured,
|
||||||
|
* 260911-e2s Aufgabe 1, Pruefungen 1/2); nothing on `Tenant` itself needs
|
||||||
|
* binding.
|
||||||
|
*
|
||||||
|
* This controller keeps talking to Prisma directly rather than going
|
||||||
|
* through a service method (same pattern as `user.controller.ts`) — a
|
||||||
|
* deliberate choice, not an oversight; see
|
||||||
|
* docs/mandantentrennung-zugriffsklassifikation.md, "(n5)".
|
||||||
*/
|
*/
|
||||||
@Controller('tenants')
|
@Controller('tenants')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -38,22 +63,26 @@ export class TenantController {
|
|||||||
@Get()
|
@Get()
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const tenants = await this.prisma.tenant.findMany({
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { users: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return tenants.map((t: any) => ({
|
const results: any[] = [];
|
||||||
id: t.id,
|
for (const tenant of tenants) {
|
||||||
name: t.name,
|
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||||
slug: t.slug,
|
const userCount = await tenantPrisma.user.count({
|
||||||
isActive: t.isActive,
|
where: { tenantId: tenant.id },
|
||||||
createdAt: t.createdAt,
|
});
|
||||||
userCount: t._count.users,
|
results.push({
|
||||||
}));
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
isActive: tenant.isActive,
|
||||||
|
createdAt: tenant.createdAt,
|
||||||
|
userCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,24 +93,24 @@ export class TenantController {
|
|||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { users: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new NotFoundException('Tenant not found');
|
throw new NotFoundException('Tenant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||||
|
const userCount = await tenantPrisma.user.count({
|
||||||
|
where: { tenantId: id },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
name: tenant.name,
|
name: tenant.name,
|
||||||
slug: tenant.slug,
|
slug: tenant.slug,
|
||||||
isActive: tenant.isActive,
|
isActive: tenant.isActive,
|
||||||
createdAt: tenant.createdAt,
|
createdAt: tenant.createdAt,
|
||||||
userCount: tenant._count.users,
|
userCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,20 +154,18 @@ export class TenantController {
|
|||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
users: { where: { isActive: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new NotFoundException('Tenant not found');
|
throw new NotFoundException('Tenant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenant._count.users > 0) {
|
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||||
|
const activeUserCount = await tenantPrisma.user.count({
|
||||||
|
where: { tenantId: id, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeUserCount > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TenantGuard } from './tenant.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenantGuard.canActivate — legt die Testlage fuer diesen Bereich aus dem
|
||||||
|
* Nichts an (260911-e2s, Aufgabe 2, Befund I: es gab vorher KEINE Testdatei
|
||||||
|
* fuer Guard oder Middleware). Muster fuer `makeContext` wie in
|
||||||
|
* `../module-registry/module.guard.spec.ts`.
|
||||||
|
*
|
||||||
|
* Der Guard wird OHNE Argumente konstruiert (`new TenantGuard()`) — das ist
|
||||||
|
* zugleich die Typpruefungs-Aussage, dass er keine Prisma-Abhaengigkeit mehr
|
||||||
|
* hat (260911-e2s, Aufgabe 2).
|
||||||
|
*
|
||||||
|
* Jeder durchlassende Fall prueft zusaetzlich, dass die alte
|
||||||
|
* Anfrageobjekt-Eigenschaft NICHT als Schluessel auf dem Anfrageobjekt
|
||||||
|
* vorhanden ist (`'tenantPrisma' in req`) — ueber den `in`-Operator, nicht
|
||||||
|
* ueber einen Property-Zugriff mit Punkt, weil das Gate dieser Aufgabe den
|
||||||
|
* Punkt-Zugriff im gesamten apps/api/src auf null zaehlt, Kommentare
|
||||||
|
* eingeschlossen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeContext(request: any) {
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TenantGuard.canActivate', () => {
|
||||||
|
it('kein req.user (oeffentliche Route): liefert true, weder tenantId noch die alte Anfrageobjekt-Eigenschaft sind gesetzt', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = {};
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect('tenantId' in req).toBe(false);
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nutzer der Rolle USER mit tenantId, keine Kopfzeile: true, req.tenantId === die eigene Kennung', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = { user: { role: 'USER', tenantId: 't1' }, headers: {} };
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(req.tenantId).toBe('t1');
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nutzer der Rolle ADMIN mit tenantId UND x-tenant-id-Kopfzeile: die Kopfzeile wird IGNORIERT, req.tenantId bleibt die eigene Kennung (T-04-03)', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = {
|
||||||
|
user: { role: 'ADMIN', tenantId: 't1' },
|
||||||
|
headers: { 'x-tenant-id': 't2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(req.tenantId).toBe('t1');
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SUPER_ADMIN mit tenantId und x-tenant-id-Kopfzeile: der Wechsel gelingt, req.tenantId === der Header-Wert (D-10)', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = {
|
||||||
|
user: { role: 'SUPER_ADMIN', tenantId: 't1' },
|
||||||
|
headers: { 'x-tenant-id': 't2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(req.tenantId).toBe('t2');
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SUPER_ADMIN mit tenantId, ohne Kopfzeile: req.tenantId === die eigene Kennung', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = { user: { role: 'SUPER_ADMIN', tenantId: 't1' }, headers: {} };
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(req.tenantId).toBe('t1');
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SUPER_ADMIN ohne tenantId und ohne Kopfzeile: true, req.tenantId === null (heutiges Verhalten, mit dem heutigen Sitzungsnachweis unerreichbar, trotzdem festgenagelt)', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = { user: { role: 'SUPER_ADMIN' }, headers: {} };
|
||||||
|
|
||||||
|
const result = guard.canActivate(makeContext(req));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(req.tenantId).toBeNull();
|
||||||
|
expect('tenantPrisma' in req).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nutzer der Rolle USER ohne tenantId: wirft ForbiddenException("No tenant context")', () => {
|
||||||
|
const guard = new TenantGuard();
|
||||||
|
const req: any = { user: { role: 'USER' }, headers: {} };
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(makeContext(req))).toThrow(
|
||||||
|
new ForbiddenException('No tenant context'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,20 +4,32 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs AFTER JwtAuthGuard (guard execution order follows APP_GUARD registration order).
|
* Runs AFTER JwtAuthGuard (guard execution order follows APP_GUARD registration order).
|
||||||
* At this point req.user is populated — middleware ran too early to access it.
|
* At this point req.user is populated — the order is load-bearing.
|
||||||
*
|
*
|
||||||
* Sets req.tenantId and req.tenantPrisma for downstream controllers.
|
* Sets ONLY req.tenantId for downstream code. Super-Admin can override the
|
||||||
* Super-Admin can override tenant via x-tenant-id header (D-10).
|
* tenant via the x-tenant-id header (D-10).
|
||||||
|
*
|
||||||
|
* DECISION (260911-e2s, Aufgabe 2): an earlier design also published a
|
||||||
|
* tenant-scoped Prisma client on the request object, under a property
|
||||||
|
* named `tenantPrisma` (assigned via `forTenant(...)`), duplicated
|
||||||
|
* identically in a never-registered Express middleware class with the same
|
||||||
|
* logic (deleted with 260911-e2s). A full-text search across
|
||||||
|
* `apps/api/src` found no reader of that property outside those two
|
||||||
|
* files — every one of the nine areas converted before this one binds
|
||||||
|
* service-internally instead, one client per method call via the
|
||||||
|
* tenant-binding helper in
|
||||||
|
* `prisma-tenant.extension.ts`. That convention, settled by nine-fold
|
||||||
|
* practice, is why this guard no longer creates a client at all: dead
|
||||||
|
* wiring that LOOKS like a protection mechanism is worse than none — it
|
||||||
|
* suggests a safeguard to a later reader that never actually ran. See
|
||||||
|
* docs/mandantentrennung-etappe2-fehlerrichtung.md, section "## Bereich
|
||||||
|
* tenant", (n4)(a) for the measurement and the reasoning.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantGuard implements CanActivate {
|
export class TenantGuard implements CanActivate {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
@@ -38,10 +50,8 @@ export class TenantGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
req.tenantPrisma = forTenant(this.prisma, tenantId);
|
|
||||||
req.tenantId = tenantId;
|
req.tenantId = tenantId;
|
||||||
} else {
|
} else {
|
||||||
req.tenantPrisma = this.prisma;
|
|
||||||
req.tenantId = null;
|
req.tenantId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
NestMiddleware,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts tenantId from the authenticated user's JWT claim and creates
|
|
||||||
* a tenant-scoped Prisma client for the request.
|
|
||||||
*
|
|
||||||
* Super-Admin can switch tenant context via x-tenant-id header (D-10).
|
|
||||||
* Per D-08: Tenant context from JWT, no URL-based routing.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
|
||||||
constructor(private prisma: PrismaService) {}
|
|
||||||
|
|
||||||
use(req: Request, res: Response, next: NextFunction) {
|
|
||||||
const user = (req as any).user;
|
|
||||||
|
|
||||||
// No user means public route (e.g., login, health) - skip tenant context
|
|
||||||
if (!user) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine tenant ID
|
|
||||||
let tenantId: string | undefined = user.tenantId;
|
|
||||||
|
|
||||||
// Super-Admin can switch tenant via header
|
|
||||||
if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) {
|
|
||||||
tenantId = req.headers['x-tenant-id'] as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-Super-Admin users MUST have a tenant
|
|
||||||
if (!tenantId && user.role !== 'SUPER_ADMIN') {
|
|
||||||
throw new ForbiddenException('No tenant context');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach tenant-scoped Prisma client
|
|
||||||
if (tenantId) {
|
|
||||||
(req as any).tenantPrisma = forTenant(this.prisma, tenantId);
|
|
||||||
(req as any).tenantId = tenantId;
|
|
||||||
} else {
|
|
||||||
// Super-Admin without tenant header gets unscoped access
|
|
||||||
(req as any).tenantPrisma = this.prisma;
|
|
||||||
(req as any).tenantId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -145,13 +145,13 @@ Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem).
|
|||||||
zeigt intern auf `http://api:3001`) auf.
|
zeigt intern auf `http://api:3001`) auf.
|
||||||
2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann
|
2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann
|
||||||
durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser
|
durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser
|
||||||
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`/`req.tenantPrisma`
|
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`
|
||||||
aus dem JWT) → `RolesGuard` (prüft `@Roles()`).
|
aus dem JWT) → `RolesGuard` (prüft `@Roles()`).
|
||||||
3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard`
|
3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard`
|
||||||
(`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`.
|
(`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`.
|
||||||
4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService`
|
4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService`
|
||||||
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über den
|
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen
|
||||||
tenant-gescopten Client aus `req.tenantPrisma` auf Postgres zugreift.
|
dienst-intern per `forTenant()` gebundenen Client auf Postgres zugreift.
|
||||||
5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder
|
5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder
|
||||||
Client-Komponente.
|
Client-Komponente.
|
||||||
|
|
||||||
@@ -296,16 +296,17 @@ Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
|
|||||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||||
`APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst
|
`APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst
|
||||||
dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt
|
dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt
|
||||||
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend `req.tenantId` sowie
|
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH
|
||||||
`req.tenantPrisma` — einen über `forTenant()`
|
`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je
|
||||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) erzeugten Prisma-Client, der vor **jeder** Query
|
Service-Methode neu, über das Bindungshilfsmittel `forTenant()`
|
||||||
in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt.
|
(`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion
|
||||||
|
`SELECT set_config('app.current_tenant', $1, true)` ausführt — der Guard selbst erzeugt keinen
|
||||||
|
Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt.
|
||||||
|
|
||||||
> Im Code existiert daneben eine gleichnamige `TenantMiddleware`
|
> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem
|
||||||
> (`apps/api/src/tenant/tenant.middleware.ts`) mit identischer Logik. Sie ist in `app.module.ts`
|
> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten
|
||||||
> nirgends über `.apply(...).forRoutes(...)` eingebunden — der tatsächlich aktive Mechanismus ist
|
> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine
|
||||||
> ausschließlich `TenantGuard`. Vereinzelte Code-Kommentare verweisen noch auf „TenantMiddleware“;
|
> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand.
|
||||||
> gemeint ist in jedem Fall der Guard.
|
|
||||||
|
|
||||||
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber
|
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber
|
||||||
**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`,
|
**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`,
|
||||||
@@ -318,12 +319,12 @@ RLS-Policy.
|
|||||||
**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss
|
**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss
|
||||||
`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das
|
`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das
|
||||||
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
||||||
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain `PrismaService` (nicht
|
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und
|
||||||
`req.tenantPrisma`) und filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle
|
filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter
|
||||||
das `tenantId`-Filter vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das
|
vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben
|
||||||
auffängt. Bei den sieben RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich,
|
RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
|
||||||
vorausgesetzt die Query läuft tatsächlich über den `tenantPrisma`-Client aus `req.tenantPrisma`
|
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
|
||||||
und nicht über den globalen `PrismaService`.
|
den globalen, ungebundenen `PrismaService`.
|
||||||
|
|
||||||
## Berechtigungen
|
## Berechtigungen
|
||||||
|
|
||||||
|
|||||||
@@ -2259,6 +2259,217 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
|||||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||||
Schemaänderung in dieser Etappe.
|
Schemaänderung in dieser Etappe.
|
||||||
|
|
||||||
|
## Bereich tenant
|
||||||
|
|
||||||
|
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenant`
|
||||||
|
(Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||||
|
Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen,
|
||||||
|
sondern die Mandantentabelle SELBST — `Tenant` hat per Definition keine
|
||||||
|
`tenantId`-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu
|
||||||
|
binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche
|
||||||
|
davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung
|
||||||
|
zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund,
|
||||||
|
den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht
|
||||||
|
Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle
|
||||||
|
`User` hinein (Aufgabe 3).
|
||||||
|
|
||||||
|
### (n1) Die Messung
|
||||||
|
|
||||||
|
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen zwoelften
|
||||||
|
Abschnitt (`runTenantAreaChecks`) erweitert. Sechs der neun neuen Pruefungen
|
||||||
|
(4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT
|
||||||
|
(`prisma.tenant.findMany`/`findUnique`/`delete`, `bound.tenant.findMany`,
|
||||||
|
`bound.user.count`) statt ueber Roh-SQL — bewusst, weil der Relationszaehler
|
||||||
|
(`include: { _count: { select: { users } } }`), den `findAll`/`findOne`
|
||||||
|
tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell
|
||||||
|
nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client").
|
||||||
|
Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten
|
||||||
|
`migration.sql`-Dateien und prueft auf `CREATE POLICY ... ON "Tenant"` sowie
|
||||||
|
`ALTER TABLE "Tenant"` — ausdruecklich EINSCHLIESSLICH
|
||||||
|
`20260910120000_rls_widen_membership_grant_and_platform_read`, die "Tenant"
|
||||||
|
nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11,
|
||||||
|
gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||||
|
|
||||||
|
```
|
||||||
|
tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": []
|
||||||
|
tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"]
|
||||||
|
tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"]
|
||||||
|
tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"]
|
||||||
|
tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2}
|
||||||
|
tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0
|
||||||
|
tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab
|
||||||
|
tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false
|
||||||
|
tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}]
|
||||||
|
Alle 110 Pruefungen bestanden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Belegzeile, die diesen Abschnitt traegt, ist
|
||||||
|
`tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten`
|
||||||
|
(Pruefung 5): dieselbe Abfrage, die `findAll` heute stellt, liefert
|
||||||
|
UNGEBUNDEN fuer JEDEN Mandanten `userCount: 0`, waehrend die Wartungsrolle
|
||||||
|
fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die
|
||||||
|
Zahl, die `admin/tenants/page.tsx` nach dem Scharfschalten anzeigen wuerde.
|
||||||
|
Der Fremdschluessel `User_tenantId_fkey` (Pruefung 7, wortgleich aus Zeile
|
||||||
|
105 von `20260618112124_auth_multi_tenancy` geschnitten) faengt das daraus
|
||||||
|
folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code `P2003`),
|
||||||
|
nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09.
|
||||||
|
|
||||||
|
### (n2) Signaltabelle je Pfad
|
||||||
|
|
||||||
|
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `TenantController.findAll` | Der Relationszaehler (`include: { _count: { select: { users } } }`) laeuft ungebunden auf dem generierten Client (Pruefung 5): `userCount` ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (`Tenant` ohne Regel) | Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste | Ja — `admin/tenants/page.tsx` zeigt `tenant.userCount` ungeprueft an |
|
||||||
|
| `TenantController.findOne` | Dieselbe Form wie `findAll`, fuer eine einzelne Kennung | `userCount: 0` fuer den betrachteten Mandanten | Ja — dieselbe Anzeige (falls einzeln abgefragt) |
|
||||||
|
| `TenantController.create` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.create` | Keins (Controller-Ebene) | — |
|
||||||
|
| `TenantController.update` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.update`, nach ungebundenem `findById` (Tenant ohne Regel, unveraendert) | Keins | — |
|
||||||
|
| `TenantController.remove` | Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: `_count.users` ist 0 (Pruefung 7), der Riegel T-02-09 passiert, `tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey` (`ON DELETE RESTRICT`): SQLSTATE 23503, Prisma-Code `P2003`, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 | Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" | Teilweise — `handleDelete` in `admin/tenants/page.tsx` prueft `res.ok`, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3)) |
|
||||||
|
| `TenantService.findAll` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) | Keins (totes Codeglied) | — |
|
||||||
|
| `TenantService.findById` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt, wird von `TenantController.update` als Existenzpruefung benutzt | Keins | — |
|
||||||
|
| `TenantService.create` | Ungebunden, `Tenant` ohne Regel; ruft danach `groupsService.ensureDefaultGroup(tenant.id)` auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber `forTenant()`/`withTenantTransaction()`, gebunden an den soeben angelegten Mandanten | Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert | — |
|
||||||
|
| `TenantService.update` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt | Keins | — |
|
||||||
|
| `TenantGuard` | Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) | Keins | — |
|
||||||
|
|
||||||
|
### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet
|
||||||
|
|
||||||
|
Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht
|
||||||
|
LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt.
|
||||||
|
|
||||||
|
**Backend:** `TenantController.findAll`/`findOne` liefern `userCount: 0` ohne
|
||||||
|
jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl,
|
||||||
|
die schlicht falsch ist. `TenantController.remove` laesst den Riegel T-02-09
|
||||||
|
passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der
|
||||||
|
Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400,
|
||||||
|
siehe (n1)/(n2)).
|
||||||
|
|
||||||
|
**Frontend**, namentlich mit Stelle:
|
||||||
|
|
||||||
|
- `apps/web/src/app/(portal)/admin/tenants/page.tsx` zeigt `tenant.userCount`
|
||||||
|
ungeprueft in der Tabellenzeile an (Zeile 209: `{tenant.userCount}`).
|
||||||
|
`fetchTenants` (Zeilen 44-55) prueft zwar `res.ok`, aber bei nicht-OK
|
||||||
|
passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste
|
||||||
|
bleibt leer oder veraltet stehen (`catch { // silently fail }`).
|
||||||
|
`handleDelete` (Zeilen 122-133) prueft ebenfalls `res.ok`, aber bei
|
||||||
|
nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der
|
||||||
|
Bestaetigungsdialog (`deleteConfirm`) bleibt offen, `fetchTenants()` wird
|
||||||
|
nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein
|
||||||
|
Knopf, der nicht reagiert, nicht wie ein Fehler.
|
||||||
|
- `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`
|
||||||
|
faengt jede nicht-OK-Antwort in eine LEERE Liste
|
||||||
|
(`.then((res) => (res.ok ? res.json() : []))`) und jeden Netzwerkfehler in
|
||||||
|
ein stilles Nichts (`.catch(() => {})`) — der SUPER_ADMIN sieht im
|
||||||
|
Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne
|
||||||
|
Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine".
|
||||||
|
|
||||||
|
Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen —
|
||||||
|
Zeilennummern koennen sich verschieben.
|
||||||
|
|
||||||
|
### (n4) Was dieser Durchlauf bewusst nicht löst
|
||||||
|
|
||||||
|
**(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft.** Gemessen (Befund B/C
|
||||||
|
der Planung, in Aufgabe 1 wiederholt): `apps/api/src/tenant/tenant.guard.ts`
|
||||||
|
und `apps/api/src/tenant/tenant.middleware.ts` setzen
|
||||||
|
`req.tenantPrisma = forTenant(this.prisma, tenantId)`, aber eine Volltextsuche
|
||||||
|
ueber `apps/api/src` (`grep -rn '\.tenantPrisma'`) findet ausserhalb dieser
|
||||||
|
beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware
|
||||||
|
nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder `apps/api/src`
|
||||||
|
noch `apps/api/src/main.ts` enthalten ein `MiddlewareConsumer`, ein
|
||||||
|
`configure(` oder einen `.apply(...).forRoutes(...)`-Aufruf auf
|
||||||
|
`TenantMiddleware` — `app.module.ts` implementiert kein `NestModule`.
|
||||||
|
ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch
|
||||||
|
`req.tenantId`; `tenant.middleware.ts` ist GELOESCHT (eine nie aufgerufene
|
||||||
|
Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor
|
||||||
|
diesem binden ausnahmslos dienst-intern, ein Klient je Methode
|
||||||
|
(`forTenant(this.prisma, tenantId)` in der jeweiligen Service-Methode) — die
|
||||||
|
Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan
|
||||||
|
neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus
|
||||||
|
AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist
|
||||||
|
schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz,
|
||||||
|
den es nicht gibt.
|
||||||
|
|
||||||
|
**(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G).**
|
||||||
|
`rls-access-inventory.spec.ts` sammelt (Datei, Modell)-Paare ausschliesslich
|
||||||
|
ueber `this.prisma.<Modell>` und `<gebundener Client>.<Modell>` — eine
|
||||||
|
Relationseinbindung (`include:`, Relationszaehler `_count`) in eine ZWEITE
|
||||||
|
Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1
|
||||||
|
erneut vermessen:
|
||||||
|
|
||||||
|
*Alle `_count`-Stellen ausserhalb von `tenant/`* (`grep -rn "_count"
|
||||||
|
apps/api/src --include=*.ts | grep -v spec`): `groups.service.ts:66` (auf
|
||||||
|
bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und
|
||||||
|
`tenders.controller.ts:405` (`groupBy` auf der plattformweiten, ungeschuetzten
|
||||||
|
`Tender`, kein Relationszugriff in eine zweite Tabelle — harmlos).
|
||||||
|
|
||||||
|
*Alle `include:`-Stellen* (`grep -rn "include:" apps/api/src --include=*.ts
|
||||||
|
| grep -v spec`, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt —
|
||||||
|
aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht,
|
||||||
|
eingebundene Tabelle geschuetzt oder nicht:
|
||||||
|
|
||||||
|
| Datei | Aeusserer Aufruf | Aeussere Tabelle geschuetzt | Eingebundene Tabelle geschuetzt | Urteil |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `tenant.controller.ts` (3 Stellen: `findAll`/`findOne`/`remove`, vor Aufgabe 3) | ungebunden | Nein (`Tenant`) | JA (`User`) | GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben |
|
||||||
|
| `ldap-config.service.ts:309` (`getAllActiveConfigs`) | ungebunden (bewusst uebergreifend) | JA (`LdapConfig`) | eingebunden: `tenant`, `fieldMappings` | harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues |
|
||||||
|
| `tenders.controller.ts:612` (`sources`) | ungebunden | Nein (`Tender`, D-03 plattformweit) | Nein (`TenderSource`, ebenfalls plattformweit) | harmlos — beide Seiten plattformweit |
|
||||||
|
| übrige 14 Stellen (`groups.service.ts`, `module-grants.service.ts`, `dashboard.service.ts`, `dkv.service.ts`, `tender-*.service.ts`, `user.service.ts`) | ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen | — | — | harmlos, einzeln nachgesehen |
|
||||||
|
|
||||||
|
Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer
|
||||||
|
UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im
|
||||||
|
gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3.
|
||||||
|
ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in
|
||||||
|
diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit
|
||||||
|
fand keine zweite — faende eine spaetere Wiederholung eine zweite
|
||||||
|
Auspraegung, waere DANN ein `gsd-tools windows append`-Eintrag anzulegen, mit
|
||||||
|
Verweis auf diesen Absatz.
|
||||||
|
|
||||||
|
**(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke.**
|
||||||
|
`User_tenantId_fkey` faengt auch INAKTIVE Benutzer, waehrend der Riegel
|
||||||
|
T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven
|
||||||
|
Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der
|
||||||
|
verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen
|
||||||
|
die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses
|
||||||
|
Auftrags.
|
||||||
|
|
||||||
|
**(d) `TenantService.findAll` ohne Aufrufer** (Befund L) — bleibt totes
|
||||||
|
Codeglied, nicht entfernt (Scope).
|
||||||
|
|
||||||
|
**(e) Der unerreichbare `null`-Zweig des Guards** — SUPER_ADMIN ohne
|
||||||
|
`tenantId` und ohne `x-tenant-id`-Header ist mit dem heutigen
|
||||||
|
Sitzungsnachweis unerreichbar (`User.tenantId` ist `String`, nicht nullbar),
|
||||||
|
bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten
|
||||||
|
getestet, nicht umgebaut.
|
||||||
|
|
||||||
|
**(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft.** Ein
|
||||||
|
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken (D-10 wie
|
||||||
|
entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck.
|
||||||
|
|
||||||
|
**(g) Die Mehrkosten des Fan-outs.** Nach Aufgabe 3 kostet `findAll` eine
|
||||||
|
gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger
|
||||||
|
Mandantenzahl belanglos, dieselbe Form wie
|
||||||
|
`UserService.findAllForPlatformAdmin`.
|
||||||
|
|
||||||
|
**(h) Die Etappe-4-Vorabpruefung.** Die Benutzerzahl je Mandant ueber die
|
||||||
|
Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie
|
||||||
|
im Bereich `user` — kein eigener Eintrag noetig.
|
||||||
|
|
||||||
|
### (n5) Was dieser Durchlauf bewusst nicht anfasst
|
||||||
|
|
||||||
|
- Der direkte Prisma-Zugriff im Controller (Muster wie `user.controller.ts`)
|
||||||
|
— bleibt, Wartbarkeitsvermerk, keine Verschiebung in den Dienst.
|
||||||
|
- Die redundante `@UseGuards(RolesGuard)`-Klassenregistrierung neben der
|
||||||
|
globalen `APP_GUARD`-Registrierung von `RolesGuard`.
|
||||||
|
- Das Frontend — in (n3) beschrieben, nicht geaendert.
|
||||||
|
- `tenant.service.ts` — unveraendert.
|
||||||
|
- Die veraltete Tabellenliste im Abschnitt `## Mandantentrennung` von
|
||||||
|
`docs/anleitung-entwicklung.md` ("aktuell nur auf User,
|
||||||
|
PasswordResetToken, ..." — seit `20260909140000` sind es 23 Tabellen);
|
||||||
|
Aufgabe 3 aendert in jener Datei NUR die Absaetze zu Guard und Middleware,
|
||||||
|
diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit
|
||||||
|
festgehalten.
|
||||||
|
- Die historische Nennung der Middleware in
|
||||||
|
`docs/mandantentrennung-datenbankrolle.md:124` — beschreibt den Stand VOR
|
||||||
|
Etappe 1 korrekt, nicht zu aendern.
|
||||||
|
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
|
||||||
|
KEINE Regel.
|
||||||
|
|
||||||
## Verweis
|
## Verweis
|
||||||
|
|
||||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ entscheiden, ob die Controller künftig darüber gehen (dann bräuchte es keinen
|
|||||||
zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos
|
zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos
|
||||||
entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.
|
entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.
|
||||||
|
|
||||||
|
**Entschieden (260911-e2s, Aufgabe 2):** ersatzloser Entfall, fuer ALLE
|
||||||
|
Bereiche der Etappe 2, nicht nur fuer `tenant`. Gemessen: außerhalb von
|
||||||
|
`tenant.middleware.ts` und `tenant.guard.ts` gab es KEINEN Leser (Suchumfang
|
||||||
|
oben bestaetigt, ebenso erneut gemessen in 260911-e2s Aufgabe 1, Befund B);
|
||||||
|
`tenant.middleware.ts` war zudem NIRGENDS verdrahtet (kein
|
||||||
|
`MiddlewareConsumer`, kein `configure(` in ganz `apps/api`, gemessen)
|
||||||
|
und hatte — anders als der urspruengliche Befund oben suggerierte — auch
|
||||||
|
KEINE eigenen Tests, ebenso wenig wie der Guard (`ls apps/api/src/tenant/`
|
||||||
|
vor 260911-e2s: einzige Testdatei war `tenant.service.spec.ts`). Entscheidung:
|
||||||
|
`tenant.middleware.ts` ist GELOESCHT, `tenant.guard.ts` setzt nur noch
|
||||||
|
`req.tenantId` und hat keine Prisma-Abhaengigkeit mehr. Grund: alle neun vor
|
||||||
|
diesem Bereich umgestellten Bereiche binden ausnahmslos dienst-intern (ein
|
||||||
|
Klient je Methode) — die Konvention ist durch Praxis entschieden, und tote
|
||||||
|
Verdrahtung, die wie ein Sicherheitsmechanismus aussieht, ist schlimmer als
|
||||||
|
keine. Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||||
|
"## Bereich tenant", (n4)(a), fuer Messung und Begruendung im Volltext.
|
||||||
|
|
||||||
**WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und
|
**WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und
|
||||||
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
|
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
|
||||||
tragen ein nullbares `tenantId` (`SearchProvider` für admin-gepflegte
|
tragen ein nullbares `tenantId` (`SearchProvider` für admin-gepflegte
|
||||||
@@ -125,12 +142,12 @@ autoritative Quelle.
|
|||||||
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||||
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
||||||
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||||
| tenant | 8 | 0 | unverändert |
|
| tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||||
| favorites | 7 | 0 | unverändert |
|
| favorites | 7 | 0 | unverändert |
|
||||||
| settings | 4 | 0 | unverändert |
|
| settings | 4 | 0 | unverändert |
|
||||||
| **Summe** | **83** | **159** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), jetzt 159 nach 260911-cwh (zusätzlich 12 in `calendar`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
| **Summe** | **83** | **162** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0) und unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern — nur die Gebunden-Spalte änderte sich). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), jetzt 162 nach 260911-e2s (zusätzlich 3 in `tenant`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare)
|
||||||
|
|
||||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||||
@@ -188,13 +205,22 @@ Vermerk waere von einer vergessenen Nachziehung nicht zu unterscheiden —
|
|||||||
deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt
|
deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt
|
||||||
stillschweigend uebersprungen zu werden.
|
stillschweigend uebersprungen zu werden.
|
||||||
|
|
||||||
|
**Stand 260911-e2s (Aufgabe 3):** 64 Paare — 63 aus dem vorherigen
|
||||||
|
Durchlauf plus EIN neues Paar (`tenant.controller.ts`/`user`, Klasse
|
||||||
|
`muss-mandantengebunden`, Stand `gebunden`): die drei gebundenen
|
||||||
|
Benutzerzähler des Fan-outs (Befund F/G aus 260911-e2s Aufgabe 1). Keine
|
||||||
|
bestehende Klasse verschiebt sich — die beiden `tenant`-Paare
|
||||||
|
(`tenant.controller.ts`/`tenant`, `tenant.service.ts`/`tenant`) bleiben
|
||||||
|
`keine-mandantengebundene-tabelle`/`ungebunden`, nur ihre Begründung wird
|
||||||
|
fortgeschrieben (siehe Fundstellentabelle unten).
|
||||||
|
|
||||||
| Klasse | Anzahl Paare |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 31 |
|
| muss-mandantengebunden | 32 |
|
||||||
| keine-mandantengebundene-tabelle | 17 |
|
| keine-mandantengebundene-tabelle | 17 |
|
||||||
| beides | 13 |
|
| beides | 13 |
|
||||||
| bewusst-uebergreifend | 2 |
|
| bewusst-uebergreifend | 2 |
|
||||||
| **Summe** | **63** |
|
| **Summe** | **64** |
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — fünf Fälle
|
## Der Hintergrunddienst als Falle — fünf Fälle
|
||||||
|
|
||||||
@@ -333,6 +359,18 @@ Mandantenkennung der Anfrage, die sie ausgelöst hat, und kann strukturell
|
|||||||
keine andere haben. Kein Kandidat für diese Liste; dieser Absatz hält die
|
keine andere haben. Kein Kandidat für diese Liste; dieser Absatz hält die
|
||||||
Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
|
Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
|
||||||
|
|
||||||
|
Auch der Bereich `tenant` fügt diesem Abschnitt keinen sechsten Fall hinzu,
|
||||||
|
gemessen statt angenommen (260911-e2s, Aufgabe 1, Befund J). Anweisung:
|
||||||
|
`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|Scheduler" apps/api/src/tenant --include=*.ts`
|
||||||
|
und `grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/tenant --include=*.ts`
|
||||||
|
liefern je null Treffer — kein Hintergrunddienst, kein Roh-SQL, keine
|
||||||
|
Transaktion in diesem Bereich. `TenantService.create` ruft nach dem Anlegen
|
||||||
|
eines Mandanten `groupsService.ensureDefaultGroup(tenant.id)` auf; dieser
|
||||||
|
Aufruf läuft seit 260909-jts bereits vollständig über den gebundenen Weg des
|
||||||
|
Bereichs `groups` (`forTenant()`/`withTenantTransaction()`, siehe
|
||||||
|
Fundstellentabelle unten, `groups.service.ts`/`group`, Stand `gebunden`) —
|
||||||
|
nichts an dieser Übergabe musste in 260911-e2s umgestellt werden.
|
||||||
|
|
||||||
## Bestandsaufnahme
|
## Bestandsaufnahme
|
||||||
|
|
||||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||||
@@ -341,6 +379,17 @@ oder — seit 260909-ipc, Befund G — in `<gebundener Client>.<Modell>`
|
|||||||
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||||
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
||||||
|
|
||||||
|
**Erkennungslücke, seit 260911-e2s vermessen (Aufgabe 1, (n4)(b)):** die
|
||||||
|
Bestandsaufnahme sieht ausschließlich (Datei, Modell)-Paare über
|
||||||
|
`this.prisma.<Modell>` bzw. `<gebundener Client>.<Modell>` — eine
|
||||||
|
Relationseinbindung (`include:`, Relationszähler `_count`) in eine ZWEITE
|
||||||
|
Tabelle erzeugt kein eigenes Paar und ist für das Werkzeug strukturell
|
||||||
|
unsichtbar. Alle 19 `include:`-Stellen und alle `_count`-Stellen des
|
||||||
|
API-Quelltexts wurden einzeln nachgesehen; die einzige gefährliche
|
||||||
|
Ausprägung (ungebundener äußerer Aufruf auf einer UNGESCHÜTZTEN Tabelle,
|
||||||
|
Einbindung in eine GESCHÜTZTE Tabelle) war `tenant.controller.ts` — hier in
|
||||||
|
Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||||
|
|
||||||
| Datei | Modell | Klasse | Stand | Begründung |
|
| Datei | Modell | Klasse | Stand | Begründung |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||||
@@ -376,8 +425,9 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
|
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
|
||||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
|
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
|
||||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). |
|
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. |
|
||||||
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. |
|
||||||
|
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. |
|
||||||
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
||||||
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
||||||
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
||||||
@@ -409,7 +459,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
- ~~Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
||||||
`forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich
|
`forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich
|
||||||
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
||||||
`forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu
|
`forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu
|
||||||
@@ -425,7 +475,15 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
`calendar` (260911-cwh) hat sich für denselben dienst-internen Weg
|
`calendar` (260911-cwh) hat sich für denselben dienst-internen Weg
|
||||||
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
|
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
|
||||||
erzeugt ihren eigenen `forTenant()`-Aufruf, wie alle acht Bereiche vor ihm.
|
erzeugt ihren eigenen `forTenant()`-Aufruf, wie alle acht Bereiche vor ihm.
|
||||||
Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen.
|
Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen.~~
|
||||||
|
**Aufgelöst (260911-e2s):** die Frage ist für ALLE Bereiche entschieden,
|
||||||
|
nicht nur für `tenant` — dienst-intern, ein Klient je Methode, ist die
|
||||||
|
Konvention. Die Anfrageobjekt-Eigenschaft existiert nicht mehr:
|
||||||
|
`tenant.guard.ts` setzt nur noch `req.tenantId`, `tenant.middleware.ts`
|
||||||
|
(der nie verdrahtete Zwilling mit identischer Logik) ist gelöscht. Siehe
|
||||||
|
Abschnitt "Zwei belegte Befunde" oben und
|
||||||
|
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich tenant",
|
||||||
|
(n4)(a).
|
||||||
- ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
- ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
||||||
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
||||||
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach
|
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach
|
||||||
|
|||||||
Reference in New Issue
Block a user