6 Commits

Author SHA1 Message Date
schalli 6236b302f4 docs(quick-260911-e2s): Etappe 2 Bereich tenant abgeschlossen, WINDOWS #27 Relations-Blindstelle
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 56s
Tessera CI/CD / Build & Publish Images (push) Successful in 29s
2026-09-11 11:08:33 +02:00
schalli c8de72e762 feat(260911-e2s): Benutzerzaehler im TenantController binden (Fan-out je Mandant)
findAll/findOne/remove zaehlen Benutzer je Mandant jetzt ueber drei
gebundene Aufrufstellen (tenantPrisma.user.count mit where: { tenantId
}, in remove zusaetzlich isActive: true) statt ueber den
Relationszaehler, der nach dem Scharfschalten unbemerkt unter der
Regel von User gelaufen waere (260911-e2s, Aufgabe 1, Pruefungen 5-7).
Fan-out-Muster aus UserService.findAllForPlatformAdmin uebernommen; die
vier tenant-Zugriffe bleiben ungebunden (Tenant ohne Regel). Antwortform,
Meldungen und Statuscodes unveraendert.

tenant.controller.spec.ts legt die Testlage aus dem Nichts an (20
Faelle): Zwei-Klienten-Nachweis ueber __makeBoundClient, Rollen-
Metadaten-Test (Klasse SUPER_ADMIN, kein Handler ueberschreibt), Wachhund
gegen mehrfache Klientenerzeugung. Falsifizierungsnachweis durchgefuehrt:
der probeweise ungebundene Zaehler in findOne macht 2 Faelle rot mit
"Cannot read properties of undefined (reading 'count')" — die dkv-Form
der Falsifizierung, nicht nur eine falsche Zahl —, danach zurueckgenommen.

Klassifikation und Entwicklungsanleitung nachgezogen: 64 Paare (ein
neues, tenant.controller.ts/user), Uebersichtszeile 8/3, Klassen-
Verteilung 32 muss-mandantengebunden, Erkennungsluecke fuer
Relationseinbindungen im Kopf der Bestandsaufnahme benannt, "Zwei
belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster
Punkt aufgeloest). Beide Dokument-Falsifizierungsnachweise durchgefuehrt
(falsche Klasse macht rls-access-inventory.spec.ts rot, falsche
Uebersichtszahl macht das herleitende Gate rot), zurueckgenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:58:27 +02:00
schalli 17dca0dfad fix(260911-e2s): Guard-Umbau und Kommentarkorrekturen nachtragen (Aufgabe 2 vollstaendig)
Die vorige Aufgabe-2-Teilcommit (11f5731) hatte nur die Loeschung von
tenant.middleware.ts und die neue tenant.guard.spec.ts erfasst — ein
`git add` mit mehreren Pfaden schlug wegen eines bereits entfernten
Pfads fataler fehl und liess die restlichen fuenf Dateien unstaged,
ohne dass das beim Commit auffiel (Rule 1 — Prozessfehler, hier
korrigiert). Dieser Commit traegt den eigentlichen Umbau nach:
tenant.guard.ts ohne Prisma-Abhaengigkeit, die geleerte
FORTENANT_ASSIGNMENT_EXCEPTIONS samt Wachhund-Test in
rls-access-inventory.spec.ts, und die drei berichtigten
Kommentarzeilen (app.module.ts, module.guard.ts, dkv.controller.ts).
Inhaltlich identisch mit dem, was bereits verifiziert wurde (891 Tests
gruen, Typpruefung sauber) — nur die Staging-Reihenfolge war fehlerhaft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:50:26 +02:00
schalli 11f5731029 feat(260911-e2s): TenantGuard setzt nur noch tenantId, Middleware geloescht
Die seit Etappe 1 offene Architekturfrage zum gebundenen Klienten auf
dem Anfrageobjekt ist entschieden: neun umgestellte Bereiche binden
ausnahmslos dienst-intern (ein Klient je Methode), ein Klient auf
req.tenantPrisma ohne Leser war tote Verdrahtung, die wie ein
Sicherheitsmechanismus aussah. tenant.guard.ts verliert die
Prisma-Abhaengigkeit und setzt nur noch req.tenantId; die nie
verdrahtete tenant.middleware.ts (identische Logik, in keinem Modul
registriert) ist geloescht.

tenant.guard.spec.ts legt die Testlage aus dem Nichts an — alle fuenf
Zweige (kein Nutzer, USER, ADMIN mit ignorierter x-tenant-id-Kopfzeile
T-04-03, SUPER_ADMIN mit/ohne Wechsel, mandantenloser Nicht-SUPER_ADMIN)
sowie die Abwesenheit der alten Eigenschaft in jedem Durchlass-Fall.
Falsifizierungsnachweis durchgefuehrt: das probeweise Wiedereinfuehren
der alten Zuweisung macht 4 der 7 Faelle rot (u. a. "expected true to
be false" auf 'tenantPrisma' in req), danach zurueckgenommen.

rls-access-inventory.spec.ts: FORTENANT_ASSIGNMENT_EXCEPTIONS ist leer
und selbstpruefend (neuer Wachhund gegen veraltete Eintraege). Drei
Fremdkommentare (app.module.ts, module.guard.ts, dkv.controller.ts)
korrigiert, die noch auf die nie verdrahtete Middleware verwiesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:49:53 +02:00
schalli 652e762ad4 feat(260911-e2s): Fehlerrichtung fuer Bereich tenant messen — Relationszaehler laeuft unter User
runTenantAreaChecks (9 neue Pruefungen, 6 davon ueber den generierten
Client) belegt: auf "Tenant" ist nichts zu binden (keine Regel in allen
34 Migrationen einschliesslich 20260910120000), aber der
Relationszaehler in findAll/findOne/remove liefert nach dem
Scharfschalten userCount=0 fuer jeden Mandanten und laesst den
Loeschriegel T-02-09 vakuum werden — der Fremdschluessel faengt das
nur laut (500) statt mit der verstaendlichen 400-Meldung ab.

docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt
"## Bereich tenant" (n1-n5) mit der tatsaechlich beobachteten
Werkzeugausgabe, der Signaltabelle je Pfad, den Frontend-Stellen, die
die falsche Zahl unkommentiert durchlassen, und der Entscheidung zur
Anfrageobjekt-Eigenschaft (Vorbereitung fuer Aufgabe 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:46:04 +02:00
schalli 6426b18630 docs(quick-260911-e2s): Plan fuer Etappe 2, Bereich tenant
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:34:52 +02:00
18 changed files with 2590 additions and 134 deletions
+1
View File
@@ -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-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-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/) |
| 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
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 8
open_count: 9
waived_count: 1
fixed_count: 17
total_count: 26
last_updated: 2026-09-11T07:57:36.769Z
total_count: 27
last_updated: 2026-09-11T09:08:00.435Z
---
# 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 | |
| 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 | |
| 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
[
@@ -355,6 +356,18 @@ last_updated: 2026-09-11T07:57:36.769Z
"reason": "",
"recorded_at": "2026-09-11T07:57:36.769Z",
"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
}
]
````
File diff suppressed because one or more lines are too long
@@ -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
@@ -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)*
+350
View File
@@ -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
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -3333,6 +3682,7 @@ async function main() {
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {
+1 -1
View File
@@ -54,7 +54,7 @@ import { UserModule } from './user/user.module';
provide: APP_GUARD,
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,
useClass: TenantGuard,
+1 -1
View File
@@ -30,7 +30,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
* @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.
*
* Routes:
+1 -1
View File
@@ -22,7 +22,7 @@ export const MODULE_SLUG_KEY = 'moduleSlug';
* Gruppen-Grant), D-01.
*
* 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.
*
* 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 { 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
* `const <Name> = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen
* den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`)
* statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die
* offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md
* ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt
* stillschweigend als Erkennungsluecke durchzurutschen.
* `const <Name> = forTenant(`-Zuweisungsform folgt.
*
* Bis 260911-e2s standen hier zwei Dateien (`tenant.middleware.ts`,
* `tenant.guard.ts`): beide veroeffentlichten einen gebundenen Client auf
* dem Anfrageobjekt statt ihn einer lokalen Konstante zuzuweisen — der Weg
* 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([
'apps/api/src/tenant/tenant.middleware.ts',
'apps/api/src/tenant/tenant.guard.ts',
]);
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
/**
* 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([]);
});
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)', () => {
const violations: string[] = [];
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);
});
});
+54 -27
View File
@@ -13,6 +13,7 @@ import {
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { TenantService } from './tenant.service';
@@ -21,6 +22,30 @@ import { TenantService } from './tenant.service';
* Tenant CRUD controller.
* D-10: Only Super-Admin can manage tenants.
* 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')
@UseGuards(RolesGuard)
@@ -38,22 +63,26 @@ export class TenantController {
@Get()
async findAll() {
const tenants = await this.prisma.tenant.findMany({
include: {
_count: {
select: { users: true },
},
},
orderBy: { name: 'asc' },
});
return tenants.map((t: any) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
createdAt: t.createdAt,
userCount: t._count.users,
}));
const results: any[] = [];
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: tenant.id },
});
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) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: { users: true },
},
},
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: id },
});
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
isActive: tenant.isActive,
createdAt: tenant.createdAt,
userCount: tenant._count.users,
userCount,
};
}
@@ -125,20 +154,18 @@ export class TenantController {
async remove(@Param('id') id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: {
users: { where: { isActive: true } },
},
},
},
});
if (!tenant) {
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(
'Cannot delete tenant with active users. Deactivate or reassign users first.',
);
+112
View File
@@ -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'),
);
});
});
+19 -9
View File
@@ -4,20 +4,32 @@ import {
ForbiddenException,
Injectable,
} 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).
* 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.
* Super-Admin can override tenant via x-tenant-id header (D-10).
* Sets ONLY req.tenantId for downstream code. Super-Admin can override the
* 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()
export class TenantGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const user = req.user;
@@ -38,10 +50,8 @@ export class TenantGuard implements CanActivate {
}
if (tenantId) {
req.tenantPrisma = forTenant(this.prisma, tenantId);
req.tenantId = tenantId;
} else {
req.tenantPrisma = this.prisma;
req.tenantId = null;
}
-54
View File
@@ -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();
}
}
+19 -18
View File
@@ -145,13 +145,13 @@ Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem).
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
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()`).
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`.
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
tenant-gescopten Client aus `req.tenantPrisma` auf Postgres zugreift.
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen
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
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
`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
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend `req.tenantId` sowie
`req.tenantPrisma` — einen über `forTenant()`
(`apps/api/src/prisma/prisma-tenant.extension.ts`) erzeugten Prisma-Client, der vor **jeder** Query
in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt.
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH
`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je
Service-Methode neu, über das Bindungshilfsmittel `forTenant()`
(`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`
> (`apps/api/src/tenant/tenant.middleware.ts`) mit identischer Logik. Sie ist in `app.module.ts`
> nirgends über `.apply(...).forRoutes(...)` eingebunden — der tatsächlich aktive Mechanismus ist
> ausschließlich `TenantGuard`. Vereinzelte Code-Kommentare verweisen noch auf „TenantMiddleware“;
> gemeint ist in jedem Fall der Guard.
> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem
> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten
> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine
> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand.
`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`,
@@ -318,12 +319,12 @@ RLS-Policy.
**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
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain `PrismaService` (nicht
`req.tenantPrisma`) und filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle
das `tenantId`-Filter vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das
auffängt. Bei den sieben RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich,
vorausgesetzt die Query läuft tatsächlich über den `tenantPrisma`-Client aus `req.tenantPrisma`
und nicht über den globalen `PrismaService`.
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und
filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter
vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben
RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
den globalen, ungebundenen `PrismaService`.
## 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ä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
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
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
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
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) |
| 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 |
| 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 |
| 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
(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
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 |
|---|---|
| muss-mandantengebunden | 31 |
| muss-mandantengebunden | 32 |
| keine-mandantengebundene-tabelle | 17 |
| beides | 13 |
| bewusst-uebergreifend | 2 |
| **Summe** | **63** |
| **Summe** | **64** |
## 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
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
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
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 |
|---|---|---|---|---|
| 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 | 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/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.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
| 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.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/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) |
@@ -409,7 +459,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
## 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
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
`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
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
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`
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach