Compare commits
6 Commits
50b3a36f0c
...
f1017fa6e9
| Author | SHA1 | Date | |
|---|---|---|---|
| f1017fa6e9 | |||
| 06038b9dfa | |||
| e0e163ec63 | |||
| 77cb124f59 | |||
| bf5fc4d4c7 | |||
| 508d9e4301 |
+8
-5
@@ -4,11 +4,11 @@ milestone: v1.2
|
||||
current_phase: 17
|
||||
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
|
||||
status: verified
|
||||
stopped_at: "Quick 260910-krx abgeschlossen: Bereich dashboard der Etappe 2 (Mandantentrennung) umgestellt, 12/13 Zugriffe gebunden, WINDOWS #25 neu offen"
|
||||
last_updated: "2026-09-11T07:05:45.197Z"
|
||||
stopped_at: "Quick 260911-cwh abgeschlossen: Bereich calendar der Etappe 2 (Mandantentrennung) umgestellt, 12/12 Zugriffe gebunden, WINDOWS #26 neu offen"
|
||||
last_updated: "2026-09-11T08:01:13.625Z"
|
||||
last_activity: 2026-09-10
|
||||
last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent
|
||||
state_head: 67b50240d6a3f0dca2c2bfbd0a129269f9a7ab53
|
||||
state_head: e0e163ec636cb48d15cc06ba9ee246a86b5d50a0
|
||||
progress:
|
||||
total_phases: 17
|
||||
completed_phases: 3
|
||||
@@ -119,6 +119,7 @@ Progress: [██████████] 100%
|
||||
| Phase quick-260909-ipc P01 | 55min | 3 tasks | 8 files |
|
||||
| Phase quick-260910-jab P01 | 70min | 3 tasks | 16 files |
|
||||
| Phase quick-260910-krx P01 | 26min | 3 tasks | 7 files |
|
||||
| Phase quick-260911-cwh P01 | 21min | 3 tasks | 7 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -297,6 +298,7 @@ Recent decisions affecting current work:
|
||||
- [Phase 17]: [260909-ipc]: getAllActiveConfigs()/onApplicationBootstrap() bleiben dauerhaft ungebunden (Befund B) — Klasse von (ldap-config.service.ts, ldapConfig) korrigiert auf beides
|
||||
- [Phase 17]: [260909-ipc]: Standardgruppen-Uebergabe (groups.service.ts) bewusst nicht angefasst — Reihenfolgebedingung fuer Etappe 4
|
||||
- [Phase 17]: 260910-krx: Bereich dashboard vollstaendig umgestellt — 12 gebunden, 1 begruendet ungebunden (Modulkatalog); getLayout/saveLayout gemeinsam gebunden; saveLayout uebersetzt PrismaClientUnknownRequestError (nicht P2002) in deutsche Konfliktmeldung; WINDOWS #25 fuer die beweisvernichtende Schleife offen angelegt
|
||||
- [Phase 17]: [quick-260911-cwh]: Bereich calendar Etappe 2 gebunden — Cache-Schluessel bleibt ohne Mandantenanteil (User.id ist plattformweit eindeutige UUID, Etappe-3-Entscheidung (1) betrifft nur username/email); keine neue Fehleruebersetzung fuer Besitzpruefungen noetig (Wettlauf-Fall wirft P2025, strukturell unerreichbar); refreshCacheInBackground zaehlt nicht als sechster Hintergrunddienst-Fall
|
||||
|
||||
### Pitfalls & Anti-Patterns
|
||||
|
||||
@@ -384,6 +386,7 @@ None yet.
|
||||
| 260910-exd | Mandantentrennung Etappe 2, Bereich module-registry — der Berechtigungs-Anfrageweg. `module.guard.ts` laeuft bei JEDER Modulanfrage und hat null eigene Datenbankzugriffe; seine Richtigkeit ist vollstaendig eine Funktion dessen, was dieser Bereich liefert. Endstand 7 ungebunden / 10 gebunden. **Eine Vorgabe des Auftrags war falsch und wurde widerlegt:** angeblich wuerde eine Bindung des Modulkatalogs ihn fuer jeden Mandanten unsichtbar machen — gemessen ueber alle 23 `ENABLE ROW LEVEL SECURITY`-Zeilen steht `Module` auf keiner, die Tabelle traegt gar keinen Zeilenschutz und kein `tenantId`. Eine Bindung waere heute WIRKUNGSLOS, nicht katastrophal; katastrophal wird sie erst, wenn Etappe 3 der Tabelle eine Regel gibt. Handlung unveraendert (Katalog bleibt ungebunden), aber Messung und Bedingung sind in Code und Dokument jetzt getrennt — eine richtige Handlung mit falscher Begruendung haelt nur, bis sich jemand auf die Begruendung verlaesst. **Die Kernfrage ehrlich beantwortet:** es gibt KEIN Signal, das 'wirklich keine Freigabe' von 'die Abfrage hat nichts gefunden' unterscheidet. Nach dem Scharfschalten saehe ein Unterlauf nicht wie ein Fehler aus, sondern wie 'du hast keine Module' — leere Seitenleiste, leerer Marktplatz, jeder Modulaufruf abgewiesen, fuer den Betroffenen nicht von einem absichtlichen Entzug zu unterscheiden. Dreifach festgehalten: im Text, als Testfall in `module.guard.spec.ts` (zwei Aufrufe, identische Meldung, gegeneinander gehalten) und als WINDOWS #23 mit konkreter Etappe-4-Vorabpruefung. **Erstmals eine Entlastung, die strukturell haelt:** die Kette unsichtbare Zeile -> falsches 'frei' -> 23505 kann hier nicht auftreten, weil die Eindeutigkeitsschluessel die Mandantenkennung fuehren — erster von sechs Bereichen, in dem sie abwesend statt umgangen ist; entsprechend wurde KEINE Absicherung eingebaut, die nichts absichert. **Wieder zwei Kopfkommentare mit Falschaussagen** (`isModuleActive` 'Used by ModuleGuard', `findActiveForTenant` als Marktplatz-Lieferant), beide Methoden mit null Aufrufern — berichtigt. `module-registry.service.ts` hatte trotz 11 der 17 Zugriffe und aller Schreibwege GAR KEINE Testdatei. Reihenfolge-Entlastung: der Dashboard-Filter wird mitgebunden, ohne dass eine `dashboard`-Datei angefasst wird. Zwei Abweichungen, beide vom eigenen Pruefgatter erzwungen und geprueft: eine Identitaets-Attrappe in `tender-scheduler.service.spec.ts` (zulaessig — jene Datei prueft Planer-Verhalten, die Bindung ist in `module-registry.service.spec.ts` mit zwei Klienten belegt) und eine Stand-Spalte, die eine Aufgabe frueher nachgezogen werden musste. **Verifiziert 9/9** (833/833 Tests, Typpruefung sauber, 66/66 Live-Pruefungen; Klassenverteilung und Summenzeile vom Pruefer eigenhaendig nachgerechnet). Eine Zahl in der Zusammenfassung (7 statt 6 neue Bindungsnachweise) vom Pruefer nachgezaehlt und berichtigt | 2026-09-10 | 7d45e2f,3df7268,9c0eefe | [260910-exd-mandantentrennung-etappe-2-bereich-modul](./quick/260910-exd-mandantentrennung-etappe-2-bereich-modul/) |
|
||||
| 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/) |
|
||||
| 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/) |
|
||||
|
||||
@@ -425,8 +428,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-11T07:05:44.595Z
|
||||
Last session: 2026-09-11T08:01:13.009Z
|
||||
Resumed: 2026-09-09 — Sitzung ueber /gsd-resume-work fortgesetzt; Einstiegspunkt Etappe 2 (Bereich ldap), Auswahl des Nutzers steht aus.
|
||||
Stopped at: Quick 260910-krx abgeschlossen: Bereich dashboard der Etappe 2 (Mandantentrennung) umgestellt, 12/13 Zugriffe gebunden, WINDOWS #25 neu offen
|
||||
Stopped at: Quick 260911-cwh abgeschlossen: Bereich calendar der Etappe 2 (Mandantentrennung) umgestellt, 12/12 Zugriffe gebunden, WINDOWS #26 neu offen
|
||||
Resume file: None
|
||||
Last activity: 2026-09-10 - Completed quick task 260910-jab: Die drei zu kurz greifenden Datenbankregeln (T-JTS-02, T-JTS-03, WINDOWS #19) geschlossen
|
||||
|
||||
+16
-3
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 7
|
||||
open_count: 8
|
||||
waived_count: 1
|
||||
fixed_count: 17
|
||||
total_count: 25
|
||||
last_updated: 2026-09-11T09:01:00.000Z
|
||||
total_count: 26
|
||||
last_updated: 2026-09-11T07:57:36.769Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -40,6 +40,7 @@ last_updated: 2026-09-11T09:01:00.000Z
|
||||
| 23 | quick-260910-exd | deviation | apps/api/src/module-registry/module-access.service.ts | | Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3). | open | | 2026-09-10T09:28:45.310Z | |
|
||||
| 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 | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -342,6 +343,18 @@ last_updated: 2026-09-11T09:01:00.000Z
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T09:01:00.000Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"kind": "deviation",
|
||||
"phase": "quick-260911-cwh",
|
||||
"file": "apps/web/src/components/dashboard/widgets/calendar-widget.tsx",
|
||||
"line": null,
|
||||
"description": "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.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-09-11T07:57:36.769Z",
|
||||
"resolved_at": null
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
+821
@@ -0,0 +1,821 @@
|
||||
---
|
||||
phase: quick-260911-cwh
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [WINDOWS-18, ETAPPE-2-CALENDAR]
|
||||
|
||||
files_modified:
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- apps/api/src/calendar/calendar.service.spec.ts
|
||||
- apps/api/src/calendar/calendar.service.ts
|
||||
- apps/api/src/calendar/calendar.controller.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- .planning/WINDOWS.md
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Alle zwoelf Datenbankzugriffe dieses Bereichs laufen gebunden unter dem woertlichen Namen `tenantPrisma`, genau ein gebundener Klient je Methode mit Datenbankzugriff. Es gibt in diesem Bereich KEINEN begruendet ungebundenen Zugriff — jede Zeile von `CalendarSource` traegt eine Pflicht-Mandantenkennung, und kein Pfad liest ueber Mandanten hinweg."
|
||||
- "Lesen und Schreiben derselben Zeile sind nirgends auf gebunden und ungebunden aufgeteilt: die drei Besitzpruefungen (`updateSource`, `deleteSource`, `testConnection`) fuehren Nachschlagen UND Schreiben ueber DENSELBEN gebundenen Klienten, und die beiden Synchronstatus-Rueckschreibungen innerhalb der Ereignisaggregation laufen ueber denselben Klienten wie das Laden der Quellen. Das ist je Pfad als Testfall festgenagelt, nicht behauptet."
|
||||
- "Die umgekehrte Fehlerrichtung dieses Bereichs ist an der ECHTEN, ausgelieferten Regel fuer `CalendarSource` gemessen — an dem Stand, den die Datenbank NACH der Migration 20260910120000 hat (die diese Tabelle nachweislich nicht angefasst hat, mit Anweisung belegt). Mindestens ein Schreib- und ein Lesepfad sind ueber den GENERIERTEN Prisma-Client gemessen, an einer Wegwerf-Tabelle, die saemtliche Spalten des Modells traegt (die Lehre aus Pruefung 5b im Bereich `dashboard`)."
|
||||
- "Die umgekehrte Fehlerrichtung ist in ihrer Auspraegung fuer diesen Bereich benannt: nach dem Scharfschalten liefert ein zu kleines Leseergebnis KEIN Fehlerbild, sondern einen leeren Kalender, der als 'mein Kalender ist leer' oder 'die Synchronisation ist kaputt' gelesen wird, und eine leere Quellenliste, die als 'keine Quelle eingerichtet' gelesen wird. Zusaetzlich benannt: das Frontend verwandelt sogar LAUTE Fehler der Lesepfade in denselben leeren Zustand. Festgehalten in der Kritikschrift UND als offener Eintrag im Broken-Windows-Register mit konkreter Vorabpruefung fuer Etappe 4."
|
||||
- "Die Frage nach der Zugangsdaten-Erhaltung ist mit Messung beantwortet, nicht mit Annahme: ob `calendar.service.ts` die zerstoerende Form aus `dkv` (lesen, entschluesseln, neu verschluesseln — nach dem Scharfschalten leer) traegt oder nicht. Der Befund steht in der Kritikschrift, und das tatsaechliche Erhaltungsverhalten (Passwort weggelassen, Passwort leer, Passwort gesetzt) ist als drei Testfaelle festgenagelt, damit eine spaetere Aenderung der Form sichtbar wird."
|
||||
- "Der Ereignis-Cache-Schluessel ohne Mandantenanteil hat ein schriftliches, belegtes Urteil: entweder bleibt die Benutzerkennung, die er traegt, auch nach der Etappe-3-Entscheidung 'Anmeldenamen pro Mandant eindeutig' plattformweit eindeutig — dann ist der Schluessel sicher und bleibt — oder sie bleibt es nicht, dann bekommt der Schluessel einen Mandantenanteil. Die Kette (Schema, Sitzungsnachweis, Controller) ist Glied fuer Glied nachgesehen, mit Anweisung, und das Urteil steht in Code UND Kritikschrift."
|
||||
- "Die drei Besitzpruefungen sind GELESEN, nicht angenommen: dieser Durchlauf hat je Pfad nachgesehen, ob zwischen Nachschlagen ueber die Kennung und Schreiben tatsaechlich ein Vergleich gegen die Benutzerkennung aus dem Sitzungsnachweis steht (das Vorhaben fand bei `ldap` und `dkv` je einmal keinen). Die Pruefungen bleiben bestehen, werden durch die Bindung ERGAENZT und sind als Testfaelle festgenagelt — sie sind der einzige Schutz zwischen Kollegen DESSELBEN Mandanten, weil die Regel keine Benutzerdimension kennt (gemessen)."
|
||||
- "Die Testlage steht VOR der Umstellung: `calendar.service.spec.ts` existiert heute nicht und wird mit dem Zwei-Klienten-Nachweis neu angelegt, mit Attrappen fuer Verschluesselung und die drei Provider — die Provider selbst werden NICHT ausgeuebt. Ein vergessener Bindungsaufruf wird dadurch rot, statt aus einem anderen Grund zu scheitern."
|
||||
- "Der Controller reicht die bereits in `extractContext` aufgeloeste Mandantenkennung an alle fuenf Handler durch, die sie heute verwerfen; es entsteht KEINE neue Vertrauensquelle, nichts wird aus Rumpf oder Pfad uebernommen."
|
||||
- "Alle fuenf handgepflegten Dokumentstellen der Klassifikation sind nachgezogen und maschinell gegatet, die Gates LEITEN ihre Werte aus den im Dokument genannten Messanweisungen ab, und der Umfang ist als ERLAUBNISLISTE gegen `50b3a36` gegatet."
|
||||
- "Baseline gehalten am Ende JEDER Aufgabe: mindestens 860 Tests gruen (mindestens 56 Dateien), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden. Der Schalter bleibt AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei angefasst, nichts in Active Directory."
|
||||
artifacts:
|
||||
- "apps/api/scripts/rls-scratch-check.mjs — ein elfter Abschnitt `runCalendarAreaChecks` mit mindestens zwoelf namentlich benannten Pruefungen gegen die aus der ausgelieferten Migration geschnittene Regel fuer `CalendarSource`, davon mindestens vier ueber den generierten Client"
|
||||
- "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich calendar` mit (k1) Messung, (k2) Signaltabelle, (k3) Leere-als-Abwesenheit im Backend UND im Frontend samt der Fehlerverschluckung, (k4) bewusst nicht geloest (darunter das Urteil zum Cache-Schluessel und der Befund zur Zugangsdaten-Erhaltung), (k5) bewusst nicht angefasst"
|
||||
- "apps/api/src/calendar/calendar.service.spec.ts — NEU, Zwei-Klienten-Nachweis ueber `__makeBoundClient`, Attrappen fuer `CryptoService` und die drei Provider, Faelle fuer jeden Pfad mit Datenbankzugriff"
|
||||
- "apps/api/src/calendar/calendar.service.ts — alle zwoelf Zugriffe gebunden, ein Klient je Methode, das Cache-Schluessel-Urteil als Kommentar an der Stelle"
|
||||
- "apps/api/src/calendar/calendar.controller.ts — die fuenf Handler, die den aufgeloesten Mandanten heute verwerfen, reichen ihn durch"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md — eine Bestandsaufnahme-Zeile, Uebersichtszeile, Summenzeile, Klassen-Verteilung (unveraendert, ausdruecklich vermerkt), Hintergrunddienst-Abschnitt (kein sechster Fall, gemessen, mit dem Sonderfall der abgekoppelten Cache-Auffrischung), Abschnitt `Was diese Etappe NICHT entscheidet`"
|
||||
- ".planning/WINDOWS.md — ein neuer OFFENER Eintrag zur lautlosen Leere dieses Bereichs, ueber `gsd-tools windows append` angelegt, damit Tabelle, JSON-Block und Kopfzaehler zusammenpassen"
|
||||
key_links:
|
||||
- "`extractContext` im Controller loest den Mandanten bereits auf und bricht ohne ihn mit `ForbiddenException` ab; fuenf von sechs kontextnutzenden Handlern verwerfen ihn heute. Die Umstellung ist ein Durchreichen, keine neue Vertrauensquelle."
|
||||
- "`fetchAndCacheEvents` laedt die Quellen und schreibt je Quelle den Synchronstatus zurueck — innerhalb eines `try/catch`, dessen `catch` selbst wieder schreibt. Waere das Laden gebunden und das Rueckschreiben nicht, schluege das erste Rueckschreiben nach dem Scharfschalten mit 'Zeile nicht gefunden' fehl, der `catch` versuchte das zweite, das ebenso scheitert, und `Promise.allSettled` liesse die Ereignisse dieser Quelle STILL fallen. Ein Klient je Methode ist hier keine Stilfrage."
|
||||
- "Der Cache-Schluessel traegt `req.user.id`; das ist laut `JwtStrategy.validate` `payload.sub`, das laut `auth.service.ts` `user.id` ist, das laut Schema `@default(uuid())` traegt. Diese Kette ist das Urteil — jedes Glied ist zur Ausfuehrungszeit nachzusehen."
|
||||
- "Die Regel auf `CalendarSource` lautet `\"tenantId\" = current_tenant_id()` ohne Benutzerdimension: die verschluesselten Exchange-/CalDAV-Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene sichtbar. Die anwendungsseitigen `userId`-Filter und Besitzpruefungen sind der einzige Schutz und bleiben unveraendert."
|
||||
- "Das Frontend (`calendar-widget.tsx`, `calendar-settings-panel.tsx`) faengt Fehler der Lesepfade und zeigt denselben leeren Zustand wie bei einer leeren Antwort. Damit ist selbst ein LAUTER Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` fuer den Nutzer unsichtbar — das Signal existiert nur im Netzwerkprotokoll des Browsers und im API-Log."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Etappe 2 der Mandantentrennung, neunter Bereich: `calendar`. Die zwoelf
|
||||
Datenbankzugriffe des einzigen Dienstes dieses Bereichs werden auf
|
||||
`forTenant()` umgestellt — alle zwoelf, denn `CalendarSource` traegt eine
|
||||
Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest ueber Mandanten
|
||||
hinweg.
|
||||
|
||||
Zweck: dieser Bereich haelt nicht bloss Daten, sondern ZUGANGSDATEN zu fremden
|
||||
Servern — die verschluesselten Exchange-, CalDAV- und ICS-Anmeldungen eines
|
||||
Nutzers. Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern
|
||||
Offenlegung der Anmeldung einer anderen Firma bei ihrem Mailserver. Und die
|
||||
umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein
|
||||
leerer Kalender: nach dem Scharfschalten liefert eine ungebunden gebliebene
|
||||
Abfrage keine Meldung, sondern null Quellen und null Ereignisse. Der Nutzer
|
||||
liest das als "die Synchronisation ist kaputt" oder "ich habe keine Quelle
|
||||
eingerichtet", legt seine Quelle neu an — und tippt dabei sein
|
||||
Exchange-Passwort ein zweites Mal in ein System, das gerade aussieht, als
|
||||
waere es defekt. Das Frontend verstaerkt das: es faengt sogar laute Fehler
|
||||
der Lesepfade und zeigt denselben leeren Zustand. Deshalb braucht dieser
|
||||
Bereich seine Kritikschrift VOR der Umstellung, gemessen.
|
||||
|
||||
Ergebnis: zwoelf gebundene Zugriffe, eine gemessene Kritikschrift, eine aus
|
||||
dem Nichts angelegte Testlage, die einen vergessenen Bindungsaufruf rot
|
||||
macht, ein schriftliches Urteil zum Cache-Schluessel, ein belegter Befund zur
|
||||
Zugangsdaten-Erhaltung, und zwei Dokumente, die am Ende nachweislich mit dem
|
||||
Quelltext uebereinstimmen.
|
||||
|
||||
**Der Schalter bleibt AUS.** `DATABASE_URL` zeigt weiterhin auf die Rolle
|
||||
`tessera` mit `BYPASSRLS`. Das Scharfschalten ist Etappe 4 und findet hier
|
||||
NICHT statt. Schema und Migrationen werden NICHT angefasst. Nichts wird in
|
||||
Active Directory geaendert.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@docs/mandantentrennung-zugriffsklassifikation.md
|
||||
@docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
@apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
|
||||
@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
|
||||
@apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
@apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
@apps/api/scripts/rls-scratch-check.mjs
|
||||
@apps/api/src/calendar/calendar.service.ts
|
||||
@apps/api/src/calendar/calendar.controller.ts
|
||||
@apps/api/src/calendar/calendar.module.ts
|
||||
@apps/api/src/calendar/dto/update-calendar-source.dto.ts
|
||||
@apps/api/src/dkv/dkv.service.ts
|
||||
@apps/api/src/dkv/dkv.service.spec.ts
|
||||
@apps/api/src/dashboard/dashboard.service.spec.ts
|
||||
@apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
@.planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-PLAN.md
|
||||
</context>
|
||||
|
||||
<planning_time_findings>
|
||||
|
||||
Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD `50b3a36`
|
||||
GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
|
||||
sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
|
||||
erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht
|
||||
eine Messung ab, gilt die Messung und nicht dieser Plan.
|
||||
|
||||
**Befund A — die Zahl haelt, ein Modell, eine Datei.** Gemessen mit
|
||||
`grep -rnoE "this\.prisma\.[a-zA-Z]+" apps/api/src/calendar --include=*.ts | grep -v spec`:
|
||||
zwoelf Treffer, alle in `calendar.service.ts`, alle auf `calendarSource`
|
||||
(Zeilen 124, 163, 176, 210, 227, 239, 248, 267, 278, 361, 387, 398). Verteilt
|
||||
auf sechs Methoden mit Datenbankzugriff: `getSources` (1), `addSource` (1),
|
||||
`updateSource` (2), `deleteSource` (2), `testConnection` (3),
|
||||
`fetchAndCacheEvents` (3). `aggregateEvents` und `refreshCacheInBackground`
|
||||
greifen nicht selbst zu, sie delegieren an `fetchAndCacheEvents`.
|
||||
`calendar.controller.ts`, `calendar.module.ts`, die vier DTOs und die drei
|
||||
Provider unter `providers/` halten null Zugriffe (gemessen mit
|
||||
`grep -rln "prisma" apps/api/src/calendar --include=*.ts` — einzige Datei ist
|
||||
der Dienst). Fuenfter Bereich in Folge, in dem beim Hineinschauen nichts
|
||||
schrumpft. Die eine Bestandsaufnahme-Zeile des Bereichs
|
||||
(`calendar.service.ts`/`calendarSource`, `muss-mandantengebunden`,
|
||||
`ungebunden`) stimmt mit dem Quelltext ueberein.
|
||||
|
||||
**Befund B — keine Transaktion, kein Roh-SQL, kein Hintergrunddienst — aber
|
||||
ein Sonderfall.** `grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/calendar --include=*.ts`:
|
||||
null Treffer; `withTenantTransaction()` wird hier nicht gebraucht.
|
||||
`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/calendar --include=*.ts`:
|
||||
ein einziger Treffer, `providers/ics.provider.ts:100` — ein `setTimeout` fuer
|
||||
den Abbruch eines HTTP-Abrufs nach acht Sekunden, kein Planer. ABER:
|
||||
`refreshCacheInBackground` (Zeile 430) ist eine ABGEKOPPELTE Fortsetzung einer
|
||||
Anfrage: `aggregateEvents` stoesst sie an, wartet nicht auf sie, und sie ruft
|
||||
`fetchAndCacheEvents` mit den Parametern der Anfrage auf. Nach der Umstellung
|
||||
muss sie die Mandantenkennung der Anfrage MITNEHMEN — sie hat keinen anderen
|
||||
Mandanten, aus dem sie schoepfen koennte. Das ist NICHT die Bauform des
|
||||
Hintergrunddienst-Abschnitts (uebergreifend lesen, dann je Mandant binden),
|
||||
sondern ein Anfragekontext, der die Anfrage ueberlebt. Aufgabe 3 haelt das
|
||||
im Hintergrunddienst-Abschnitt als PLAIN-Absatz fest.
|
||||
|
||||
**Befund C — es gibt KEINE Testdatei.** `ls apps/api/src/calendar/*.spec.ts`
|
||||
liefert nichts. Das ist die `dkv`-Form (260909-mir): eine
|
||||
`calendar.service.spec.ts` mit dem Zwei-Klienten-Nachweis ist die
|
||||
VORAUSSETZUNG dafuer, dass irgendeine Aussage dieses Plans nachpruefbar ist —
|
||||
nicht eine Zugabe. Der Dienst hat fuenf Konstruktorabhaengigkeiten
|
||||
(`PrismaService`, `CryptoService`, `ICSProvider`, `CalDAVProvider`,
|
||||
`ExchangeProvider`); die Provider reden mit echten Servern und bekommen
|
||||
Attrappen (`fetchEvents`/`testConnection` als `vi.fn`), sie werden NICHT
|
||||
ausgeuebt. Vorlage fuer Aufbau und Attrappen: `dkv.service.spec.ts`
|
||||
(`makeFakeCrypto`, `__makeBoundClient`, `expectBoundCall`); Vorlage fuer den
|
||||
Wachhund "genau ein Klient je Aufruf": `dashboard.service.spec.ts` ab Zeile
|
||||
545 (`vi.mocked(forTenant).mock.calls.length`).
|
||||
|
||||
**Befund D — die Besitzpruefungen sind ECHT, in allen drei Pfaden, und sie
|
||||
antworten anders als im Bereich `dashboard`.** `updateSource` (176-186),
|
||||
`deleteSource` (227-237) und `testConnection` (248-250) laden ueber die
|
||||
Kennung, pruefen `existing.userId !== userId` gegen die Benutzerkennung aus
|
||||
dem Sitzungsnachweis und werfen `ForbiddenException('Not your calendar source')`
|
||||
— nicht `NotFoundException` wie `dashboard`. Ein fremder Nutzer bekommt damit
|
||||
403 statt 404: die Existenz einer Kennung wird preisgegeben. Kennungen sind
|
||||
UUIDs, also nicht erratbar; nach der Bindung bekommt ein Nutzer eines FREMDEN
|
||||
Mandanten ohnehin 404 (Zeile unsichtbar), nur der Kollege DESSELBEN Mandanten
|
||||
weiterhin 403. Dieser Plan aendert die Antwortsemantik NICHT (waere eine
|
||||
API-Aenderung ausserhalb des Auftrags), haelt sie aber in (k4) fest. **Die
|
||||
Echtheit aller drei Pruefungen ist zur Ausfuehrungszeit erneut zu lesen,
|
||||
bevor sie im SUMMARY behauptet wird** — dieses Vorhaben fand bei `ldap` und
|
||||
`dkv` je einmal keine.
|
||||
|
||||
Was daraus folgt: die beiden (bei `testConnection`: drei) Abfragen jedes
|
||||
Pfads muessen ueber DENSELBEN gebundenen Klienten laufen. Waere das Nachschlagen
|
||||
gebunden und das Schreiben nicht, ginge die Pruefung auf einer Zeile auf, die
|
||||
der Schreibvorgang nicht mehr sieht — und umgekehrt.
|
||||
|
||||
**Befund E — die zerstoerende `dkv`-Form der Zugangsdaten-Erhaltung existiert
|
||||
hier NICHT, und das ist ein Befund, kein Freispruch.** `dkv.service.ts`
|
||||
`saveConfig` liest die bestehende Zeile, entschluesselt das gespeicherte
|
||||
Passwort und verschluesselt es neu, wenn das Formularfeld leer blieb — laeuft
|
||||
dieser Lesezugriff nach dem Scharfschalten leer, wird ein leerer Wert
|
||||
verschluesselt abgelegt (d3, Stelle 5). `calendar.service.ts` `updateSource`
|
||||
(204-208) macht etwas anderes: `if (dto.password !== undefined)` — nur wenn
|
||||
das Feld im Rumpf STEHT, wird geschrieben (gesetzt: verschluesseln; leer:
|
||||
`null`, also ausdrueckliches Loeschen); FEHLT das Feld, wird `encryptedPassword`
|
||||
im Prisma-`update` gar nicht angefasst und bleibt in der Datenbank stehen. Es
|
||||
gibt keinen Lesezugriff, der leer laufen koennte. Auf der Web-Seite
|
||||
(`apps/web/src/components/settings/calendar-source-form.tsx`, um Zeile 149:
|
||||
`if (password) payload.password = password;`) wird ein leer gelassenes
|
||||
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
|
||||
Erhaltung laeuft also per Weglassen, und `update-calendar-source.dto.ts`
|
||||
fuehrt `password` als `@IsOptional()`. Die einzige Stelle, die gespeicherte
|
||||
Zugangsdaten LIEST, um sie zu benutzen, ist `testConnection` (256-258,
|
||||
Verbindungstest mit gespeichertem Passwort) und `fetchAndCacheEvents`
|
||||
(374-376) — beide entschluesseln nur, sie schreiben nichts Entschluesseltes
|
||||
zurueck. **Zur Ausfuehrungszeit an allen vier Stellen erneut nachzulesen;**
|
||||
faellt es anders aus, ist die `dkv`-Reparatur (gemeinsame Bindung, kein
|
||||
stilles Weiterlaufen mit leerem Wert) hier anzuwenden und der Befund
|
||||
umzudrehen, nicht zu uebergehen.
|
||||
|
||||
**Befund F — der Cache-Schluessel traegt eine plattformweit eindeutige
|
||||
Kennung, und die Kette ist vierteilig.** `eventCache` (Zeile 109) wird mit
|
||||
`${userId}:${from}:${to}` (Zeile 337) beschluesselt, ohne Mandantenanteil.
|
||||
Die Benutzerkennung stammt aus `calendar.controller.ts` `extractContext`
|
||||
(`req.user?.id`), das ist laut `apps/api/src/auth/strategies/jwt.strategy.ts`
|
||||
`validate` das Feld `id: payload.sub`, das laut `apps/api/src/auth/auth.service.ts`
|
||||
(Zeile 143, `sub: user.id`) die Datenbankkennung ist, und die traegt laut
|
||||
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`. Die
|
||||
Etappe-3-Entscheidung des Users vom 2026-09-10 (STATE.md, Sitzungsabschnitt,
|
||||
Commit 89fb027) betrifft `User.username`/`User.email` — NICHT `User.id`.
|
||||
Damit lautet das voraussichtliche Urteil: der Schluessel ist sicher, weil er
|
||||
eine UUID traegt, die kein Mandant mit einem anderen teilen kann; er bleibt
|
||||
unveraendert. **Das Urteil wird in Aufgabe 1 Glied fuer Glied nachgesehen und
|
||||
aufgeschrieben, nicht aus diesem Absatz uebernommen.** Faellt ein Glied anders
|
||||
aus (etwa: `req.user.id` waere ein Anmeldename), bekommt der Schluessel in
|
||||
Aufgabe 2 einen Mandantenanteil.
|
||||
|
||||
**Befund G — die Regel kennt keine Benutzerdimension, und das wiegt hier
|
||||
schwerer als anderswo.** `20260909140000_rls_remaining_tenant_tables/migration.sql`
|
||||
Zeile 61: `USING ("tenantId" = current_tenant_id())`, ein Ausdruck, ohne
|
||||
`WITH CHECK`, ohne Benutzerdimension. Gemessen mit
|
||||
`grep -n CalendarSource apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql`:
|
||||
null Treffer — die Regelaenderung von 260910-jab hat diese Tabelle NICHT
|
||||
angefasst, die Regel aus 20260909140000 ist der Stand, gegen den gemessen
|
||||
wird (Aufgabe 1 prueft das zur Laufzeit im Werkzeug, damit die Messfalle aus
|
||||
260910-jab — still die abgeloeste Regel messen — hier nicht zuschlagen kann).
|
||||
Zwei Nutzer DESSELBEN Mandanten sind fuereinander auf Datenbankebene
|
||||
vollstaendig sichtbar — einschliesslich `encryptedPassword`. Die
|
||||
Etappe-3-Entscheidung (2) des Users (Kollegen strikt getrennt, Benutzerdimension
|
||||
in den Regeln, `CalendarSource` ausdruecklich in der Liste) schliesst das
|
||||
spaeter; bis dahin sind der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
|
||||
und die drei Besitzpruefungen der EINZIGE Schutz und bleiben unveraendert.
|
||||
|
||||
**Befund H — keine Eindeutigkeitskette.** `model CalendarSource` traegt ausser
|
||||
dem Primaerschluessel (UUID, clientseitig erzeugt) KEINE Eindeutigkeitsbedingung.
|
||||
Die Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler
|
||||
(WINDOWS #22, `tenders`/`user`/`dashboard`) kann hier strukturell nicht
|
||||
auftreten — der zweite Bereich nach `module-registry`, in dem sie abwesend
|
||||
statt umgangen ist. Es wird deshalb KEINE Konfliktuebersetzung eingebaut,
|
||||
die nichts uebersetzt. Aufgabe 1 misst stattdessen, was ein gebundenes
|
||||
`update` ueber den generierten Client auf eine unsichtbare Zeile tut — das
|
||||
ist der Fall, den `testConnection`/`fetchAndCacheEvents` bei einem Wettlauf
|
||||
zwischen Nachschlagen und Rueckschreiben traefen.
|
||||
|
||||
**Befund I — welcher Code Leere als Abwesenheit deutet, Backend.** Zwei
|
||||
Stellen: `getSources` (124-136) liefert bei null Treffern eine leere Liste,
|
||||
Status 200; `fetchAndCacheEvents` (365) `if (sources.length === 0) return [];`
|
||||
— kehrt VOR dem Cache-Eintrag zurueck, also wird ein leeres Quellenergebnis
|
||||
nicht einmal fuer fuenf Minuten festgehalten, es wird bei jedem Aufruf neu
|
||||
leer geliefert. Die Besitzpruefungspfade (`updateSource`, `deleteSource`,
|
||||
`testConnection`) sind LAUT: ein zu kleines Nachschlagen wirft
|
||||
`NotFoundException('Calendar source not found')`. `addSource` ist laut in die
|
||||
andere Richtung: ungebunden unter der Anwendungsrolle wuerde das Einfuegen
|
||||
mit SQLSTATE 42501 abgewiesen (gemessen in Aufgabe 1).
|
||||
|
||||
**Befund J — welcher Code Leere als Abwesenheit deutet, Frontend, und die
|
||||
Fehlerverschluckung.** Gemessen an drei Web-Dateien, die dieser Plan NICHT
|
||||
aendert:
|
||||
|
||||
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, um Zeile
|
||||
37: `if (sources.length === 0)` — leere Quellenliste heisst
|
||||
`emptyNoSources` ("keine Quelle eingerichtet"); um Zeile 50: `catch { // Silent fail — show empty state }`
|
||||
— ein FEHLER von `GET /calendar/sources` oder `GET /calendar/events`
|
||||
(403, 500, Netzwerk) fuehrt zu `setEvents([])`, also zu demselben leeren
|
||||
Zustand wie eine erfolgreiche leere Antwort.
|
||||
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, um Zeile
|
||||
49: `.catch(() => { // Silent fail — show empty state })`; um Zeile 127:
|
||||
`sources.length === 0` zeigt `sourceEmpty`. Dieselbe Verschluckung auf der
|
||||
Einstellungsseite.
|
||||
3. `calendar-source-form.tsx` (Befund E) — nicht Leere, sondern Weglassen;
|
||||
gehoert hierhin, weil es die Erhaltungsfrage beantwortet.
|
||||
|
||||
Folge nach dem Scharfschalten bei einem zu kleinen Lesepfad: Widget zeigt
|
||||
"keine Quelle eingerichtet", Einstellungsseite zeigt "keine Quelle
|
||||
eingerichtet", der Nutzer legt seine Quelle NEU an (`addSource`, gebunden,
|
||||
gelingt), tippt sein Passwort erneut ein, und die urspruengliche Zeile bleibt
|
||||
unsichtbar liegen — nicht ueberschrieben (anders als `dashboard`), aber
|
||||
verdoppelt, sobald die Ursache behoben ist: doppelte Quellen, doppelte
|
||||
Termine. Und: weil das Frontend Fehler verschluckt, ist selbst ein LAUTER
|
||||
Fehler (etwa ein 500 durch eine halb gebundene Schleife) fuer den Nutzer vom
|
||||
leeren Kalender nicht zu unterscheiden — das Signal existiert nur im
|
||||
Netzwerkprotokoll des Browsers und im API-Log. **Zur Ausfuehrungszeit an den
|
||||
Dateien erneut zu pruefen, bevor es in der Kritikschrift behauptet wird.**
|
||||
|
||||
**Befund K — die halb gebundene Schleife als eigener Gefahrenfall.**
|
||||
`fetchAndCacheEvents` laedt die Quellen (361) und schreibt je Quelle den
|
||||
Synchronstatus zurueck: bei Erfolg (387), bei Fehler im `catch` (398).
|
||||
Waere das Laden gebunden und das Rueckschreiben nicht (oder umgekehrt), traefe
|
||||
das Rueckschreiben nach dem Scharfschalten keine Zeile, Prisma wuerfe 'Record
|
||||
to update not found', der `catch` versuchte das Fehler-Rueckschreiben, das
|
||||
ebenso scheitert, und `Promise.allSettled` liesse das Ergebnis dieser Quelle
|
||||
als `rejected` STILL fallen — die Ereignisse fehlen, `lastSyncError` wird nie
|
||||
gesetzt, der Nutzer sieht "keine Termine". Deshalb: ein gebundener Klient je
|
||||
Methode, und die beiden Rueckschreibungen als Testfaelle festgenagelt.
|
||||
|
||||
**Befund L — der Controller verwirft den Mandanten in fuenf von sechs
|
||||
Handlern.** `extractContext` (Zeile 38-51) loest `userId` und `tenantId` aus
|
||||
dem Sitzungsnachweis auf und bricht ohne einen von beiden mit
|
||||
`ForbiddenException` ab. `addSource` reicht beide durch; `getSources`,
|
||||
`updateSource`, `deleteSource`, `testSource`, `getEvents` nehmen nur die
|
||||
Benutzerkennung. `testSourceConfig` ruft `extractContext` gar nicht auf und
|
||||
hat keinen Datenbankzugriff — er bleibt unveraendert. Die Umstellung ist ein
|
||||
Durchreichen; die Parameterreihenfolge des Dienstes folgt `addSource`:
|
||||
Mandantenkennung unmittelbar hinter der Benutzerkennung.
|
||||
|
||||
</planning_time_findings>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — an der Regel, wie sie nach der Migration 20260910120000 steht, durch den generierten Client</name>
|
||||
<precondition>Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen.</precondition>
|
||||
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
|
||||
<read_first>apps/api/scripts/rls-scratch-check.mjs (Kopf, `extractPolicySql`, `readRlsWidenMigrationSql`, `runDkvAreaChecks`, `runDashboardAreaChecks` VOLLSTAENDIG einschliesslich Pruefung 5b, `buildInlineExtendedClient`, `main`), apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql, apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql, apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql (CREATE TABLE "CalendarSource"), apps/api/prisma/migrations/20260723111946_add_rss_feed_source_and_poll_granularity/migration.sql (ALTER "CalendarSource" ADD "domain"), apps/api/prisma/schema.prisma (model CalendarSource, model User), apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/calendar/dto/update-calendar-source.dto.ts, apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/auth.service.ts (Aufbau des Sitzungsnachweises), apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-source-form.tsx, docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich dkv` (d3) und `## Bereich dashboard` vollstaendig)</read_first>
|
||||
<action>
|
||||
TEIL 1 — die Messung. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um
|
||||
einen elften Abschnitt `runCalendarAreaChecks(adminUrl, scratchRoleUrl, results)`
|
||||
und rufe ihn in `main()` NACH `runDashboardAreaChecks` und VOR
|
||||
`runTransactionShapeMeasurement` auf. Der Abschnitt ist ein Blatt in der
|
||||
Aufrufkette: er legt die Wegwerf-Tabelle `CalendarSource` selbst an, setzt
|
||||
auf keiner Tabelle eines anderen Abschnitts auf, und keine spaetere Pruefung
|
||||
setzt auf seiner auf. Halte diese Reihenfolgebedingung im Kopfkommentar fest,
|
||||
so wie `runDkvAreaChecks` es vormacht.
|
||||
|
||||
Die Regel wird mit `extractPolicySql()` WORTGLEICH aus
|
||||
`20260909140000_rls_remaining_tenant_tables` geschnitten, nicht nachgetippt.
|
||||
Zusaetzlich — die Messfalle aus 260910-jab — prueft der Abschnitt zur
|
||||
Laufzeit, dass `readRlsWidenMigrationSql()` KEINE Regel fuer `"CalendarSource"`
|
||||
enthaelt (Befund G); faende er eine, meldet er eine FEHLGESCHLAGENE Pruefung
|
||||
`calendarsource-regelstand-eindeutig` und bricht ab, statt die abgeloeste
|
||||
Regel weiterzumessen. Findet `extractPolicySql()` die Regel nicht, ebenso
|
||||
Abbruch mit FEHLGESCHLAGEN.
|
||||
|
||||
Die Wegwerf-Tabelle traegt SAEMTLICHE Spalten des Modells `CalendarSource`
|
||||
mit den Typen und Vorgaben der ausgelieferten Migrationen (CREATE TABLE aus
|
||||
20260629130000 plus die Spalte `domain` aus 20260723111946) — nicht nur die,
|
||||
die Roh-SQL braucht. Das ist die Lehre aus Pruefung 5b im Bereich `dashboard`:
|
||||
der generierte Client waehlt standardmaessig JEDE Spalte des Modells aus und
|
||||
scheitert mit P2022 an jeder fehlenden, Roh-SQL merkt das nie. Lies die
|
||||
Spaltenliste zur Laufzeit aus `apps/api/prisma/schema.prisma` (Block
|
||||
`model CalendarSource`, Feldname = erstes Wort jeder Zeile, die nicht leer
|
||||
ist, nicht mit `@@` und nicht mit `//` beginnt) und vergleiche sie mit
|
||||
`information_schema.columns` der angelegten Tabelle — das ist Pruefung 8
|
||||
unten, keine Annahme.
|
||||
|
||||
Testzeilen ueber die Wartungsrolle: zwei Quellen unter TENANT-A mit
|
||||
VERSCHIEDENEN Benutzerkennungen (`user-a1`, `user-a2`), beide mit einem
|
||||
gesetzten `encryptedPassword`-Platzhalter, damit Pruefung 3 zeigen kann, dass
|
||||
der Kollege die verschluesselten Zugangsdaten sieht; eine Quelle unter
|
||||
TENANT-B; alle mit `isVisible = true` und den Pflichtspalten.
|
||||
|
||||
Mindestens zwoelf namentlich benannte Pruefungen, jede mit einer
|
||||
Belegausgabe, die die beobachteten Werte nennt:
|
||||
|
||||
1. `calendarsource-gebunden-nur-eigener-mandant` — gebundener Lesezugriff fuer
|
||||
TENANT-A liefert ausschliesslich A-Zeilen.
|
||||
2. `calendarsource-ungebunden-null-zeilen` — die tragende Belegzeile: der
|
||||
IDENTISCHE Lesezugriff ohne Mandantenkontext liefert null Zeilen, nicht
|
||||
alle vorhandenen.
|
||||
3. `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` —
|
||||
das GELINGEN ist das bestandene Ergebnis: die Regel kennt keine
|
||||
Benutzerdimension, die Zeile von `user-a2` ist unter TENANT-A sichtbar,
|
||||
EINSCHLIESSLICH `encryptedPassword`. Die Belegausgabe sagt ausdruecklich,
|
||||
dass die verschluesselten Zugangsdaten eines Kollegen auf Datenbankebene
|
||||
lesbar sind und die anwendungsseitige Filterung ueber die Benutzerkennung
|
||||
deshalb der einzige Schutz bleibt — bis die Etappe-3-Entscheidung (2)
|
||||
die Benutzerdimension nachzieht.
|
||||
4. `calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile`
|
||||
— die Datenbankseite der drei Besitzpruefungen (Befund I): ein
|
||||
ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null
|
||||
Zeilen; das ist der Weg in `NotFoundException`.
|
||||
5. `calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt` — ein
|
||||
gebundenes Einfuegen unter TENANT-A mit `tenantId = TENANT-B` wird mit
|
||||
SQLSTATE 42501 abgewiesen.
|
||||
6. `calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile` —
|
||||
gebundenes Loeschen ueber die Kennung der B-Zeile entfernt nichts, wirft
|
||||
nichts; die Zeile ist danach ueber die Wartungsrolle noch da.
|
||||
7. `calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`
|
||||
— gebundenes `UPDATE ... WHERE id = <B-Zeile>` unter TENANT-A trifft null
|
||||
Zeilen (Roh-SQL, `lastSyncError` bleibt unveraendert).
|
||||
8. `calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`
|
||||
— Spaltenmenge der Wegwerf-Tabelle ist identisch mit der Feldmenge des
|
||||
Modells im Schema (siehe oben). Faellt sie durch, sind alle folgenden
|
||||
Client-Messungen wertlos — deshalb steht sie VOR ihnen.
|
||||
9. `calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant`
|
||||
— ueber `buildInlineExtendedClient(prisma, 'TENANT-A').calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } })`,
|
||||
also der Abfrage, die `fetchAndCacheEvents` stellt: genau die A1-Zeile.
|
||||
10. `calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen`
|
||||
— dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert eine
|
||||
leere Liste ohne Fehler. Die Belegausgabe nennt es beim Namen: das ist
|
||||
exakt der Wert, den `getSources` als "keine Quelle" und
|
||||
`fetchAndCacheEvents` als "keine Termine" weiterreicht.
|
||||
11. `calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`
|
||||
— `bound.calendarSource.update({ where: { id: <B-Zeile> }, data: { lastSyncError: 'x' } })`
|
||||
unter TENANT-A. Bestanden genau dann, wenn ein Fehler geworfen wird; die
|
||||
Belegausgabe nennt KONSTRUKTORNAME und `code` woertlich, damit (k2) und
|
||||
die Kritikschrift die tatsaechlich gemessene Klasse nennen. Rate das
|
||||
Ergebnis NICHT vorweg — es ist der Wettlauf-Fall aus Befund H/K.
|
||||
12. `calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt`
|
||||
— `bound.calendarSource.create(...)` unter TENANT-A mit `tenantId: 'TENANT-A'`
|
||||
und den Pflichtfeldern (der `addSource`-Weg) gelingt, die Zeile ist danach
|
||||
gebunden lesbar. Das prueft nebenbei, dass die Wegwerf-Tabelle die
|
||||
clientseitig erzeugten Werte (`id`, `createdAt`, `updatedAt`) annimmt.
|
||||
|
||||
Ergaenzt wird nur, gestrichen wird nicht; nenne im SUMMARY die tatsaechlich
|
||||
gezaehlte Zahl, nicht diese.
|
||||
|
||||
TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die
|
||||
Nachpruefungen aus den Befunden D, E, F, J und L tatsaechlich aus und notiere
|
||||
jeweils die Anweisung oder die Datei-und-Zeile, mit der du nachgesehen hast,
|
||||
damit jede Aussage widerlegbar bleibt:
|
||||
|
||||
- Befund D: die drei Besitzpruefungen — steht in `updateSource`,
|
||||
`deleteSource` UND `testConnection` zwischen Nachschlagen und Schreiben ein
|
||||
Vergleich gegen die Benutzerkennung aus dem Sitzungsnachweis? Welche
|
||||
Ausnahme wird geworfen?
|
||||
- Befund E: die Zugangsdaten-Erhaltung — gibt es in `calendar.service.ts`
|
||||
einen Lesezugriff, der ein gespeichertes Passwort laedt, um es neu zu
|
||||
verschluesseln? Wie behandelt `updateSource` die drei Faelle Feld fehlt,
|
||||
Feld leer, Feld gesetzt? Sendet `calendar-source-form.tsx` ein leeres Feld
|
||||
oder laesst es es weg?
|
||||
- Befund F: das Urteil zum Cache-Schluessel — alle vier Glieder (Schema
|
||||
`User.id`, `JwtStrategy.validate`, Aufbau des Sitzungsnachweises in
|
||||
`auth.service.ts`, `extractContext` im Controller), jedes einzeln
|
||||
nachgesehen. Formuliere das Urteil in EINEM Satz, der die Etappe-3-
|
||||
Entscheidung (1) beim Namen nennt und sagt, warum sie den Schluessel
|
||||
beruehrt oder nicht.
|
||||
- Befund J: die drei Web-Dateien — an welcher Zeile wird Leere als
|
||||
"keine Quelle" gedeutet, an welcher Zeile wird ein Fehler verschluckt?
|
||||
- Befund L: welche Handler verwerfen den Mandanten, welche nicht.
|
||||
- Befund B: die Anweisung fuer den Hintergrunddienst-Abschnitt und der
|
||||
Sonderfall der abgekoppelten Cache-Auffrischung.
|
||||
|
||||
Faellt eine Nachpruefung ANDERS aus als in den Planungsbefunden, gilt die
|
||||
Messung; schreibe sie auf und benenne die Abweichung ausdruecklich.
|
||||
|
||||
TEIL 3 — die Kritikschrift. Erweitere
|
||||
`docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt
|
||||
`## Bereich calendar` unmittelbar VOR `## Verweis`, in der Form der
|
||||
vorhandenen Bereichsabschnitte, mit fuenf Unterabschnitten unter dem
|
||||
Buchstaben `k`:
|
||||
|
||||
- `### (k1) Die Messung` — die tatsaechlich beobachtete Werkzeugausgabe
|
||||
woertlich eingerueckt, die tragende Belegzeile benannt, ausdruecklich der
|
||||
Regelstand NACH 20260910120000 samt der Anweisung, mit der belegt ist, dass
|
||||
jene Migration `CalendarSource` nicht anfasst. Nenne, welche Pruefungen
|
||||
ueber den generierten Client laufen und warum (dashboard-Lehre).
|
||||
- `### (k2) Signaltabelle je umgestelltem Pfad` — je Dienstmethode mit
|
||||
Datenbankzugriff eine Zeile (sechs Zeilen), plus eine fuer
|
||||
`refreshCacheInBackground`: Verhalten bei zu wenig Ergebnis und das
|
||||
konkrete Signal, an dem man es saehe — UND, in einer eigenen Spalte oder
|
||||
im Text, ob das Frontend dieses Signal durchlaesst oder verschluckt. Die
|
||||
Zeile zu `fetchAndCacheEvents` nennt die gemessene Fehlerklasse aus
|
||||
Pruefung 11 fuer den Wettlauf-Fall und die halb gebundene Schleife aus
|
||||
Befund K.
|
||||
- `### (k3) Welcher Code Leere als Abwesenheit deutet` — namentlich, mit
|
||||
Dateiname und Stelle, Backend (zwei Stellen, Befund I) getrennt vom
|
||||
Frontend (Befund J). Beschreibe die Folgekette: leerer Kalender,
|
||||
"Synchronisation kaputt" oder "keine Quelle", Neuanlage, Passwort ein
|
||||
zweites Mal eingetippt, urspruengliche Zeile bleibt unsichtbar liegen,
|
||||
Dublette nach Behebung. Grenze ausdruecklich gegen `dashboard` ab: hier
|
||||
wird nichts ueberschrieben, aber der Nutzer gibt Zugangsdaten in ein
|
||||
scheinbar defektes System ein. Und benenne die Fehlerverschluckung als
|
||||
eigenen Punkt: selbst ein LAUTER Fehler der Lesepfade sieht fuer den
|
||||
Nutzer wie ein leerer Kalender aus.
|
||||
- `### (k4) Was dieser Durchlauf bewusst nicht löst` — (a) das Urteil zum
|
||||
Cache-Schluessel mit der vierteiligen Kette und der Anweisung je Glied;
|
||||
(b) der Befund zur Zugangsdaten-Erhaltung (Befund E) mit dem Vergleich zur
|
||||
`dkv`-Form und der Aussage, warum hier kein Lesezugriff leer laufen kann;
|
||||
(c) die fehlende Benutzerdimension der Regel (Befund G) mit Verweis auf
|
||||
die Etappe-3-Entscheidung (2) und dem Hinweis, dass die verschluesselten
|
||||
Zugangsdaten eines Kollegen bis dahin datenbankseitig sichtbar sind;
|
||||
(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D);
|
||||
(e) die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle nicht
|
||||
sichtbar" und die konkrete Vorabpruefung fuer Etappe 4
|
||||
(`rls-preflight.mjs`: physisch vorhandene `CalendarSource`-Zeilen je
|
||||
Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung je
|
||||
Mandant vergleichen — jede Abweichung ist ein Trennungsfehler, kein
|
||||
Erstbenutzer). Eine Laufzeitwarnung ist zu erwaegen und, wenn verworfen,
|
||||
mit eigener Begruendung zu verwerfen (Praezedenz: `getAllActiveConfigs`
|
||||
im Bereich `ldap`, Dauerlaerm auf frischer Installation) — begruende,
|
||||
uebernimm nicht.
|
||||
- `### (k5) Was dieser Durchlauf bewusst nicht anfasst` — das Frontend
|
||||
(nur beschrieben), die drei Provider (reden mit echten Servern, werden
|
||||
nicht getestet), `testConnectionFromConfig` (kein Datenbankzugriff, kein
|
||||
Mandant, unveraendert), der Bereich `favorites` (eigener Bereich), die
|
||||
Antwortsemantik 403/404, und der Fremdkommentar in `ldap-config.service.ts`
|
||||
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschluesselung) —
|
||||
zutreffend, nicht zu aendern.
|
||||
|
||||
Aendere in dieser Aufgabe KEINE Datei unter `apps/api/src`, KEINE unter
|
||||
`apps/api/prisma` und KEINE unter `apps/web`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in calendarsource-gebunden-nur-eigener-mandant calendarsource-ungebunden-null-zeilen calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && echo "$OUT" | grep -qE '^Alle [0-9]+ Pruefungen bestanden\.$' && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test "$N" -ge 100 || { echo "PRUEFUNGSZAHL: $N, erwartet mindestens 100 (88 bisherige plus mindestens 12 neue)"; exit 1; }; } && grep -q 'runCalendarAreaChecks' apps/api/scripts/rls-scratch-check.mjs && awk '/await runDashboardAreaChecks\(/{d=NR} /await runCalendarAreaChecks\(/{c=NR} /await runTransactionShapeMeasurement\(/{t=NR} END{ if(!(d&&c&&t&&d<c&&c<t)){print "REIHENFOLGE in main(): runCalendarAreaChecks muss nach runDashboardAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' apps/api/scripts/rls-scratch-check.mjs && grep -q '^## Bereich calendar$' docs/mandantentrennung-etappe2-fehlerrichtung.md && for S in k1 k2 k3 k4 k5; do grep -qE "^### \($S\) " docs/mandantentrennung-etappe2-fehlerrichtung.md || { echo "FEHLENDER UNTERABSCHNITT: ($S)"; exit 1; }; done && awk '/^## Bereich calendar$/{f=1; next} /^## /{f=0} f && /calendar-widget|calendar-settings-panel|calendar-source-form/{m++} f && /JwtStrategy|jwt\.strategy/{j=1} f && /uuid/{u=1} f && /20260910120000/{r=1} END{ if(m+0 < 3){print "ABSCHNITT (k3): die drei gemessenen Frontend-Dateien sind nicht namentlich genannt"; exit 1} if(!j||!u){print "ABSCHNITT (k4): das Urteil zum Cache-Schluessel nennt nicht beide Glieder der Kette (JwtStrategy, uuid)"; exit 1} if(!r){print "ABSCHNITT (k1): der Regelstand nach 20260910120000 ist nicht benannt"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && npm --prefix apps/api run test && npm --prefix apps/api run type-check && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
|
||||
</verify>
|
||||
<done>`apps/api/scripts/rls-scratch-check.mjs` hat einen elften Abschnitt `runCalendarAreaChecks` in der richtigen Reihenfolge mit mindestens zwoelf neuen, namentlich benannten Pruefungen gegen die aus der ausgelieferten Migration geschnittene Regel, davon vier ueber den generierten Client an einer Wegwerf-Tabelle, deren Spaltenmenge zur Laufzeit gegen das Schema geprueft wird; alle Pruefungen des Werkzeugs bestehen. `docs/mandantentrennung-etappe2-fehlerrichtung.md` hat einen Abschnitt `## Bereich calendar` mit (k1) bis (k5), die tatsaechlich beobachtete Werkzeugausgabe woertlich, die drei Web-Dateien namentlich, das Urteil zum Cache-Schluessel mit vierteiliger Kette, den Befund zur Zugangsdaten-Erhaltung und die Vorabpruefung fuer Etappe 4. Baseline gehalten: 860 Tests gruen, Typpruefung sauber. Unter `apps/api/src`, `apps/api/prisma`, `apps/web` und den Compose-/Umgebungsdateien ist nichts geaendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Die Testlage aus dem Nichts anlegen, dann alle zwoelf Zugriffe binden und den Mandanten durchreichen — Nachschlagen und Schreiben nie getrennt</name>
|
||||
<files>apps/api/src/calendar/calendar.service.spec.ts, apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<read_first>apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/dkv/dkv.service.spec.ts (Kopf, `makeFakePrisma` mit `__makeBoundClient`, `expectBoundCall`, `makeFakeCrypto`, `makeDkvService`), apps/api/src/dashboard/dashboard.service.spec.ts (Zeilen 545-570, Wachhund fuer einen Klienten je Aufruf), apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile`, `computeStandByKey`), docs/mandantentrennung-zugriffsklassifikation.md (Bestandsaufnahme-Zeile `calendar.service.ts`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich calendar` aus Aufgabe 1)</read_first>
|
||||
<behavior>
|
||||
ZUERST die Testlage, DANN die Umstellung. Lege
|
||||
`apps/api/src/calendar/calendar.service.spec.ts` NEU an, in der Form von
|
||||
`dkv.service.spec.ts`: `vi.mock` auf das Bindungshilfsmittel, umgeleitet auf
|
||||
`prisma.__makeBoundClient(tenantId)`; ein handgerollter Prisma-Nachbau mit
|
||||
In-Memory-Zeilen fuer `calendarSource` (`findMany`, `findUnique`, `create`,
|
||||
`update`, `delete`), dessen ungebundene Form NICHT protokolliert und dessen
|
||||
gebundene Form je Aufruf Mandantenkennung, Modell und Methode in ein
|
||||
Bindungsprotokoll schreibt; Attrappen fuer `CryptoService`
|
||||
(`encrypt`/`decrypt` als umkehrbare Textfunktionen) und die drei Provider
|
||||
(`fetchEvents`/`testConnection` als `vi.fn`). Die Provider werden NICHT
|
||||
ausgeuebt.
|
||||
|
||||
Jeder Fall eigenstaendig, jeder mit sprechendem Namen:
|
||||
|
||||
- `getSources`: laeuft gebunden mit der uebergebenen Mandantenkennung im
|
||||
Protokoll; die Antwort traegt `hasCredentials` und NIE `encryptedPassword`.
|
||||
- `getSources` von Nutzer A liefert nicht die Quellen von Nutzer B desselben
|
||||
Mandanten — der `userId`-Filter bleibt, die Bindung ergaenzt ihn.
|
||||
- `addSource`: gebunden, die Mandantenkennung wird als Pflichtwert
|
||||
geschrieben, das Passwort verschluesselt, die Antwort ohne
|
||||
`encryptedPassword`.
|
||||
- `updateSource`: BEIDE Abfragen (Nachschlagen und Aendern) ueber DENSELBEN
|
||||
gebundenen Klienten und dieselbe Mandantenkennung.
|
||||
- `updateSource`, Zugangsdaten-Erhaltung in drei Faellen (Befund E): Feld
|
||||
FEHLT — `encryptedPassword` bleibt unveraendert und es findet KEIN
|
||||
Lesezugriff statt, der ein Passwort laedt; Feld LEER — wird `null`; Feld
|
||||
GESETZT — wird verschluesselt. Diese drei Faelle nageln fest, dass hier
|
||||
keine `dkv`-Form existiert; sie werden rot, sobald jemand eine einbaut.
|
||||
- `updateSource`/`deleteSource`/`testConnection`: die Besitzpruefung bleibt
|
||||
wirksam — eine Quelle eines anderen Benutzers fuehrt weiterhin zu
|
||||
`ForbiddenException`, eine unbekannte Kennung zu `NotFoundException`.
|
||||
Drei Faelle je Ausnahmeart. Diese Faelle sind der Nachweis, dass die
|
||||
Bindung die Pruefung ERGAENZT und nicht ersetzt.
|
||||
- `deleteSource`: beide Abfragen ueber denselben gebundenen Klienten.
|
||||
- `testConnection`: alle drei Abfragen (Nachschlagen, Rueckschreiben bei
|
||||
Erfolg ODER Rueckschreiben im `catch`) ueber denselben gebundenen Klienten;
|
||||
der Provider erhaelt das ENTSCHLUESSELTE Passwort; bei Providerfehler ist
|
||||
die Antwort generisch (kein Passwort, keine Serverdetails).
|
||||
- `aggregateEvents`: das Laden der Quellen laeuft gebunden; die beiden
|
||||
Synchronstatus-Rueckschreibungen (Erfolgspfad UND Fehlerpfad, Befund K)
|
||||
stehen gebunden unter derselben Mandantenkennung im Protokoll. Zwei Faelle.
|
||||
- `aggregateEvents` ohne Quellen: Rueckgabe ist eine leere Liste, kein
|
||||
Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als
|
||||
Abwesenheit als heutiges Verhalten festgehalten, damit eine spaetere
|
||||
Aenderung sichtbar wird.
|
||||
- Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen
|
||||
weiteren Datenbankzugriff; zwei VERSCHIEDENE Benutzerkennungen teilen sich
|
||||
keinen Eintrag (das Urteil aus Aufgabe 1, festgenagelt).
|
||||
- `testConnectionFromConfig`: kein Datenbankzugriff, weder gebunden noch
|
||||
ungebunden — der Nachbau bleibt unberuehrt.
|
||||
- Wachhund: keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen
|
||||
Klienten je Aufruf (Muster `dashboard.service.spec.ts` Zeile 545).
|
||||
|
||||
Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten
|
||||
Zahl genannt, nicht mit der hier aufgelisteten — `tenders` und
|
||||
`module-registry` haben genau an dieser Stelle je eine falsche Zahl
|
||||
behauptet.
|
||||
</behavior>
|
||||
<action>
|
||||
Stelle in `calendar.service.ts` alle zwoelf Zugriffe auf `forTenant()` um.
|
||||
Ein gebundener Klient JE METHODE mit Datenbankzugriff, unter dem woertlichen
|
||||
Namen `tenantPrisma` in der Zuweisungsform, die `rls-access-inventory.spec.ts`
|
||||
erkennt, wie in jedem bereits umgestellten Bereich — sechs Aufrufstellen
|
||||
(`getSources`, `addSource`, `updateSource`, `deleteSource`, `testConnection`,
|
||||
`fetchAndCacheEvents`); `aggregateEvents` und `refreshCacheInBackground`
|
||||
erzeugen keinen eigenen Klienten, sie reichen die Mandantenkennung an
|
||||
`fetchAndCacheEvents` durch. Gebundene Klienten werden nicht zwischen
|
||||
Methoden weitergereicht. Die Mandantenkennung steht in jeder Signatur
|
||||
unmittelbar hinter der Benutzerkennung, wie `addSource` es vormacht.
|
||||
|
||||
Die bestehenden `where`-Filter ueber die Benutzerkennung und die drei
|
||||
Besitzpruefungen bleiben ausnahmslos stehen. Begruende im Quelltext an EINER
|
||||
Stelle (Klassenkommentar), warum sie kein Beiwerk sind: die Regel dieses
|
||||
Bereichs kennt keine Benutzerdimension (gemessen in Aufgabe 1), die
|
||||
verschluesselten Zugangsdaten eines Kollegen sind datenbankseitig sichtbar,
|
||||
und bis zum Scharfschalten ist die Anwendungspruefung ohnehin der einzige
|
||||
wirksame Schutz. Der veraltete Satz "Source config is per-user (D-09), not
|
||||
per-tenant" im Klassenkommentar ist zu praezisieren: je Nutzer UND je
|
||||
Mandant gebunden.
|
||||
|
||||
Schreibe das Urteil zum Cache-Schluessel aus Aufgabe 1 als Kommentar
|
||||
unmittelbar ueber die Zuweisung von `eventCache`: nenne `User.id` als das,
|
||||
was der Schluessel traegt, die Kette bis zum Sitzungsnachweis, und die
|
||||
Etappe-3-Entscheidung (1) mit dem Grund, warum sie den Schluessel nicht
|
||||
beruehrt. Lautet das Urteil aus Aufgabe 1 anders, bekommt der Schluessel
|
||||
einen Mandantenanteil — dann steht das hier, mit dem Grund. Die Entscheidung
|
||||
folgt der Messung, nicht diesem Absatz.
|
||||
|
||||
`fetchAndCacheEvents` und `refreshCacheInBackground` bekommen die
|
||||
Mandantenkennung als Parameter; die abgekoppelte Auffrischung nimmt sie aus
|
||||
der Anfrage mit, die sie angestossen hat. Halte im Kommentar von
|
||||
`refreshCacheInBackground` fest, dass sie den Mandanten der urspruenglichen
|
||||
Anfrage traegt und keinen anderen haben kann.
|
||||
|
||||
Reiche in `calendar.controller.ts` bei den fuenf Handlern, die den bereits
|
||||
aufgeloesten Mandanten heute verwerfen (`getSources`, `updateSource`,
|
||||
`deleteSource`, `testSource`, `getEvents`), diesen an den Dienst durch: jeder
|
||||
der sechs kontextnutzenden Handler nimmt Benutzer- UND Mandantenkennung aus
|
||||
`extractContext` in einer Destrukturierung (die Form, die `addSource` heute
|
||||
schon hat), keiner nimmt nur die Benutzerkennung. Die Mandantenkennung stammt
|
||||
unveraendert aus `extractContext` und damit aus dem Sitzungsnachweis — es
|
||||
entsteht KEINE neue Vertrauensquelle, aus Rumpf oder Pfad wird nichts
|
||||
uebernommen. `testSourceConfig` bleibt unveraendert. Schreibe den
|
||||
Kopfkommentar des Controllers nach, damit er das Durchreichen nennt.
|
||||
|
||||
Kommentare in `calendar.service.ts` duerfen die Zeichenfolge, mit der die
|
||||
Uebersichtstabelle der Klassifikation ungebundene Zugriffe zaehlt (siehe
|
||||
deren Messanweisung), NICHT woertlich enthalten — sonst zaehlt die
|
||||
Buchfuehrung einen Zugriff, den es nicht mehr gibt; das Gate vergleicht die
|
||||
Zaehlung mit und ohne Kommentare.
|
||||
|
||||
Ziehe in DIESER Aufgabe die eine Bestandsaufnahme-Zeile in
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` nach
|
||||
(`calendar.service.ts`/`calendarSource`, Spalte `Stand` auf den gemessenen
|
||||
Wert, Begruendung fortgeschrieben mit Verweis auf 260911-cwh, die gemeinsame
|
||||
Bindung je Pfad und den Befund zur Zugangsdaten-Erhaltung) — sonst ist die
|
||||
maschinelle Bestandspruefung am Ende dieser Aufgabe rot und die Baseline
|
||||
gebrochen (die Lehre aus 260910-exd). Die uebrigen vier handgepflegten
|
||||
Stellen sind Aufgabe 3.
|
||||
|
||||
Aendere keine Datei ausserhalb der vier genannten. Fuehre am Ende dieser
|
||||
Aufgabe einen Falsifizierungsnachweis durch: nimm probeweise die Bindung
|
||||
EINER Synchronstatus-Rueckschreibung in `fetchAndCacheEvents` zurueck
|
||||
(ungebundener Klient nur fuer diese eine Anweisung), ueberzeuge dich, dass
|
||||
die Testlage rot wird, notiere Testname und Fehlermeldung woertlich, und
|
||||
stelle den Zustand wieder her.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && npm --prefix apps/api run test -- src/calendar/calendar.service.spec.ts && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && test -f apps/api/src/calendar/calendar.service.spec.ts && grep -q '__makeBoundClient' apps/api/src/calendar/calendar.service.spec.ts && grep -q "vi.mock('../prisma/prisma-tenant.extension'" apps/api/src/calendar/calendar.service.spec.ts && grep -qi 'cache' apps/api/src/calendar/calendar.service.spec.ts && grep -q 'testConnectionFromConfig' apps/api/src/calendar/calendar.service.spec.ts && grep -q "from '../prisma/prisma-tenant.extension'" apps/api/src/calendar/calendar.service.ts && SRC=$(grep -vE '^\s*(//|\*|/\*)' apps/api/src/calendar/calendar.service.ts) && B=$(printf '%s\n' "$SRC" | grep -o "tenantPrisma\.calendarSource\." | wc -l | tr -d ' ') && { test "$B" -ge 12 || { echo "BINDUNG: nur $B gebundene Modellzugriffe in calendar.service.ts, erwartet mindestens 12"; exit 1; }; } && U=$(printf '%s\n' "$SRC" | grep -o "this\.prisma\.[a-zA-Z]*" | wc -l | tr -d ' ') && { test "$U" -eq 0 || { echo "REST: $U ungebundene Modellzugriffe in calendar.service.ts, erwartet 0 — dieser Bereich hat keinen begruendet ungebundenen Zugriff"; exit 1; }; } && URAW=$(grep -o "this\.prisma\.[a-zA-Z]*" apps/api/src/calendar/calendar.service.ts | wc -l | tr -d ' ') && { test "$URAW" -eq "$U" || { echo "KOMMENTARE in calendar.service.ts nennen die ungebundene Zugriffsform woertlich ($URAW mit, $U ohne Kommentare) — die Uebersichtstabelle wuerde sie mitzaehlen"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this\.prisma' | wc -l | tr -d ' ') && { test "$C" -eq 6 || { echo "KLIENTEN: $C forTenant-Aufrufstellen in calendar.service.ts, erwartet genau 6 (eine je Methode mit Datenbankzugriff, keine zweite in derselben Methode, keine in aggregateEvents/refreshCacheInBackground)"; exit 1; }; } && grep -B10 'eventCache = new Map' apps/api/src/calendar/calendar.service.ts | grep -q 'User.id' && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/calendar --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && H=$(grep -c 'const { userId, tenantId } = this.extractContext(req)' apps/api/src/calendar/calendar.controller.ts) && { test "$H" -eq 6 || { echo "CONTROLLER: $H Handler nehmen Benutzer- und Mandantenkennung, erwartet 6"; exit 1; }; } && test 0 -eq "$(grep -vE '^\s*(//|\*|/\*)' apps/api/src/calendar/calendar.controller.ts | grep -c 'const { userId } = this.extractContext')" && grep -qE '^\| apps/api/src/calendar/calendar\.service\.ts \| calendarSource \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|apps/api/src/calendar/calendar\.service\.ts|apps/api/src/calendar/calendar\.service\.spec\.ts|apps/api/src/calendar/calendar\.controller\.ts|docs/mandantentrennung-zugriffsklassifikation\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
|
||||
</verify>
|
||||
<done>`calendar.service.spec.ts` existiert neu mit dem Zwei-Klienten-Nachweis, Attrappen fuer Verschluesselung und Provider, und deckt jeden in `<behavior>` genannten Fall ab — einschliesslich der drei Erhaltungsfaelle, der Besitzpruefungen je Ausnahmeart, der beiden Rueckschreibungen der Aggregationsschleife und des Wachhunds. In `calendar.service.ts` laufen alle zwoelf Zugriffe ueber `forTenant()` unter dem Namen `tenantPrisma`, genau ein Klient je Methode mit Datenbankzugriff, das Cache-Schluessel-Urteil steht als Kommentar an der Stelle. `calendar.controller.ts` reicht den bereits aufgeloesten Mandanten in allen sechs kontextnutzenden Handlern durch, ohne neue Vertrauensquelle. Die Bestandsaufnahme-Zeile steht auf `gebunden`, die maschinelle Bestandspruefung ist gruen. Der Falsifizierungsnachweis ist durchgefuehrt, zurueckgenommen und mit Testname und Fehlermeldung notiert. Baseline gehalten.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Die uebrigen vier handgepflegten Dokumentstellen nachziehen, den Ledger-Eintrag anlegen und die Gates falsifizieren</name>
|
||||
<files>docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md</files>
|
||||
<read_first>docs/mandantentrennung-zugriffsklassifikation.md (vollstaendig: Uebersichtstabelle samt Messanweisung, Klassen-Verteilung, Hintergrunddienst-Abschnitt, Abschnitt `Was diese Etappe NICHT entscheidet`), .planning/WINDOWS.md (Kopfzeilen, Eintrag 23 und 25 in Tabelle und JSON), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich calendar`), apps/api/src/calendar/calendar.service.ts (Endstand aus Aufgabe 2)</read_first>
|
||||
<action>
|
||||
Ziehe `docs/mandantentrennung-zugriffsklassifikation.md` an den vier noch
|
||||
offenen handgepflegten Stellen nach — jede einzeln nachgesehen, keine
|
||||
ueberflogen (Fehler 4 dieses Vorhabens):
|
||||
|
||||
1. Die Uebersichtszeile `calendar` mit den NEU GEMESSENEN Zahlen aus der im
|
||||
Dokument genannten Messanweisung (beide Spalten), im etablierten Stil mit
|
||||
dem Vermerk des vorherigen Standes (`**war 12/0**`) und der Aufzaehlung der
|
||||
sechs umgestellten Methoden. Anders als bei den sieben Bereichen davor
|
||||
gibt es hier keinen verbleibenden ungebundenen Treffer zu begruenden —
|
||||
schreibe das ausdruecklich hin, mit dem Grund (Pflicht-Mandantenkennung,
|
||||
kein uebergreifender Pfad).
|
||||
2. Die Summenzeile derselben Tabelle, mit fortgeschriebener Herkunftsspur im
|
||||
Hinweisfeld.
|
||||
3. Die Klassen-Verteilung samt der Zahl in ihrer Ueberschrift. Aendert sich
|
||||
nichts, schreibe in einem `**Stand 260911-cwh**`-Absatz ausdruecklich hin,
|
||||
dass sich nichts aendert und warum (das eine Paar war bereits richtig
|
||||
klassifiziert, nur der `Stand` wechselte in Aufgabe 2) — eine
|
||||
unveraenderte Tabelle ohne Vermerk ist von einer vergessenen nicht zu
|
||||
unterscheiden.
|
||||
4. Den Abschnitt zur Hintergrunddienst-Falle: ergaenze einen PLAIN-Absatz
|
||||
(KEINEN Aufzaehlungspunkt in der Form der bestehenden Faelle und KEINE
|
||||
Zeile der Form "Der ... Fall, anderer Bauart", sonst waere die Zahl in der
|
||||
Ueberschrift falsch), der festhaelt, dass dieser Bereich keinen sechsten
|
||||
Fall hinzufuegt, mit der Anweisung aus Aufgabe 1 — UND der den Sonderfall
|
||||
`refreshCacheInBackground` benennt: eine abgekoppelte Fortsetzung einer
|
||||
Anfrage, die den Mandanten der Anfrage mitnimmt, nicht die Bauform
|
||||
"uebergreifend lesen, dann je Mandant binden". Die Abwesenheit steht da,
|
||||
damit sie nicht wie ein Uebersehen aussieht.
|
||||
|
||||
Ergaenze den Abschnitt `Was diese Etappe NICHT entscheidet` um die
|
||||
Entscheidung dieses Bereichs zur offenen Architekturfrage — er bindet
|
||||
dienst-intern, ein Klient je Methode, wie alle acht Bereiche vor ihm.
|
||||
|
||||
Lege in `.planning/WINDOWS.md` einen neuen OFFENEN Eintrag an, und zwar ueber
|
||||
`gsd-tools windows append --kind deviation --phase quick-260911-cwh --file apps/web/src/components/dashboard/widgets/calendar-widget.tsx --description "..."`,
|
||||
damit Tabelle, JSON-Block und die Zaehler im Dateikopf zusammenpassen —
|
||||
nicht von Hand. Inhalt: die lautlose Auspraegung der umgekehrten
|
||||
Fehlerrichtung im Bereich calendar aus (k3): zu kleines Leseergebnis auf
|
||||
`getSources`/`fetchAndCacheEvents` sieht aus wie "keine Quelle eingerichtet"
|
||||
beziehungsweise "keine Termine"; das Frontend (`calendar-widget.tsx`,
|
||||
`calendar-settings-panel.tsx`, mit Stellen) verschluckt zusaetzlich LAUTE
|
||||
Fehler derselben Pfade in denselben leeren Zustand; der Nutzer legt seine
|
||||
Quelle neu an und tippt seine Exchange-/CalDAV-Zugangsdaten ein zweites Mal
|
||||
in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt
|
||||
unsichtbar liegen und wird nach Behebung zur Dublette; die konkrete
|
||||
Vorabpruefung fuer Etappe 4 aus (k4)(e); an dieselbe Bedingung gebunden wie
|
||||
#18; das Frontend wird von 260911-cwh NICHT geaendert. Verweise auf #23 und
|
||||
#25 als Familie. Pruefe nach dem Anlegen, dass der Eintrag in Tabelle UND
|
||||
JSON-Block steht und die Kopfzaehler stimmen.
|
||||
|
||||
Fuehre am Ende zwei Falsifizierungsnachweise durch, jeder zurueckgenommen und
|
||||
mit Meldung woertlich notiert: (a) setze die Bestandsaufnahme-Zeile dieses
|
||||
Bereichs probeweise auf einen falschen `Stand` — die maschinelle
|
||||
Bestandspruefung muss rot werden; (b) setze die Uebersichtszeile probeweise
|
||||
auf eine falsche Zahl — das herleitende Gate dieser Aufgabe muss fehlschlagen.
|
||||
|
||||
Aendere in dieser Aufgabe KEINE Datei unter `apps/`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && DU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/calendar | grep -v spec | wc -l | tr -d ' ') && DB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/calendar | grep -v spec | wc -l | tr -d ' ') && { test "$DU" -eq 0 || { echo "UEBERSICHTSZEILE: ungebundene Rohtreffer in apps/api/src/calendar sind $DU, erwartet 0"; exit 1; }; } && { test "$DB" -ge 12 || { echo "UEBERSICHTSZEILE: gebundene Rohtreffer in apps/api/src/calendar sind $DB, erwartet mindestens 12"; exit 1; }; } && { grep -qE "^\| calendar \| ${DU} \| ${DB} \| \*\*war 12/0\*\*" docs/mandantentrennung-zugriffsklassifikation.md || { echo "UEBERSICHTSZEILE calendar nennt nicht die neu gemessenen Zahlen ${DU}/${DB} im etablierten Stil"; exit 1; }; } && awk -F'|' '$2 ~ /^ *[a-z][a-z-]* *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *[0-9]+ *$/ { su+=$3; sb+=$4; n++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 !~ /^ *$/ { g3=$3; g4=$4; gsub(/[^0-9]/,"",g3); gsub(/[^0-9]/,"",g4); ru=g3+0; rb=g4+0; seen=1 } END { if (!seen || n+0 != 12) { print "UEBERSICHTSTABELLE nicht erkannt, Bereichszeilen: " n; exit 1 } if (su+0 != ru || sb+0 != rb) { print "SUMMENZEILE stimmt nicht: Bereichszeilen ergeben " su "/" sb ", Summenzeile nennt " ru "/" rb; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk -F'|' '$2 ~ /^ *apps\/api\/src\// { k=$4; gsub(/^ +| +$/,"",k); cls[k]++; pairs++ } $2 ~ /^ *(muss-mandantengebunden|keine-mandantengebundene-tabelle|beides|bewusst-uebergreifend) *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *$/ { k=$2; gsub(/^ +| +$/,"",k); v=$3; gsub(/[^0-9]/,"",v); tab[k]=v+0; tn++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 ~ /^ *$/ { v=$3; gsub(/[^0-9]/,"",v); tsum=v+0; tseen=1 } /^## Klassen-Verteilung/ { h=$0; gsub(/[^0-9]/,"",h); hp=h+0; hseen=1 } END { if (tn+0 != 4 || !tseen || !hseen) { print "KLASSEN-VERTEILUNG nicht erkannt: Klassenzeilen " tn ", Summenzeile " tseen ", Ueberschrift " hseen; exit 1 } if (tsum != pairs+0) { print "KLASSEN-SUMME stimmt nicht: Bestandsaufnahme hat " pairs " Paare, Tabellensumme nennt " tsum; exit 1 } if (hp != pairs+0) { print "UEBERSCHRIFT der Klassen-Verteilung nennt " hp " Paare, Bestandsaufnahme hat " pairs; exit 1 } s=0; for (k in tab) { if (tab[k] != cls[k]+0) { print "KLASSE " k ": Tabelle nennt " tab[k] ", Bestandsaufnahme zaehlt " cls[k]+0; exit 1 } s+=tab[k] } if (s != pairs+0) { print "KLASSENZEILEN ergeben " s ", Bestandsaufnahme hat " pairs; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Klassen-Verteilung/{f=1; next} /^## /{f=0} f && /Stand 260911-cwh/{m=1} END{ if(!m){print "KLASSEN-VERTEILUNG: kein Stand-Vermerk fuer 260911-cwh — eine unveraenderte Tabelle ohne Vermerk ist von einer vergessenen nicht zu unterscheiden"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk 'BEGIN{split("ein zwei drei vier x sechs sieben acht neun",w," "); w[5]="fünf"} /^## Der Hintergrunddienst als Falle/{seen=1; head=$0; f=1; next} /^## /{f=0} f && /^- \*\*`/{n++} f && /^\*\*Der .* Fall, anderer Bauart/{n++} f && /calendar/{m=1} f && /refreshCacheInBackground/{r=1} END{ if(!seen){print "ABSCHNITT Hintergrunddienst nicht gefunden"; exit 1} want="## Der Hintergrunddienst als Falle — " w[n] " Fälle"; if(head != want){printf "HINTERGRUNDDIENST-UEBERSCHRIFT nennt \"%s\", gezaehlt wurden %d Faelle, erwartet \"%s\"\n", head, n, want; exit 1} if(!m){print "ABSCHNITT Hintergrunddienst nennt diesen Bereich nicht — die Abwesenheit eines sechsten Falls ist nicht belegt"; exit 1} if(!r){print "ABSCHNITT Hintergrunddienst nennt den Sonderfall refreshCacheInBackground nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} f && /calendar/{m=1} END{ if(!m){print "ABSCHNITT \"Was diese Etappe NICHT entscheidet\" nennt den Bereich calendar nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && python3 -c "
|
||||
import re,sys
|
||||
s=open('.planning/WINDOWS.md',encoding='utf-8').read()
|
||||
rows=[l for l in s.splitlines() if re.match(r'^\| \d+ \|', l)]
|
||||
ids=re.findall(r'\"id\": (\d+),', s)
|
||||
if len(rows)!=len(ids): print('WINDOWS: %d Tabellenzeilen, %d JSON-Eintraege' % (len(rows), len(ids))); sys.exit(1)
|
||||
mine=[l for l in rows if 'quick-260911-cwh' in l]
|
||||
if len(mine)!=1: print('WINDOWS: erwartet genau einen Tabelleneintrag fuer quick-260911-cwh, gefunden %d' % len(mine)); sys.exit(1)
|
||||
mid=re.match(r'^\| (\d+) \|', mine[0]).group(1)
|
||||
if mid not in ids: print('WINDOWS: Eintrag %s fehlt im JSON-Block' % mid); sys.exit(1)
|
||||
if '| open |' not in mine[0]: print('WINDOWS: Eintrag %s ist nicht offen' % mid); sys.exit(1)
|
||||
if 'calendar' not in mine[0] or 'calendar-widget' not in mine[0]: print('WINDOWS: Eintrag %s nennt Bereich oder Widget-Datei nicht' % mid); sys.exit(1)
|
||||
tc=int(re.search(r'^total_count: (\d+)', s, re.M).group(1)); oc=int(re.search(r'^open_count: (\d+)', s, re.M).group(1))
|
||||
if tc!=len(rows): print('WINDOWS: total_count %d, Tabellenzeilen %d' % (tc,len(rows))); sys.exit(1)
|
||||
op=len([l for l in rows if '| open |' in l])
|
||||
if oc!=op: print('WINDOWS: open_count %d, offene Zeilen %d' % (oc,op)); sys.exit(1)
|
||||
" && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/calendar --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|apps/api/src/calendar/calendar\.service\.ts|apps/api/src/calendar/calendar\.service\.spec\.ts|apps/api/src/calendar/calendar\.controller\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
|
||||
</verify>
|
||||
<done>Alle fuenf handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und maschinell gegatet: Bestandsaufnahme-Zeile (Aufgabe 2), Uebersichtszeile mit neu gemessenen Zahlen, Summenzeile, Klassen-Verteilung mit ausdruecklichem Unveraendert-Vermerk, Hintergrunddienst-Abschnitt mit Messbeleg fuer die Abwesenheit eines sechsten Falls und dem Sonderfall der abgekoppelten Auffrischung; dazu der Abschnitt `Was diese Etappe NICHT entscheidet`. `.planning/WINDOWS.md` traegt den neuen offenen Eintrag ueber das Werkzeug in Tabelle, JSON-Block und Kopfzaehlern. Beide Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und woertlich notiert. Baseline gehalten, Schalter unveraendert aus.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
Konfiguriert: ASVS-Stufe 1, blockierend ab `high`.
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser/Benutzer → Kalender-API | Benutzer- und Mandantenkennung stammen ausschliesslich aus dem validierten Sitzungsnachweis (`extractContext` liest `req.user.id` und `req.tenantId`/`req.user.tenantId`, bricht ohne beide ab). Die Quellenkennung im Pfad ist frei waehlbare Nutzereingabe. |
|
||||
| Nutzer A → Kalenderquellen (samt Zugangsdaten) von Nutzer B DESSELBEN Mandanten | Die Grenze, die die Datenbank NACHWEISLICH nicht zieht — die Regel kennt nur die Mandantendimension. Gezogen allein vom `userId`-Filter in den Listenpfaden und den drei Besitzpruefungen. |
|
||||
| Mandant A → Zeilen des Mandanten B | Die Grenze dieses Plans. Heute nur von der Anwendung gezogen, nach diesem Plan zusaetzlich von der Datenbank — wirksam erst nach Etappe 4. |
|
||||
| API → PostgreSQL | Die Zeilenschutz-Grenze. Heute wirkungslos (Rolle mit `BYPASSRLS`, WINDOWS #18) — dieser Plan bereitet sie vor, schaltet sie NICHT scharf. |
|
||||
| API → externe Kalenderserver (Exchange/CalDAV/ICS) | Die Grenze, ueber die entschluesselte Zugangsdaten gehen. Dieser Plan aendert daran nichts; er sorgt dafuer, dass nur die Zugangsdaten der eigenen Quelle dorthin gehen. |
|
||||
| Anfrage → abgekoppelte Cache-Auffrischung | Ein Anfragekontext, der die Anfrage ueberlebt: die Auffrischung traegt Benutzer- und Mandantenkennung der Anfrage, die sie angestossen hat, und kann keinen anderen Mandanten haben. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-CWH-01 | Information Disclosure | `calendar.service.ts`, `getSources`/`fetchAndCacheEvents`/`testConnection`/`updateSource` (Lesepfade, die `encryptedPassword` laden) | high | mitigate | Quer-Lesen der Kalenderquellen eines fremden Mandanten EINSCHLIESSLICH der verschluesselten Exchange-/CalDAV-Zugangsdaten — die Klasse, in der der Bereich `dkv` Postfach-Zugangsdaten behandelt hat. Alle Lesepfade werden gebunden; `SOURCE_SAFE_SELECT` bleibt, `encryptedPassword` verlaesst die API weiterhin nie. Gemessen in Aufgabe 1: `calendarsource-gebunden-nur-eigener-mandant`, `calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant`. |
|
||||
| T-CWH-02 | Information Disclosure | Regel auf `CalendarSource`, keine Benutzerdimension | high | accept | Quer-Lesen der Zugangsdaten eines Kollegen DESSELBEN Mandanten auf Datenbankebene. Gemessen in Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`, das Gelingen IST das Ergebnis, Belegausgabe nennt `encryptedPassword`). Bewusst akzeptiert und aufgezeichnet: der Schutz bleibt vollstaendig beim `userId`-Filter und den drei Besitzpruefungen, die dieser Plan NICHT entfernt und als Testfaelle festnagelt; die Benutzerdimension in der Regel ist die Etappe-3-Entscheidung (2) des Users vom 2026-09-10, `CalendarSource` steht dort ausdruecklich in der Liste. Bis dahin ist derselbe Zustand wie heute — dieser Plan verschlechtert ihn nicht. |
|
||||
| T-CWH-03 | Tampering | `calendar.service.ts`, `updateSource`/`deleteSource`/`testConnection` | high | mitigate | Quer-Aendern, Quer-Loeschen oder fremder Verbindungstest ueber die Kennung im Pfad — die Bauform, die bei `ldap` und `dkv` je eine Luecke riss. Hier existiert die Besitzpruefung in allen drei Pfaden (Befund D, zur Ausfuehrungszeit erneut zu lesen), sie wird durch die Bindung ERGAENZT, und alle Abfragen jedes Pfads laufen ueber DENSELBEN gebundenen Klienten. Als Testfaelle je Ausnahmeart festgenagelt; datenbankseitig gemessen mit `calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile` und `calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`. |
|
||||
| T-CWH-04 | Denial of Service | `calendar.service.ts` `getSources`/`fetchAndCacheEvents`, plus `calendar-widget.tsx`/`calendar-settings-panel.tsx` | high | mitigate | Die umgekehrte Fehlerrichtung: ein zu kleines Leseergebnis liefert einen leeren Kalender und eine leere Quellenliste, kein Fehlerbild — gelesen als "Synchronisation kaputt" oder "keine Quelle eingerichtet". Das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand. Der Nutzer legt neu an und tippt Zugangsdaten in ein scheinbar defektes System. Vollstaendige Bindung aller Lesepfade; Signaltabelle mit Spalte "laesst das Frontend das Signal durch"; namentliche Liste in (k3); offener Ledger-Eintrag mit konkreter Vorabpruefung fuer Etappe 4. Das Frontend wird NICHT geaendert — beschrieben, nicht unterbrochen. |
|
||||
| T-CWH-05 | Tampering | `calendar.service.ts` `fetchAndCacheEvents`, Synchronstatus-Rueckschreibungen | high | mitigate | Die halb gebundene Schleife (Befund K): Laden gebunden, Rueckschreiben nicht (oder umgekehrt) — nach dem Scharfschalten scheitert das Rueckschreiben, der `catch` scheitert erneut, `Promise.allSettled` laesst die Ereignisse dieser Quelle STILL fallen, `lastSyncError` wird nie gesetzt. Ein Klient je Methode; beide Rueckschreibungen als Testfaelle festgenagelt; der Falsifizierungsnachweis von Aufgabe 2 nimmt genau eine dieser Bindungen zurueck. Fehlerklasse des Wettlauf-Falls gemessen (`...-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`). |
|
||||
| T-CWH-06 | Tampering | `calendar.service.ts` `updateSource`, Zugangsdaten-Erhaltung | medium | mitigate | Zugangsdatenverlust ueber einen Erhaltungspfad, der nach dem Scharfschalten leer laeuft (die `dkv`-Form: lesen, entschluesseln, neu verschluesseln). Befund E: die Form existiert hier NICHT — Erhaltung per Weglassen des Felds, das Web-Formular laesst ein leeres Feld weg, kein Lesezugriff kann leer laufen. Zur Ausfuehrungszeit an allen vier Stellen nachgelesen (Aufgabe 1), als drei Testfaelle festgenagelt (Aufgabe 2), damit eine spaeter eingebaute Erhaltungsform sofort rot wird. Faellt der Befund anders aus, wird die `dkv`-Reparatur angewandt. |
|
||||
| T-CWH-07 | Information Disclosure | `calendar.service.ts` `eventCache`, Schluessel ohne Mandantenanteil | medium | mitigate | Quer-Lesen von Ereignissen ueber einen Cache-Treffer, falls Benutzerkennungen zwischen Mandanten kollidieren koennten. Befund F: der Schluessel traegt `User.id` (`@default(uuid())`), nicht den Anmeldenamen; die Etappe-3-Entscheidung (1) betrifft `username`/`email`, nicht `id`. Vierteilige Kette in Aufgabe 1 Glied fuer Glied nachgesehen; Urteil als Kommentar an der Stelle und in (k4); Testfall, dass zwei Benutzerkennungen keinen Eintrag teilen. Lautet das Urteil anders, bekommt der Schluessel einen Mandantenanteil. |
|
||||
| T-CWH-08 | Elevation of Privilege | `calendar.controller.ts`, Durchreichen des Mandanten | high | mitigate | Die Mandantenkennung koennte beim Umbau versehentlich aus Rumpf oder Pfad statt aus dem Sitzungsnachweis genommen werden. Alle sechs kontextnutzenden Handler nehmen sie unveraendert aus `extractContext`, das ohne Mandant mit `ForbiddenException` abbricht — keine neue Vertrauensquelle. Gate: sechs Handler mit Benutzer- und Mandantenkennung, keiner mit Benutzerkennung allein; Erlaubnisliste des Umfangs. |
|
||||
| T-CWH-09 | Information Disclosure | Besitzpruefung, 403 statt 404 | low | accept | Ein Kollege desselben Mandanten erfaehrt ueber 403 die Existenz einer fremden Quellenkennung. Kennungen sind UUIDs; ein fremder Mandant bekommt nach der Bindung 404. Antwortsemantik wird NICHT geaendert (API-Aenderung ausserhalb des Auftrags); festgehalten in (k4)(d). |
|
||||
| T-CWH-10 | Information Disclosure | `validateUrlNotPrivate`, SSRF-Ausnahme fuer Exchange | low | accept | Bestehender Zustand aus Phase 05/T-05-11 (Exchange-Server liegen im Intranet, Pruefung fuer diesen Typ uebersprungen). Dieser Plan aendert daran nichts und schwaecht es nicht. |
|
||||
| T-CWH-11 | Spoofing | Sitzungsnachweis | low | accept | Mandanten- oder Benutzerkennung aus Rumpf oder Pfad. Bereits in Phase 05 behandelt (T-05-12); dieser Plan aendert daran nichts. |
|
||||
| T-CWH-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
|
||||
Nach Abschluss aller drei Aufgaben:
|
||||
|
||||
1. `node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen
|
||||
bestanden (88 bisherige plus die neuen), Rueckgabewert 0.
|
||||
2. `npm --prefix apps/api run test` meldet mindestens 860 Tests gruen in
|
||||
mindestens 57 Dateien (56 bisherige plus die neue Testdatei dieses
|
||||
Bereichs).
|
||||
3. `npm --prefix apps/api run type-check` ist sauber.
|
||||
4. `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts`
|
||||
ist gruen — die Bestandsaufnahme stimmt mit dem Quelltext ueberein.
|
||||
5. Alle fuenf handgepflegten Stellen der Klassifikation sind maschinell
|
||||
gegatet und gruen, und die Zaehlgates LEITEN ihre Werte aus den im Dokument
|
||||
selbst genannten Messanweisungen ab.
|
||||
6. Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich gegenueber
|
||||
`50b3a36` geaendert hat, ist eine der sieben in `files_modified` genannten
|
||||
(oder liegt unter `.planning/`). Unter `apps/api/prisma` und `apps/web` hat
|
||||
sich nichts geaendert. Beide Pruefungen laufen gegen den Ausgangsstand,
|
||||
nicht gegen `HEAD`.
|
||||
7. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`; keine Compose-
|
||||
oder Umgebungsdatei ist angefasst; nichts in Active Directory.
|
||||
8. Der Ledger-Eintrag steht in Tabelle, JSON-Block und Kopfzaehlern von
|
||||
`.planning/WINDOWS.md`.
|
||||
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
|
||||
- Die zwoelf Zugriffe des Bereichs sind vollstaendig gebunden, genau ein
|
||||
Klient je Methode mit Datenbankzugriff, keiner unentschieden, keiner
|
||||
begruendet ungebunden — und die Abwesenheit eines ungebundenen Rests ist
|
||||
in der Uebersichtstabelle ausdruecklich begruendet.
|
||||
- Keine Zeile dieses Bereichs wird halb gebunden: Nachschlagen und Schreiben
|
||||
jeder Besitzpruefung und Laden und Rueckschreiben der Aggregationsschleife
|
||||
laufen ueber denselben Klienten — festgenagelt, falsifiziert.
|
||||
- Die umgekehrte Fehlerrichtung ist an der echten Regel in ihrem AKTUELLEN
|
||||
Stand gemessen, mindestens vier Pruefungen laufen ueber den generierten
|
||||
Client an einer schemagleichen Wegwerf-Tabelle, und die Fehlerverschluckung
|
||||
des Frontends ist als eigener Punkt benannt.
|
||||
- Das Urteil zum Cache-Schluessel ist vierteilig belegt und steht in Code und
|
||||
Kritikschrift; der Befund zur Zugangsdaten-Erhaltung ist an vier Stellen
|
||||
nachgelesen und als drei Testfaelle festgenagelt.
|
||||
- Die drei Besitzpruefungen sind gelesen, bestaetigt, ergaenzt und als
|
||||
Testfaelle festgenagelt.
|
||||
- Alle drei Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und
|
||||
im SUMMARY mit Testname beziehungsweise Meldung festgehalten.
|
||||
- Jede im SUMMARY genannte Zahl (neue Werkzeugpruefungen, neue Testfaelle,
|
||||
umgestellte Zugriffe) ist ABGEZAEHLT, nicht aus diesem Plan abgeschrieben.
|
||||
- Baseline gehalten am Ende jeder Aufgabe. Der Schalter ist weiterhin AUS.
|
||||
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-SUMMARY.md` when done
|
||||
</output>
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: quick-260911-cwh
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [prisma, postgresql, row-level-security, multi-tenancy, nestjs, vitest, calendar]
|
||||
|
||||
requires:
|
||||
- phase: quick-260910-krx
|
||||
provides: Bereich dashboard vollstaendig gebunden, Muster fuer generierten-Client-Messung (Pruefung 5b)
|
||||
provides:
|
||||
- "Bereich calendar (calendar.service.ts, Modell calendarSource) vollstaendig an forTenant() gebunden, alle zwoelf Datenbankzugriffe"
|
||||
- "Elfter Abschnitt runCalendarAreaChecks in rls-scratch-check.mjs mit 13 neuen Pruefungen (davon vier ueber den generierten Client)"
|
||||
- "calendar.service.spec.ts NEU angelegt — erste Testlage dieses Bereichs, 23 Testfaelle mit Zwei-Klienten-Nachweis"
|
||||
- "Kritikschrift Abschnitt '## Bereich calendar' mit gemessenem Cache-Schluessel-Urteil und Zugangsdaten-Erhaltungsbefund"
|
||||
- "WINDOWS #26: offener Ledger-Eintrag zur lautlosen Fehlerrichtung im Bereich calendar"
|
||||
affects: [etappe-2-mandantentrennung, calendar-modul, rls-scratch-check-tooling]
|
||||
|
||||
actuals:
|
||||
tokens: 25378
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 508d9e43015df192f5b86c39ac1e81c983f100b8
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "forTenant(this.prisma, tenantId) als lokale tenantPrisma-Konstante je Methode mit Datenbankzugriff (dienst-interner Weg, wie alle acht Bereiche vor calendar)"
|
||||
- "Zwei-Klienten-Testnachweis via __makeBoundClient(tenantId) — ungebundener Fake protokolliert nicht, gebundener schon"
|
||||
- "Generierter-Client-Messung an einer schemagleichen Wegwerf-Tabelle (Spaltenmenge zur Laufzeit gegen schema.prisma geprueft) statt Roh-SQL, fuer Fehlerklassen die die Anwendung tatsaechlich sieht"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/calendar/calendar.service.spec.ts
|
||||
modified:
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- apps/api/src/calendar/calendar.service.ts
|
||||
- apps/api/src/calendar/calendar.controller.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- .planning/WINDOWS.md
|
||||
|
||||
key-decisions:
|
||||
- "Cache-Schluessel (userId:from:to) bleibt OHNE Mandantenanteil — die vierteilige Kette (schema.prisma User.id @default(uuid()) -> JwtStrategy.validate -> auth.service.ts sub:user.id -> extractContext) zeigt eine plattformweit eindeutige UUID, die Etappe-3-Entscheidung (1) betrifft User.username/User.email, nicht User.id"
|
||||
- "Keine neue Fehleruebersetzung fuer die drei Besitzpruefungen noetig: der gemessene Wettlauf-Fall wirft PrismaClientKnownRequestError/P2025 (nicht PrismaClientUnknownRequestError wie im Bereich dashboard), und dieser Pfad ist durch die vorgeschaltete findUnique-Besitzpruefung strukturell unerreichbar"
|
||||
- "refreshCacheInBackground wird NICHT als sechster Hintergrunddienst-Fall gefuehrt — sie iteriert nicht ueber Mandanten, sondern traegt den Mandanten der Anfrage, die sie angestossen hat (Befund B)"
|
||||
- "Antwortsemantik 403 (Forbidden bei fremdem Besitz) vs. 404 (NotFound bei unbekannter Kennung) bleibt unveraendert — waere eine API-Aenderung ausserhalb des Auftrags"
|
||||
|
||||
requirements-completed: [WINDOWS-18, ETAPPE-2-CALENDAR]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Alle zwoelf Datenbankzugriffe von calendar.service.ts laufen gebunden ueber forTenant(), ein tenantPrisma-Klient je Methode mit Datenbankzugriff"
|
||||
requirement: ETAPPE-2-CALENDAR
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/calendar/calendar.service.spec.ts — 23 Testfaelle"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts — Bestandsaufnahme stimmt mit Quelltext ueberein"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "node apps/api/scripts/rls-scratch-check.mjs — runCalendarAreaChecks, 13 neue Pruefungen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Kritikschrift misst die umgekehrte Fehlerrichtung an der ausgelieferten Regel (Migration 20260909140000, bestaetigt unveraendert durch 20260910120000) inklusive vier Pruefungen ueber den generierten Prisma-Client an einer schemagleichen Wegwerf-Tabelle"
|
||||
requirement: WINDOWS-18
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "node apps/api/scripts/rls-scratch-check.mjs — Pruefungen calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients, calendarsource-generierter-client-*"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Fuenf handgepflegte Dokumentstellen der Klassifikation nachgezogen und maschinell gegen den Quelltext gegatet; WINDOWS-Ledger-Eintrag #26 in Tabelle, JSON-Block und Kopfzaehlern konsistent"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Plan-Verify-Gates Aufgabe 3 (Uebersichtszeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst-Abschnitt, WINDOWS.md-Konsistenz) — manuell in der Ausfuehrung nachvollzogen"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 21min
|
||||
completed: 2026-09-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260911-cwh: Etappe 2 Mandantentrennung, Bereich calendar — Summary
|
||||
|
||||
**Alle zwoelf `calendarSource`-Datenbankzugriffe in `calendar.service.ts` auf `forTenant()` umgestellt (ein `tenantPrisma`-Klient je Methode), mit einer aus dem Nichts angelegten Testlage (23 Faelle), 13 neuen Wegwerf-Pruefungen — vier davon ueber den generierten Prisma-Client — und einem gemessenen, schriftlich festgehaltenen Urteil zum Cache-Schluessel sowie zur Zugangsdaten-Erhaltung.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 21 min
|
||||
- **Started:** 2026-09-11T07:39:07Z
|
||||
- **Completed:** 2026-09-11T08:00:00Z (ca.)
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 7 (6 modified, 1 neu angelegt)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Neunter Bereich der Etappe 2 (Mandantentrennung) abgeschlossen: `calendar` — der einzige bisher umgestellte Bereich, der verschluesselte Zugangsdaten zu FREMDEN Servern (Exchange/CalDAV/ICS) haelt
|
||||
- `apps/api/scripts/rls-scratch-check.mjs`: elfter Abschnitt `runCalendarAreaChecks` mit 13 namentlich benannten Pruefungen, davon vier ueber den generierten Client an einer Wegwerf-Tabelle, deren 17-Spalten-Deckung zur Laufzeit gegen `schema.prisma` geprueft wird — alle 101 Pruefungen des Werkzeugs bestehen
|
||||
- `calendar.service.spec.ts`: NEU angelegt (dieser Bereich hatte zuvor KEINE Testdatei), 23 Testfaelle mit Zwei-Klienten-Nachweis, decken jeden Datenbankpfad, alle drei Besitzpruefungen je Ausnahmeart, die drei Zugangsdaten-Erhaltungsfaelle, beide Rueckschreibungen der Aggregationsschleife und den Wachhund fuer "genau ein Klient je Aufruf" ab
|
||||
- Kritikschrift (`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt `## Bereich calendar`) mit (k1)-(k5): die tatsaechlich gemessene Fehlerklasse des Wettlauf-Falls (`PrismaClientKnownRequestError`/P2025, ANDERS als im Bereich `dashboard`), das vierteilige Cache-Schluessel-Urteil, die Backend- UND Frontend-Leere-als-Abwesenheit-Kette samt Fehlerverschluckung
|
||||
- Fuenf handgepflegte Stellen der Klassifikation nachgezogen (Bestandsaufnahme-Zeile, Uebersichtszeile 0/12, Summenzeile 83/159, Klassen-Verteilung mit Stand-Vermerk, Hintergrunddienst-Abschnitt mit dem Sonderfall `refreshCacheInBackground`), WINDOWS-Ledger-Eintrag #26 angelegt
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Aufgabe 1: Fehlerrichtung messen** - `bf5fc4d` (feat)
|
||||
2. **Aufgabe 2: Testlage anlegen, alle zwoelf Zugriffe binden** - `77cb124` (feat)
|
||||
3. **Aufgabe 3: Restliche Dokumentstellen, WINDOWS-Ledger, Falsifizierungsnachweise** - `e0e163e` (docs)
|
||||
|
||||
_Kein TDD-Modus fuer diesen Plan — die Testlage wurde in Aufgabe 2 als Voraussetzung neu angelegt, nicht als RED/GREEN-Zyklus einzeln entwickelt._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/scripts/rls-scratch-check.mjs` - elfter Abschnitt `runCalendarAreaChecks` (13 neue Pruefungen), Helfer `readSchemaModelFieldNames()`
|
||||
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` - Abschnitt `## Bereich calendar` mit (k1)-(k5)
|
||||
- `apps/api/src/calendar/calendar.service.spec.ts` - NEU, 23 Testfaelle, Zwei-Klienten-Nachweis
|
||||
- `apps/api/src/calendar/calendar.service.ts` - alle zwoelf Zugriffe gebunden, Klassenkommentar praezisiert, Cache-Schluessel-Urteil und Hintergrunddienst-Begruendung als Kommentare
|
||||
- `apps/api/src/calendar/calendar.controller.ts` - sechs kontextnutzende Handler reichen Benutzer- UND Mandantenkennung durch
|
||||
- `docs/mandantentrennung-zugriffsklassifikation.md` - fuenf handgepflegte Stellen nachgezogen
|
||||
- `.planning/WINDOWS.md` - neuer offener Eintrag #26
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Cache-Schluessel bleibt ohne Mandantenanteil — vierteilige Kette gemessen, Urteil in Code UND Kritikschrift verankert (siehe `key-decisions` oben)
|
||||
- Keine neue Fehleruebersetzung fuer die Besitzpruefungen: gemessener Wettlauf-Fall (P2025) ist ueber die vorgeschaltete Pruefung strukturell unerreichbar
|
||||
- `refreshCacheInBackground` bleibt ausserhalb des Hintergrunddienst-Abschnitts (kein sechster Fall) — sie traegt den Mandanten der Anfrage, iteriert nicht ueber mehrere Mandanten
|
||||
- Antwortsemantik 403/404 bewusst unveraendert gelassen
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Eine, dokumentarischer Art: Aufgabe 2 war im Plan als `tdd="true"` markiert, wurde aber nicht als RED/GREEN-Zyklus je Testfall entwickelt — die Testdatei entstand als Voraussetzung im Ganzen (siehe Hinweis oben unter der Testtabelle). Inhaltlich ohne Folge: die Falsifizierung durch Rueckbau ersetzt den RED-Nachweis. Dieser Abschnitt sagte zunaechst 'None' und widersprach damit dem eigenen Hinweis weiter oben; vom Verifizierer bemerkt, hier berichtigt. Alle Planungsbefunde (A-L) wurden zur Ausfuehrungszeit nachgeprueft und bestaetigt.
|
||||
|
||||
## Falsifizierungsnachweise (drei, alle durchgefuehrt und zurueckgenommen)
|
||||
|
||||
1. **Aufgabe 2 — halb gebundene Aggregationsschleife:** Die Bindung der Synchronstatus-Rueckschreibung im Erfolgspfad von `fetchAndCacheEvents` wurde probeweise zurueckgenommen (`tenantPrisma.calendarSource.update` → `this.prisma.calendarSource.update`). Ergebnis: der benannte Test `aggregateEvents, Erfolgspfad: das Laden der Quellen UND die Synchronstatus-Rueckschreibung bei Erfolg stehen gebunden unter derselben Mandantenkennung im Protokoll` ging rot mit der Meldung `erwarteter gebundener Aufruf calendarSource.update(tenant=t1) fehlt im Protokoll: [{"tenantId":"t1","model":"calendarSource","method":"findMany"}]: expected false to be true`. Bindung wiederhergestellt, Testlage danach wieder gruen (23/23).
|
||||
2. **Aufgabe 3a — falscher Stand in der Bestandsaufnahme:** Die Zeile `calendar.service.ts`/`calendarSource` wurde probeweise auf `Stand: ungebunden` zurueckgesetzt. Ergebnis: `rls-access-inventory.spec.ts` ging rot mit `Abweichender Stand (Dokument vs. Quelltext): apps/api/src/calendar/calendar.service.ts::calendarSource — dokumentiert=ungebunden, gemessen=gebunden`. Zurueckgesetzt, Test danach wieder gruen (10/10).
|
||||
3. **Aufgabe 3b — falsche Uebersichtszahl:** Die Uebersichtszeile `calendar` wurde probeweise auf `5 | 12` (statt der gemessenen `0 | 12`) gesetzt. Ergebnis: das herleitende Gate (`grep -qE "^\| calendar \| ${DU} \| ${DB} \| \*\*war 12/0\*\*"`) meldete `UEBERSICHTSZEILE calendar nennt nicht die neu gemessenen Zahlen 0/12 im etablierten Stil`. Zurueckgesetzt, Gate danach wieder erfuellt.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - keine externe Diensteinrichtung erforderlich.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Neunter von zwoelf Bereichen der Etappe 2 abgeschlossen — Klassen-Verteilung (63 Paare) unveraendert, nur die `Stand`-Spalte des `calendar`-Paares gewechselt
|
||||
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, `BYPASSRLS`) bleibt AUS — keine Compose-/Umgebungsdatei angefasst, keine Schema-/Migrationsaenderung, nichts in Active Directory
|
||||
- Baseline am Ende gehalten: 883 Tests gruen (57 Dateien, davon 23 neu), Typpruefung sauber, `rls-scratch-check.mjs` meldet 101/101 Pruefungen bestanden
|
||||
- WINDOWS #26 bleibt bis nach dem Scharfschalten (Etappe 4) offen — an dieselbe Bedingung gebunden wie #18, Familie mit #23/#25
|
||||
- Naechster Bereich der Etappe 2 (zehnter von zwoelf) ist aus der Klassen-Verteilung in `docs/mandantentrennung-zugriffsklassifikation.md` zu waehlen
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created/modified files verified present on disk; all three task commit hashes (`bf5fc4d`, `77cb124`, `e0e163e`) verified present in git history.
|
||||
|
||||
---
|
||||
*Phase: quick-260911-cwh*
|
||||
*Completed: 2026-09-11*
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
---
|
||||
phase: quick-260911-cwh
|
||||
verified: 2026-09-11T10:15:00Z
|
||||
status: passed
|
||||
score: 12/12 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/WINDOWS.md"
|
||||
- ".planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-PLAN.md"
|
||||
- ".planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-SUMMARY.md"
|
||||
- "apps/api/scripts/rls-scratch-check.mjs"
|
||||
- "apps/api/src/calendar/calendar.controller.ts"
|
||||
- "apps/api/src/calendar/calendar.service.spec.ts"
|
||||
- "apps/api/src/calendar/calendar.service.ts"
|
||||
- "docs/mandantentrennung-etappe2-fehlerrichtung.md"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md"
|
||||
covered_digest: "v1:sha256:f092c2eea8be58064da21108d78ef37879ab4183bdc6c622c699cee603c0f434"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick Task 260911-cwh: Mandantentrennung Etappe 2, Bereich `calendar` — Verification Report
|
||||
|
||||
**Task Goal:** Bind all 12 `calendarSource` access sites in `calendar.service.ts`, create the area's missing spec coverage, pin the credential-path exoneration and the cache-key verdict as tests, and keep the classification document's five hand-maintained sections in sync.
|
||||
|
||||
**Verified:** 2026-09-11T10:15:00Z
|
||||
**Status:** passed
|
||||
**Commits reviewed:** bf5fc4d, 77cb124, e0e163e, 06038b9 (base 508d9e4), all present in `git log`, working tree clean before and after this review.
|
||||
|
||||
## Re-verification Checklist Results
|
||||
|
||||
### 1. Coverage — all 12 sites bound, one `tenantPrisma` per method, six methods
|
||||
|
||||
Independently counted (not taken from SUMMARY):
|
||||
|
||||
```
|
||||
grep -ro "tenantPrisma\.calendarSource\." apps/api/src/calendar/calendar.service.ts | wc -l → 12
|
||||
grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/calendar/calendar.service.ts | wc -l → 0
|
||||
grep -o "forTenant(this\.prisma" (non-comment source) → 6
|
||||
```
|
||||
|
||||
Distribution matches the plan's Befund A exactly: `getSources` (1), `addSource` (1),
|
||||
`updateSource` (2), `deleteSource` (2), `testConnection` (3),
|
||||
`fetchAndCacheEvents` (3) = 12. `aggregateEvents`/`refreshCacheInBackground`
|
||||
create no client of their own (confirmed by reading both bodies — they only
|
||||
pass `tenantId` through to `fetchAndCacheEvents`).
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 2. Credential exoneration pinned, not asserted
|
||||
|
||||
Three test cases exist in `calendar.service.spec.ts` (lines 289, 303, 313):
|
||||
"Feld FEHLT" (missing), "Feld LEER" (empty → `null`), "Feld GESETZT" (set →
|
||||
re-encrypted). The "Feld FEHLT" case asserts `crypto.decrypt`/`crypto.encrypt`
|
||||
were **not called** and that `update(...).data` does **not** have an
|
||||
`encryptedPassword` key at all — this is a real pin, not a shape check. A
|
||||
regression that reintroduced the `dkv` read-decrypt-re-encrypt form would
|
||||
fail this test on both the decrypt-not-called assertion and the
|
||||
data-shape assertion.
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 3. Cache-key verdict
|
||||
|
||||
Code comment directly above `eventCache = new Map(...)` in
|
||||
`calendar.service.ts` (lines 125-142) states the full four-link chain
|
||||
(`User.id @id @default(uuid())` → `auth.service.ts` `sub: user.id` →
|
||||
`JwtStrategy.validate` `id: payload.sub` → `extractContext`) and concludes
|
||||
the key stays without a tenant component because Etappe-3-Entscheidung (1)
|
||||
only touches `username`/`email`, not `id`. Independently confirmed against
|
||||
`apps/api/prisma/schema.prisma:30` (`id String @id @default(uuid())`),
|
||||
`apps/api/src/auth/auth.service.ts:143,332` (`sub: user.id`), and
|
||||
`apps/api/src/auth/strategies/jwt.strategy.ts:29` (`id: payload.sub`) — the
|
||||
chain in the comment matches the actual source. The identical verdict is
|
||||
also written in `docs/mandantentrennung-etappe2-fehlerrichtung.md` (k4)(a),
|
||||
naming the same four links.
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 4. Both write-backs in `fetchAndCacheEvents` bound and pinned
|
||||
|
||||
Success path (line 429, inside the `try`) and catch path (line 440, inside
|
||||
the `catch`) both call `tenantPrisma.calendarSource.update(...)`. Two named
|
||||
tests cover this (`aggregateEvents, Erfolgspfad...` line 428,
|
||||
`aggregateEvents, Fehlerpfad (Providerfehler)...` line 439), both asserting
|
||||
`expectBoundCall(..., 'update')`.
|
||||
|
||||
**Falsification performed by this verifier** (not just re-reading the
|
||||
SUMMARY's claim): reverted the success-path binding
|
||||
(`tenantPrisma.calendarSource.update` → `this.prisma.calendarSource.update`
|
||||
at line 429) and ran `npm --prefix apps/api run test -- src/calendar/calendar.service.spec.ts`.
|
||||
Result: 1 of 23 tests failed —
|
||||
`aggregateEvents, Erfolgspfad: ... → AssertionError: erwarteter gebundener Aufruf calendarSource.update(tenant=t1) fehlt im Protokoll: [{"tenantId":"t1","model":"calendarSource","method":"findMany"}]`
|
||||
— exactly the failure mode described in the SUMMARY's own falsification
|
||||
proof #1. Reverted the change; re-ran the same test file: 23/23 green.
|
||||
Working tree confirmed clean afterward (`git status --short` empty).
|
||||
|
||||
**VERIFIED** (independently reproduced, not merely re-read).
|
||||
|
||||
### 5. Ownership checks survived
|
||||
|
||||
`updateSource` (line 221), `deleteSource` (line 273), `testConnection`
|
||||
(line 289) all still compare `existing.userId !== userId` /
|
||||
`source.userId !== userId` against the session-derived `userId` parameter
|
||||
and throw `ForbiddenException`. Three ForbiddenException test cases and
|
||||
three NotFoundException test cases exist and pass.
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 6. No conflict translation added
|
||||
|
||||
`grep` over `calendar.service.ts` shows no `P2002`/unique-constraint
|
||||
handling anywhere; the only Prisma-error-sensitive code is the generic
|
||||
`catch` blocks in `testConnection`/`fetchAndCacheEvents`, which existed
|
||||
before this plan and are unrelated to uniqueness. Matches Befund H (no
|
||||
uniqueness chain on `CalendarSource` besides the client-generated UUID PK).
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 7. Frontend NOT touched
|
||||
|
||||
`git diff --name-only 508d9e4 HEAD` and `git diff --name-only 50b3a36 HEAD`
|
||||
both show zero files under `apps/web`. The silent-empty-state behaviour is
|
||||
recorded, not fixed: `docs/mandantentrennung-etappe2-fehlerrichtung.md` (k3)
|
||||
names `calendar-widget.tsx`/`calendar-settings-panel.tsx`/`calendar-source-form.tsx`
|
||||
by file and line, and `.planning/WINDOWS.md` entry #26 (open, table row +
|
||||
JSON block both present) describes the same silent-empty-state defect with
|
||||
"das Frontend wird von 260911-cwh NICHT geaendert" stated explicitly.
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 8. Generated-client measurements — committed and honest
|
||||
|
||||
Re-ran `apps/api/scripts/rls-scratch-check.mjs` live against the running
|
||||
`tessera-ctl-db-1` container (freshly resolved IP `172.19.0.2`, not reused
|
||||
from any cached value):
|
||||
|
||||
```
|
||||
DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}')
|
||||
TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs
|
||||
```
|
||||
|
||||
Exit code: 0. Final line: `Alle 101 Pruefungen bestanden.` — matches the
|
||||
SUMMARY's claimed total (101). All 13 `calendarsource-*` named checks passed
|
||||
(confirmed by name), including the 4 generated-client checks:
|
||||
`calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant`,
|
||||
`calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen`,
|
||||
`calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`,
|
||||
`calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt`.
|
||||
|
||||
The runtime column-set comparison (`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
|
||||
actually executed and printed both sets: schema.prisma yields 17 fields,
|
||||
the scratch table's `information_schema.columns` yields the same 17 fields,
|
||||
sorted identically — this is a real comparison, not a hardcoded pass.
|
||||
|
||||
Call-order gate confirmed: `runCalendarAreaChecks` is invoked after
|
||||
`runDashboardAreaChecks` and before `runTransactionShapeMeasurement` in
|
||||
`main()` (source inspection, line 3335).
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
### 9. Five hand-maintained sections — class distribution recomputed independently
|
||||
|
||||
Recomputed the class distribution directly from the Bestandsaufnahme rows
|
||||
with an independent `awk` one-liner (not copy-pasted from the plan's gate):
|
||||
|
||||
```
|
||||
muss-mandantengebunden: 31
|
||||
keine-mandantengebundene-tabelle: 17
|
||||
beides: 13
|
||||
bewusst-uebergreifend: 2
|
||||
TOTAL: 63
|
||||
```
|
||||
|
||||
Matches the documented table exactly (31/17/13/2, Summe 63, heading "63
|
||||
Paare"). Also recomputed the overview table's column sums across all 12
|
||||
area rows (tenders 35/27, groups 0/31, ldap 4/26, dkv 1/22, user 8/14,
|
||||
module-registry 7/10, dashboard 1/12, auth 8/5, calendar 0/12, tenant 8/0,
|
||||
favorites 7/0, settings 4/0) → 83/159, matching the documented **Summe**
|
||||
row. The `calendar` row itself reads `0 | 12` with the required
|
||||
`**war 12/0**` marker and explicit no-remaining-unbound justification. The
|
||||
"Stand 260911-cwh" unchanged-marker paragraph is present under
|
||||
`## Klassen-Verteilung`. The background-service section header reads
|
||||
"fünf Fälle" (matching its own bullet count) and contains a plain paragraph
|
||||
naming `refreshCacheInBackground` and `calendar` without adding a sixth
|
||||
bullet-point case (confirmed: no new `- **\`...\`**` entry and no "Der ...
|
||||
Fall, anderer Bauart" line was added for this area). `## Was diese Etappe
|
||||
NICHT entscheidet` mentions `calendar`. WINDOWS #26 present in table row,
|
||||
JSON block, `open` status, and header counters (`total_count: 26` = 26
|
||||
table rows, `open_count: 8` = 8 rows with `| open |`, both independently
|
||||
recomputed and matching).
|
||||
|
||||
**VERIFIED (all five sections, independently recomputed, not re-read from
|
||||
SUMMARY).**
|
||||
|
||||
### 10. Falsification proofs — three claimed
|
||||
|
||||
1. **Aggregation write-back binding revert** — independently reproduced by
|
||||
this verifier (see item 4 above). Confirmed identical failure mode and
|
||||
message pattern to the one quoted in the SUMMARY.
|
||||
2. **Bestandsaufnahme `Stand` mismatch** — mechanism confirmed present:
|
||||
`apps/api/src/prisma/rls-access-inventory.spec.ts:321` contains the exact
|
||||
assertion template `Abweichender Stand (Dokument vs. Quelltext):` that
|
||||
the SUMMARY's quoted failure message is built from. Not independently
|
||||
re-triggered (would require editing and reverting the classification doc
|
||||
under time budget), but the underlying gate genuinely exists and matches
|
||||
the described mechanism precisely — not a fabricated quote.
|
||||
3. **Uebersichtszeile wrong-number gate** — the awk gate quoted in the
|
||||
SUMMARY (`grep -qE "^\| calendar \| ${DU} \| ${DB} \| \*\*war 12/0\*\*"`)
|
||||
is present verbatim in the plan's Aufgabe 3 `<verify>` block, which
|
||||
executed successfully as part of the task-3 commit's automated gate (the
|
||||
task would not have committed otherwise, since these are `autonomous:
|
||||
true` plans with gated commits).
|
||||
|
||||
**VERIFIED** (one directly reproduced by this verifier, two confirmed as
|
||||
genuinely-wired mechanisms matching the quoted evidence, not fabricated).
|
||||
|
||||
### 11. Constraints held
|
||||
|
||||
- **Allow-list scope against `50b3a36`:** `git diff --name-only 50b3a36 HEAD`
|
||||
shows exactly the 7 files in `files_modified` (`rls-scratch-check.mjs`,
|
||||
`mandantentrennung-etappe2-fehlerrichtung.md`,
|
||||
`calendar.service.spec.ts`, `calendar.service.ts`,
|
||||
`calendar.controller.ts`, `mandantentrennung-zugriffsklassifikation.md`,
|
||||
`.planning/WINDOWS.md`) plus `.planning/STATE.md` and the plan/summary
|
||||
files themselves under `.planning/`. No `apps/api/prisma`, no
|
||||
`apps/web`, no compose/env file.
|
||||
- **Providers not exercised against real endpoints:** confirmed —
|
||||
`icsProvider`/`caldavProvider`/`exchangeProvider` are `vi.fn()` mocks
|
||||
throughout the spec file; no network call is made.
|
||||
- **Switch OFF:** no compose/env file in the diff; nothing under
|
||||
`apps/api/prisma` changed (`git diff --name-only 50b3a36 -- apps/api/prisma`
|
||||
is empty).
|
||||
|
||||
**VERIFIED.**
|
||||
|
||||
## Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | All 12 `calendarSource` accesses bound via `tenantPrisma`, one client per method (6 methods) | ✓ VERIFIED | Independent grep count: 12 bound, 0 unbound, 6 `forTenant()` call sites |
|
||||
| 2 | Ownership checks (`updateSource`/`deleteSource`/`testConnection`) read+write over the same bound client, pinned as tests | ✓ VERIFIED | Source read + 2 tests per path (ForbiddenException/NotFoundException) + `expectBoundCall` pairs |
|
||||
| 3 | Reverse error direction measured against the real post-20260910120000 rule, ≥4 checks via generated client on a schema-matching scratch table | ✓ VERIFIED | Live tool run: 101/101, 13 named `calendarsource-*` checks, 4 via generated client, column-set comparison executed with real 17/17 match |
|
||||
| 4 | Reverse error direction's silent-empty shape named, including frontend swallowing | ✓ VERIFIED | (k3) names both backend spots and 3 frontend files; WINDOWS #26 open entry |
|
||||
| 5 | Credential-preservation question answered by measurement, pinned as 3 tests | ✓ VERIFIED | 3 tests at lines 289/303/313, one asserting decrypt/encrypt NOT called |
|
||||
| 6 | Cache-key verdict, 4-link chain, written in code AND critique | ✓ VERIFIED | Comment above `eventCache`, (k4)(a) in critique doc, chain matches schema/auth source |
|
||||
| 7 | Ownership checks read (not assumed), kept, pinned | ✓ VERIFIED | Same as #2 |
|
||||
| 8 | Test suite created from nothing, two-client proof, providers not exercised | ✓ VERIFIED | 23 test cases, `__makeBoundClient`, providers are `vi.fn()` |
|
||||
| 9 | Controller passes resolved tenant through to all 5 (of 6) previously-discarding handlers | ✓ VERIFIED | 6/6 handlers destructure `{ userId, tenantId }`, 0 handlers take `userId` alone |
|
||||
| 10 | Five hand-maintained classification sections in sync, allow-list gated | ✓ VERIFIED | Independently recomputed sums/class distribution match exactly |
|
||||
| 11 | Baseline held at end of every task | ✓ VERIFIED (via orchestrator measurement) | 883/883 tests, 57 files, type-check clean — independently measured by orchestrator per task instructions |
|
||||
|
||||
**Score:** 12/12 truths verified (0 present-but-behavior-unverified).
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/scripts/rls-scratch-check.mjs` | 11th section `runCalendarAreaChecks`, ≥12 named checks, ≥4 via generated client | ✓ VERIFIED | 13 named checks in normal path, 4 generated-client; live-run confirmed |
|
||||
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich calendar` with (k1)-(k5) | ✓ VERIFIED | All 5 subsections present, content spot-checked |
|
||||
| `apps/api/src/calendar/calendar.service.spec.ts` | New, two-client proof, mocks for crypto+3 providers | ✓ VERIFIED | 23 tests, `vi.mock` on `prisma-tenant.extension`, `makeFakeCrypto`, `makeFakeProviders` |
|
||||
| `apps/api/src/calendar/calendar.service.ts` | All 12 accesses bound, cache-key verdict as comment | ✓ VERIFIED | 12/12 bound, comment present and accurate |
|
||||
| `apps/api/src/calendar/calendar.controller.ts` | 5 discarding handlers pass tenant through | ✓ VERIFIED | 6/6 handlers pass `{ userId, tenantId }` |
|
||||
| `docs/mandantentrennung-zugriffsklassifikation.md` | Bestandsaufnahme, overview, sum, class distribution, background-service section, "was NICHT entscheidet" | ✓ VERIFIED | All independently recomputed and matched |
|
||||
| `.planning/WINDOWS.md` | New open entry via `gsd-tools windows append` | ✓ VERIFIED | Entry #26, table+JSON+counters consistent |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `calendar.controller.ts extractContext` | `CalendarService` methods | Destructured `{ userId, tenantId }` passed as args | ✓ WIRED | All 6 handlers |
|
||||
| `fetchAndCacheEvents` success write-back | `tenantPrisma.calendarSource.update` | Same client as the `findMany` load | ✓ WIRED | Falsified and restored by this verifier |
|
||||
| `fetchAndCacheEvents` catch write-back | `tenantPrisma.calendarSource.update` | Same client as the `findMany` load | ✓ WIRED | Test exists, source confirmed |
|
||||
| `eventCache` key | `User.id` via `extractContext`→`JwtStrategy`→`auth.service.ts`→`schema.prisma` | Comment chain | ✓ WIRED | All 4 links independently checked against source |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Reverting one write-back binding breaks exactly the named test | Edited line 429, ran `vitest run src/calendar/calendar.service.spec.ts`, restored | 22 passed, 1 failed with quoted message; then 23/23 after restore | ✓ PASS |
|
||||
| `rls-scratch-check.mjs` passes live against running DB | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` | exit 0, "Alle 101 Pruefungen bestanden." | ✓ PASS |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None. Grepped modified files for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` and empty-return stubs — no matches beyond pre-existing, unrelated code.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. All must-haves resolved to VERIFIED via direct source inspection, independent recomputation, and live tool execution (including one directly reproduced falsification).
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
None found. Working tree is clean; all commits (bf5fc4d, 77cb124, e0e163e,
|
||||
06038b9) are present in `git log` on `main`, in the correct order, on top of
|
||||
base `508d9e4`. Scope is allow-listed against `50b3a36` with zero
|
||||
unexpected files. The one minor documentation wrinkle — the plan's Aufgabe 2
|
||||
carries `tdd="true"` while the SUMMARY states "Kein TDD-Modus fuer diesen
|
||||
Plan" under "Deviations from Plan: None" — is a self-contradiction inside
|
||||
the SUMMARY's own prose (claims "executed exactly as written" while also
|
||||
describing a TDD-flag deviation), but it has no bearing on the delivered
|
||||
artifacts: the test file exists, is substantive, and all pins are real and
|
||||
falsifiable as demonstrated above. Noted here for completeness, not raised
|
||||
as a gap since it does not affect goal achievement.
|
||||
|
||||
---
|
||||
|
||||
*Verified: 2026-09-11T10:15:00Z*
|
||||
*Verifier: Claude (gsd-verifier)*
|
||||
@@ -42,6 +42,7 @@ const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
|
||||
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
|
||||
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
|
||||
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
|
||||
const SCHEMA_PRISMA_PATH = join(__dirname, '../prisma/schema.prisma');
|
||||
|
||||
/**
|
||||
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
|
||||
@@ -2765,6 +2766,336 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Feldnamen eines Prisma-Modellblocks direkt aus
|
||||
* `apps/api/prisma/schema.prisma`, statt sie im Werkzeug zu wiederholen
|
||||
* (Pruefung 8 in `runCalendarAreaChecks`, Lehre aus Pruefung 5b im Bereich
|
||||
* `dashboard`: der generierte Client waehlt standardmaessig JEDE Spalte des
|
||||
* Modells aus und scheitert mit P2022 an jeder fehlenden — eine
|
||||
* Wegwerf-Tabelle mit unvollstaendigem Spaltensatz wuerde das nie zeigen,
|
||||
* Roh-SQL merkt es ohnehin nie). Feldname = erstes Wort jeder nicht-leeren
|
||||
* Zeile im Modellblock, die nicht mit `@@` (Modell-Attribute wie
|
||||
* `@@index`) und nicht mit `//` (Kommentarzeile) beginnt.
|
||||
*/
|
||||
function readSchemaModelFieldNames(modelName) {
|
||||
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
|
||||
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
|
||||
const match = schemaSource.match(re);
|
||||
if (!match) return [];
|
||||
const fields = [];
|
||||
for (const rawLine of match[1].split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('@@')) continue;
|
||||
if (line.startsWith('//')) continue;
|
||||
const firstWord = line.split(/\s+/)[0];
|
||||
if (firstWord) fields.push(firstWord);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
|
||||
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
|
||||
* an der Regel WORTGLEICH aus der ausgelieferten Migration
|
||||
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
|
||||
* Werkzeug nachgetippt (vgl. runDkvAreaChecks/runDashboardAreaChecks).
|
||||
*
|
||||
* Prueft zur Laufzeit zusaetzlich die Messfalle aus 260910-jab: die
|
||||
* `*_rls_widen_membership_grant_and_platform_read`-Migration (260910-jab)
|
||||
* darf KEINE eigene Regel fuer "CalendarSource" enthalten (Befund G) —
|
||||
* faende sich dort eine, waere der Regelstand nicht mehr eindeutig auf
|
||||
* 20260909140000 zurueckzufuehren und dieser Abschnitt braeche ab, statt
|
||||
* die abgeloeste Regel weiterzumessen.
|
||||
*
|
||||
* Legt die Wegwerf-Tabelle "CalendarSource" selbst neu an, mit SAEMTLICHEN
|
||||
* Spalten des Modells (nicht nur denen, die Roh-SQL braucht — Pruefung 8,
|
||||
* die dashboard-Lehre aus Pruefung 5b) und setzt auf keiner Tabelle eines
|
||||
* anderen Abschnitts auf: er ist ein Blatt in der Aufrufkette, muss NACH
|
||||
* runDashboardAreaChecks() und VOR runTransactionShapeMeasurement() laufen
|
||||
* (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der von
|
||||
* runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser Abschnitt
|
||||
* aendert daran nichts, und keine spaetere Pruefung setzt auf der hier
|
||||
* angelegten Tabelle auf.
|
||||
*/
|
||||
async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
const widenMigrationSql = readRlsWidenMigrationSql();
|
||||
const widenHasOwnCalendarSourcePolicy =
|
||||
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource'));
|
||||
report(
|
||||
results,
|
||||
'calendarsource-regelstand-eindeutig',
|
||||
!widenHasOwnCalendarSourcePolicy,
|
||||
widenHasOwnCalendarSourcePolicy
|
||||
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
|
||||
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
|
||||
);
|
||||
if (widenHasOwnCalendarSourcePolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
|
||||
const calendarSourcePolicy = remainingMigrationSql
|
||||
? extractPolicySql(remainingMigrationSql, 'CalendarSource')
|
||||
: null;
|
||||
|
||||
if (!calendarSourcePolicy) {
|
||||
report(
|
||||
results,
|
||||
'calendarsource-policy-aus-migration-gefunden',
|
||||
false,
|
||||
'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "CalendarSource" (
|
||||
id text PRIMARY KEY,
|
||||
"userId" text NOT NULL,
|
||||
"tenantId" text NOT NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
"exchangeMode" text,
|
||||
domain text,
|
||||
url text NOT NULL,
|
||||
username text,
|
||||
"encryptedPassword" text,
|
||||
color text DEFAULT '#3B82F6',
|
||||
"isVisible" boolean NOT NULL DEFAULT true,
|
||||
"syncIntervalMin" integer NOT NULL DEFAULT 15,
|
||||
"lastSyncAt" timestamp(3),
|
||||
"lastSyncError" text,
|
||||
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;`);
|
||||
await db.$executeRawUnsafe(calendarSourcePolicy);
|
||||
await db.$executeRawUnsafe(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON "CalendarSource" TO ${SCRATCH_ROLE_NAME}`,
|
||||
);
|
||||
|
||||
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen
|
||||
// (Befund G — die Regel kennt keine Benutzerdimension), je mit
|
||||
// gesetztem encryptedPassword-Platzhalter, damit Pruefung 3 zeigen
|
||||
// kann, dass ein Kollege desselben Mandanten die verschluesselten
|
||||
// Zugangsdaten sieht; eine Zeile unter TENANT-B fuer die
|
||||
// Mandantengrenze.
|
||||
await db.$executeRawUnsafe(`
|
||||
INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url, "encryptedPassword", "isVisible") VALUES
|
||||
('source-a1', 'user-a1', 'TENANT-A', 'Quelle A1', 'ics', 'https://example.invalid/a1.ics', 'enc(a1-passwort-platzhalter)', true),
|
||||
('source-a2', 'user-a2', 'TENANT-A', 'Quelle A2', 'ics', 'https://example.invalid/a2.ics', 'enc(a2-passwort-platzhalter)', true),
|
||||
('source-b1', 'user-b1', 'TENANT-B', 'Quelle B1', 'ics', 'https://example.invalid/b1.ics', 'enc(b1-passwort-platzhalter)', true);
|
||||
`);
|
||||
});
|
||||
|
||||
// Pruefung 8 zuerst — faellt sie durch, sind die Client-Messungen (9-12)
|
||||
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
|
||||
// sie fehlschlaegt.
|
||||
const schemaFields = readSchemaModelFieldNames('CalendarSource');
|
||||
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 = 'CalendarSource'
|
||||
`;
|
||||
return rows.map((r) => r.column_name).sort();
|
||||
},
|
||||
);
|
||||
const schemaFieldsSorted = [...schemaFields].sort();
|
||||
const columnsMatch =
|
||||
schemaFieldsSorted.length > 0 &&
|
||||
schemaFieldsSorted.length === tableColumns.length &&
|
||||
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
|
||||
columnsMatch,
|
||||
`Schema-Felder aus schema.prisma (model CalendarSource, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||
);
|
||||
if (!columnsMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// 1 + 3: calendarsource-gebunden-nur-eigener-mandant UND
|
||||
// calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
|
||||
// eine Abfrage, zwei Aussagen. Pruefung 3 zu bestehen IST das erwartete
|
||||
// Ergebnis: die Regel kennt keine Benutzerdimension.
|
||||
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
||||
tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-gebunden-nur-eigener-mandant',
|
||||
rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'),
|
||||
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`,
|
||||
);
|
||||
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
|
||||
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
|
||||
a2CredentialsVisible,
|
||||
`forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`,
|
||||
);
|
||||
|
||||
// 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile.
|
||||
const unboundRows = await prisma.$queryRaw`SELECT "tenantId" FROM "CalendarSource"`;
|
||||
report(
|
||||
results,
|
||||
'calendarsource-ungebunden-null-zeilen',
|
||||
unboundRows.length === 0,
|
||||
`ungebundener SELECT auf "CalendarSource" liefert ${unboundRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
|
||||
);
|
||||
|
||||
// 4: calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile
|
||||
// — die Datenbankseite der drei Besitzpruefungen (Befund I).
|
||||
const unboundSingleRow = await prisma.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
|
||||
report(
|
||||
results,
|
||||
'calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile',
|
||||
unboundSingleRow.length === 0,
|
||||
`ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert ${unboundSingleRow.length} Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException`,
|
||||
);
|
||||
|
||||
// 5: calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt
|
||||
let foreignInsertRejected = false;
|
||||
let foreignInsertDetail = '';
|
||||
try {
|
||||
await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) =>
|
||||
tx.$executeRaw`INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES ('source-rejected', 'user-a1', 'TENANT-B', 'Sollte abgewiesen werden', 'ics', 'https://example.invalid/rejected.ics')`,
|
||||
);
|
||||
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
const sqlState = sqlStateOf(err);
|
||||
foreignInsertRejected = sqlState === '42501';
|
||||
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt',
|
||||
foreignInsertRejected,
|
||||
foreignInsertDetail,
|
||||
);
|
||||
|
||||
// 6: calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
|
||||
const foreignDeleteAffected = await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) => tx.$executeRaw`DELETE FROM "CalendarSource" WHERE id = 'source-b1'`,
|
||||
);
|
||||
const stillThereAfterDelete = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
|
||||
return rows.length === 1;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
|
||||
foreignDeleteAffected === 0 && stillThereAfterDelete,
|
||||
`gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft ${foreignDeleteAffected} Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: ${stillThereAfterDelete}`,
|
||||
);
|
||||
|
||||
// 7: calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
|
||||
const foreignUpdateAffected = await forTenantQuery(
|
||||
prisma,
|
||||
'TENANT-A',
|
||||
(tx) => tx.$executeRaw`UPDATE "CalendarSource" SET "lastSyncError" = 'x' WHERE id = 'source-b1'`,
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
|
||||
foreignUpdateAffected === 0,
|
||||
`gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft ${foreignUpdateAffected} Zeile(n), lastSyncError bleibt unveraendert`,
|
||||
);
|
||||
|
||||
// 9: calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant
|
||||
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||
const clientBoundSources = await bound.calendarSource.findMany({
|
||||
where: { userId: 'user-a1', isVisible: true },
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant',
|
||||
clientBoundSources.length === 1 && clientBoundSources[0].id === 'source-a1',
|
||||
`bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert ${clientBoundSources.length} Zeile(n): ${JSON.stringify(clientBoundSources.map((s) => s.id))} — die Abfrage, die fetchAndCacheEvents stellt`,
|
||||
);
|
||||
|
||||
// 10: calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen
|
||||
const clientUnboundSources = await prisma.calendarSource.findMany({
|
||||
where: { userId: 'user-a1', isVisible: true },
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen',
|
||||
clientUnboundSources.length === 0,
|
||||
`dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert ${clientUnboundSources.length} Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht`,
|
||||
);
|
||||
|
||||
// 11: calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut
|
||||
let updateOnInvisibleThrew = false;
|
||||
let updateOnInvisibleDetail = '';
|
||||
try {
|
||||
await bound.calendarSource.update({
|
||||
where: { id: 'source-b1' },
|
||||
data: { lastSyncError: 'x' },
|
||||
});
|
||||
updateOnInvisibleDetail =
|
||||
'bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) ist NICHT fehlgeschlagen';
|
||||
} catch (err) {
|
||||
updateOnInvisibleThrew = true;
|
||||
const ctor = err?.constructor?.name ?? 'unbekannt';
|
||||
updateOnInvisibleDetail = `bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut',
|
||||
updateOnInvisibleThrew,
|
||||
updateOnInvisibleDetail,
|
||||
);
|
||||
|
||||
// 12: calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt
|
||||
let createSucceeded = false;
|
||||
let createDetail = '';
|
||||
try {
|
||||
const created = await bound.calendarSource.create({
|
||||
data: {
|
||||
userId: 'user-a1',
|
||||
tenantId: 'TENANT-A',
|
||||
name: 'Neu angelegte Quelle',
|
||||
type: 'ics',
|
||||
url: 'https://example.invalid/neu.ics',
|
||||
},
|
||||
});
|
||||
const readBack = await bound.calendarSource.findMany({ where: { id: created.id } });
|
||||
createSucceeded = readBack.length === 1;
|
||||
createDetail = `bound.calendarSource.create unter TENANT-A gelingt (id=${created.id}, createdAt=${JSON.stringify(created.createdAt)}), gebunden lesbar: ${createSucceeded} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt`;
|
||||
} catch (err) {
|
||||
createDetail = `bound.calendarSource.create unter TENANT-A ist fehlgeschlagen: ${err.message}`;
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt',
|
||||
createSucceeded,
|
||||
createDetail,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -3001,6 +3332,7 @@ async function main() {
|
||||
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
@@ -24,6 +24,15 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
|
||||
* Every handler extracts userId and tenantId from the request
|
||||
* and scopes all operations to the calling user (T-05-12).
|
||||
*
|
||||
* `extractContext()` already resolves BOTH userId and tenantId from the
|
||||
* validated session (throws `ForbiddenException` without either). All six
|
||||
* context-using handlers destructure both and pass them through to
|
||||
* `CalendarService` unchanged — this is a pass-through of an
|
||||
* already-resolved tenant, not a new trust source; nothing is taken from
|
||||
* the request body or path (Mandantentrennung Etappe 2, 260911-cwh).
|
||||
* `testSourceConfig` never calls `extractContext()` and stays unchanged —
|
||||
* it has no database access and no tenant.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /calendar/sources — list user's calendar sources (no passwords)
|
||||
* - POST /calendar/sources — add a new calendar source
|
||||
@@ -58,8 +67,8 @@ export class CalendarController {
|
||||
*/
|
||||
@Get('sources')
|
||||
async getSources(@Req() req: Request) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.getSources(userId);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.getSources(userId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,8 +96,8 @@ export class CalendarController {
|
||||
@Req() req: Request,
|
||||
@Body() dto: UpdateCalendarSourceDto,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.updateSource(id, userId, dto);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.updateSource(id, userId, tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,8 +109,8 @@ export class CalendarController {
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.deleteSource(id, userId);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.deleteSource(id, userId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,8 +134,8 @@ export class CalendarController {
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.testConnection(id, userId);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.testConnection(id, userId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +147,7 @@ export class CalendarController {
|
||||
@Req() req: Request,
|
||||
@Query() query: CalendarEventsQueryDto,
|
||||
) {
|
||||
const { userId } = this.extractContext(req);
|
||||
return this.calendarService.aggregateEvents(userId, query.from, query.to);
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.aggregateEvents(userId, tenantId, query.from, query.to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CalendarService.spec — Zwei-Klienten-Nachweis fuer die Bindung an
|
||||
* forTenant() (260911-cwh, Aufgabe 2). Dieser Bereich hatte VOR diesem
|
||||
* Durchlauf KEINE einzige Testdatei (Befund C) — dieser Fake ist deshalb
|
||||
* die Voraussetzung dafuer, dass irgendeine Aussage dieses Plans
|
||||
* nachpruefbar ist, nicht eine Zugabe.
|
||||
*
|
||||
* Muster wie `dkv.service.spec.ts` (260909-mir): `__makeBoundClient(tenantId)`
|
||||
* wrappt DIESELBEN In-Memory-Zeilen mit einer protokollierenden Schicht fuer
|
||||
* `calendarSource`. Der ungebundene Fake protokolliert NICHT, der gebundene
|
||||
* schon — eine vergessene Bindung wird dadurch sichtbar, ein reiner
|
||||
* Identitaets-Mock (`(p) => p`) wuerde das nicht leisten. Der Wachhund
|
||||
* "genau ein Klient je Aufruf" folgt `dashboard.service.spec.ts` (Zeile
|
||||
* 545): `vi.mocked(forTenant).mock.calls.length` wird nach jedem Aufruf
|
||||
* geprueft.
|
||||
*
|
||||
* Die drei Provider (ICS/CalDAV/Exchange) reden mit echten Servern und
|
||||
* werden NICHT ausgeuebt — nur als `vi.fn()`-Attrappen fuer
|
||||
* `fetchEvents`/`testConnection` eingebunden.
|
||||
*/
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { CalendarService } from './calendar.service';
|
||||
|
||||
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return { ...row };
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) out[key] = (row as any)[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeDefaultSourceFields() {
|
||||
const now = new Date('2026-01-01T00:00:00.000Z');
|
||||
return {
|
||||
name: 'Testquelle',
|
||||
type: 'ics',
|
||||
exchangeMode: null,
|
||||
domain: null,
|
||||
url: 'https://example.invalid/cal.ics',
|
||||
username: null,
|
||||
encryptedPassword: null,
|
||||
color: '#3B82F6',
|
||||
isVisible: true,
|
||||
syncIntervalMin: 15,
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `calendarSource`
|
||||
* (`findMany`, `findUnique`, `create`, `update`, `delete`). Die ungebundene
|
||||
* Form protokolliert NICHT; `__makeBoundClient(tenantId)` liefert eine
|
||||
* ZWEITE, protokollierende Schicht ueber denselben Zeilen.
|
||||
*/
|
||||
function makeFakePrisma() {
|
||||
const sources = new Map<string, any>(); // key: id
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
let autoId = 0;
|
||||
|
||||
const calendarSource = {
|
||||
findMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
select,
|
||||
orderBy,
|
||||
}: { where?: Record<string, unknown>; select?: Record<string, boolean>; orderBy?: { createdAt?: string } } = {}) => {
|
||||
let rows = Array.from(sources.values());
|
||||
if (where) {
|
||||
rows = rows.filter((r) =>
|
||||
Object.entries(where).every(([k, v]) => (r as any)[k] === v),
|
||||
);
|
||||
}
|
||||
if (orderBy?.createdAt === 'asc') {
|
||||
rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
return rows.map((r) => _applySelect(r, select));
|
||||
},
|
||||
),
|
||||
findUnique: vi.fn(
|
||||
async ({ where, select }: { where: { id: string }; select?: Record<string, boolean> }) => {
|
||||
const row = sources.get(where.id);
|
||||
return row ? _applySelect(row, select) : null;
|
||||
},
|
||||
),
|
||||
create: vi.fn(
|
||||
async ({
|
||||
data,
|
||||
select,
|
||||
}: { data: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||
const id = (data.id as string) ?? `src-${++autoId}`;
|
||||
const now = new Date();
|
||||
const record = { ...makeDefaultSourceFields(), id, createdAt: now, updatedAt: now, ...data };
|
||||
sources.set(id, record);
|
||||
return _applySelect(record, select);
|
||||
},
|
||||
),
|
||||
update: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
select,
|
||||
}: { where: { id: string }; data: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||
const existing = sources.get(where.id);
|
||||
if (!existing) {
|
||||
// Nachbau des in Aufgabe 1 gemessenen Wettlauf-Ergebnisses
|
||||
// (`calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`,
|
||||
// 260911-cwh): PrismaClientKnownRequestError mit code P2025.
|
||||
const err: any = new Error('An operation failed because it depends on one or more records that were required but not found.');
|
||||
err.code = 'P2025';
|
||||
throw err;
|
||||
}
|
||||
const record = { ...existing, ...data, updatedAt: new Date() };
|
||||
sources.set(where.id, record);
|
||||
return _applySelect(record, select);
|
||||
},
|
||||
),
|
||||
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
const existing = sources.get(where.id);
|
||||
sources.delete(where.id);
|
||||
return existing;
|
||||
}),
|
||||
};
|
||||
|
||||
const fake: any = {
|
||||
calendarSource,
|
||||
__boundCallLog: boundCallLog,
|
||||
__seedSource(row: { id: string; userId: string; tenantId: string } & Partial<ReturnType<typeof makeDefaultSourceFields>>) {
|
||||
sources.set(row.id, { ...makeDefaultSourceFields(), ...row });
|
||||
},
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const wrapModel = (model: Record<string, any>, modelName: string, methods: string[]) => {
|
||||
const wrapped: any = {};
|
||||
for (const method of methods) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: modelName, method });
|
||||
return model[method](...args);
|
||||
};
|
||||
}
|
||||
return wrapped;
|
||||
};
|
||||
return {
|
||||
calendarSource: wrapModel(calendarSource, 'calendarSource', [
|
||||
'findMany',
|
||||
'findUnique',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
]),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Umkehrbare Attrappen fuer CryptoService — kein echtes Verschluesseln. */
|
||||
function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
|
||||
return {
|
||||
encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`),
|
||||
decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
/** Attrappen fuer die drei Provider — NIE ausgeuebt, nur `vi.fn()`. */
|
||||
function makeFakeProviders(
|
||||
overrides: Partial<{ ics: any; caldav: any; exchange: any }> = {},
|
||||
) {
|
||||
const makeProvider = () => ({
|
||||
fetchEvents: vi.fn(async () => []),
|
||||
testConnection: vi.fn(async () => true),
|
||||
});
|
||||
return {
|
||||
icsProvider: overrides.ics ?? makeProvider(),
|
||||
caldavProvider: overrides.caldav ?? makeProvider(),
|
||||
exchangeProvider: overrides.exchange ?? makeProvider(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCalendarService(
|
||||
prisma: any,
|
||||
overrides: Partial<{
|
||||
encrypt: any;
|
||||
decrypt: any;
|
||||
icsProvider: any;
|
||||
caldavProvider: any;
|
||||
exchangeProvider: any;
|
||||
}> = {},
|
||||
) {
|
||||
const crypto = makeFakeCrypto(overrides);
|
||||
const { icsProvider, caldavProvider, exchangeProvider } = makeFakeProviders({
|
||||
ics: overrides.icsProvider,
|
||||
caldav: overrides.caldavProvider,
|
||||
exchange: overrides.exchangeProvider,
|
||||
});
|
||||
const service = new CalendarService(
|
||||
prisma,
|
||||
crypto as any,
|
||||
icsProvider as any,
|
||||
caldavProvider as any,
|
||||
exchangeProvider as any,
|
||||
);
|
||||
return { service, crypto, icsProvider, caldavProvider, exchangeProvider };
|
||||
}
|
||||
|
||||
describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
|
||||
// ─── getSources ────────────────────────────────────────────────────────
|
||||
|
||||
it('getSources: laeuft gebunden mit der uebergebenen Mandantenkennung im Protokoll; die Antwort traegt hasCredentials und NIE encryptedPassword', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim)' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.getSources('user-a1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hasCredentials).toBe(true);
|
||||
expect((result[0] as any).encryptedPassword).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
|
||||
prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.getSources('user-a1', 't1');
|
||||
|
||||
expect(result.map((s: any) => s.id)).toEqual(['src-a1']);
|
||||
});
|
||||
|
||||
// ─── addSource ─────────────────────────────────────────────────────────
|
||||
|
||||
it('addSource: gebunden, Mandantenkennung als Pflichtwert geschrieben, Passwort verschluesselt, Antwort ohne encryptedPassword', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service, crypto } = makeCalendarService(prisma);
|
||||
|
||||
const created = await service.addSource('user-a1', 't1', {
|
||||
name: 'Neue Quelle',
|
||||
type: 'ics',
|
||||
url: 'https://example.invalid/neu.ics',
|
||||
password: 'geheim-123',
|
||||
} as any);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'create');
|
||||
expect(prisma.calendarSource.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ tenantId: 't1', userId: 'user-a1' }) }),
|
||||
);
|
||||
expect(created.hasCredentials).toBe(true);
|
||||
expect((created as any).encryptedPassword).toBeUndefined();
|
||||
expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('geheim-123');
|
||||
});
|
||||
|
||||
// ─── updateSource ──────────────────────────────────────────────────────
|
||||
|
||||
it('updateSource: BEIDE Abfragen (Nachschlagen und Aendern) stehen ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung im Protokoll', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Neuer Name' } as any);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
|
||||
});
|
||||
|
||||
it('updateSource, Zugangsdaten-Erhaltung — Feld FEHLT: encryptedPassword bleibt unveraendert, kein Lesezugriff laedt ein Passwort (Befund E)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
|
||||
const { service, crypto } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Umbenannt' } as any);
|
||||
|
||||
expect(result.hasCredentials).toBe(true);
|
||||
expect(vi.mocked(crypto.decrypt)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(crypto.encrypt)).not.toHaveBeenCalled();
|
||||
const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls[0][0] as any;
|
||||
expect(updateCall.data).not.toHaveProperty('encryptedPassword');
|
||||
});
|
||||
|
||||
it('updateSource, Zugangsdaten-Erhaltung — Feld LEER: encryptedPassword wird null', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: '' } as any);
|
||||
|
||||
expect(result.hasCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it('updateSource, Zugangsdaten-Erhaltung — Feld GESETZT: encryptedPassword wird neu verschluesselt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
|
||||
const { service, crypto } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: 'neu-geheim' } as any);
|
||||
|
||||
expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('neu-geheim');
|
||||
expect(result.hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Besitzpruefungen (updateSource/deleteSource/testConnection) ──────
|
||||
|
||||
it('updateSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(
|
||||
service.updateSource('src-b1', 'user-a1', 't1', { name: 'X' } as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('updateSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(
|
||||
service.updateSource('src-unbekannt', 'user-a1', 't1', { name: 'X' } as any),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deleteSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(service.deleteSource('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('deleteSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(service.deleteSource('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('testConnection: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(service.testConnection('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('testConnection: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await expect(service.testConnection('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
// ─── deleteSource, positiver Pfad ──────────────────────────────────────
|
||||
|
||||
it('deleteSource: beide Abfragen (Nachschlagen und Loeschen) stehen ueber denselben gebundenen Klienten im Protokoll', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.deleteSource('src-a1', 'user-a1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'delete');
|
||||
});
|
||||
|
||||
// ─── testConnection, positive Pfade ────────────────────────────────────
|
||||
|
||||
it('testConnection, Erfolgspfad: alle Abfragen (Nachschlagen, Rueckschreiben bei Erfolg) stehen ueber denselben gebundenen Klienten; der Provider erhaelt das ENTSCHLUESSELTE Passwort', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' });
|
||||
const { service, icsProvider } = makeCalendarService(prisma);
|
||||
|
||||
const result = await service.testConnection('src-a1', 'user-a1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
|
||||
expect(result.success).toBe(true);
|
||||
expect(vi.mocked(icsProvider.testConnection)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ password: 'geheim-123' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('testConnection, Fehlerpfad: alle Abfragen (Nachschlagen, Rueckschreiben im catch) stehen ueber denselben gebundenen Klienten; die Antwort ist generisch (kein Passwort, keine Serverdetails)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' });
|
||||
const icsProvider = {
|
||||
fetchEvents: vi.fn(async () => []),
|
||||
testConnection: vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED mail.example.invalid:993 password=geheim-123');
|
||||
}),
|
||||
};
|
||||
const { service } = makeCalendarService(prisma, { icsProvider });
|
||||
|
||||
const result = await service.testConnection('src-a1', 'user-a1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Connection failed');
|
||||
expect(result.error).not.toContain('geheim-123');
|
||||
expect(result.error).not.toContain('mail.example.invalid');
|
||||
});
|
||||
|
||||
// ─── aggregateEvents / fetchAndCacheEvents ────────────────────────────
|
||||
|
||||
it('aggregateEvents, Erfolgspfad: das Laden der Quellen UND die Synchronstatus-Rueckschreibung bei Erfolg stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.aggregateEvents('user-a1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
|
||||
});
|
||||
|
||||
it('aggregateEvents, Fehlerpfad (Providerfehler): das Laden der Quellen UND die Synchronstatus-Rueckschreibung im catch stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
const icsProvider = {
|
||||
fetchEvents: vi.fn(async () => {
|
||||
throw new Error('Provider nicht erreichbar');
|
||||
}),
|
||||
testConnection: vi.fn(async () => true),
|
||||
};
|
||||
const { service } = makeCalendarService(prisma, { icsProvider });
|
||||
|
||||
const events = await service.aggregateEvents('user-a1', 't1');
|
||||
|
||||
expect(events).toEqual([]);
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
|
||||
const updateCalls = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.tenantId === 't1' && c.model === 'calendarSource' && c.method === 'update',
|
||||
);
|
||||
expect(updateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('aggregateEvents ohne Quellen: Rueckgabe ist eine leere Liste, kein Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als Abwesenheit als heutiges Verhalten festgehalten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
const first = await service.aggregateEvents('user-ohne-quellen', 't1');
|
||||
expect(first).toEqual([]);
|
||||
|
||||
// Zweiter Aufruf misst erneut ueber die Datenbank — waere ein
|
||||
// Cache-Eintrag angelegt worden, bliebe der Aufrufzaehler von
|
||||
// findMany bei 1 stehen.
|
||||
await service.aggregateEvents('user-ohne-quellen', 't1');
|
||||
const findManyCalls = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
|
||||
);
|
||||
expect(findManyCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
// ─── Cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen weiteren Datenbankzugriff', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
|
||||
const findManyCallsAfterFirst = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
|
||||
).length;
|
||||
|
||||
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
|
||||
const findManyCallsAfterSecond = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
|
||||
).length;
|
||||
|
||||
expect(findManyCallsAfterSecond).toBe(findManyCallsAfterFirst);
|
||||
});
|
||||
|
||||
it('Cache: zwei VERSCHIEDENE Benutzerkennungen teilen sich keinen Eintrag — das Urteil aus Aufgabe 1 zum Cache-Schluessel, festgenagelt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
|
||||
const findManyCallsAfterUserA = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
|
||||
).length;
|
||||
|
||||
await service.aggregateEvents('user-a2', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
|
||||
const findManyCallsAfterUserB = prisma.__boundCallLog.filter(
|
||||
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
|
||||
).length;
|
||||
|
||||
expect(findManyCallsAfterUserB).toBe(findManyCallsAfterUserA + 1);
|
||||
});
|
||||
|
||||
// ─── testConnectionFromConfig ───────────────────────────────────────────
|
||||
|
||||
it('testConnectionFromConfig: kein Datenbankzugriff, weder gebunden noch ungebunden — der Nachbau bleibt unberuehrt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
await service.testConnectionFromConfig({
|
||||
type: 'ics',
|
||||
url: 'https://example.invalid/cal.ics',
|
||||
} as any);
|
||||
|
||||
expect(prisma.__boundCallLog).toEqual([]);
|
||||
expect(vi.mocked(prisma.calendarSource.findMany)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.calendarSource.findUnique)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── Wachhund ────────────────────────────────────────────────────────
|
||||
|
||||
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
|
||||
const { service } = makeCalendarService(prisma);
|
||||
|
||||
for (const call of [
|
||||
() => service.getSources('user-a1', 't1'),
|
||||
() => service.addSource('user-a1', 't1', { name: 'X', type: 'ics', url: 'https://example.invalid/x.ics' } as any),
|
||||
() => service.updateSource('src-a1', 'user-a1', 't1', { name: 'Y' } as any),
|
||||
() => service.testConnection('src-a1', 'user-a1', 't1'),
|
||||
() => service.aggregateEvents('user-a1', 't1', '2026-02-01T00:00:00.000Z', '2026-02-02T00:00:00.000Z'),
|
||||
() => service.deleteSource('src-a1', 'user-a1', 't1'),
|
||||
]) {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await call().catch(() => undefined);
|
||||
expect(
|
||||
vi.mocked(forTenant).mock.calls.length,
|
||||
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
|
||||
).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
@@ -98,14 +99,47 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
/**
|
||||
* Service for calendar source CRUD and event aggregation.
|
||||
*
|
||||
* Source config is per-user (D-09), not per-tenant.
|
||||
* Source config is per-user AND per-tenant bound (Mandantentrennung Etappe
|
||||
* 2, 260911-cwh) — `CalendarSource` carries a mandatory `tenantId`
|
||||
* (D-09/T-05-*: per-user ownership; row-level security: per-tenant
|
||||
* isolation). Every method with database access binds its own
|
||||
* `tenantPrisma` via `forTenant()`.
|
||||
*
|
||||
* The three ownership checks (`updateSource`/`deleteSource`/
|
||||
* `testConnection`, comparing `existing.userId` against the calling user)
|
||||
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS
|
||||
* policy on `CalendarSource` carries no user dimension (measured
|
||||
* 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
|
||||
* so a colleague of the SAME tenant would otherwise see and modify a
|
||||
* fellow user's encrypted Exchange/CalDAV credentials. Until the RLS
|
||||
* policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these
|
||||
* application-level checks remain the only protection between users of the
|
||||
* same tenant.
|
||||
*
|
||||
* Credentials encrypted at rest via CryptoService (T-05-10).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
|
||||
/** Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. */
|
||||
/**
|
||||
* Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`.
|
||||
*
|
||||
* Cache-key judgment (260911-cwh, Aufgabe 1, Befund F — chain checked
|
||||
* link by link at execution time): `userId` here is `User.id`
|
||||
* (`apps/api/prisma/schema.prisma`, `model User`, `@id @default(uuid())`),
|
||||
* reached via `calendar.controller.ts` `extractContext()`
|
||||
* (`req.user?.id`), which is `JwtStrategy.validate()`'s `id: payload.sub`
|
||||
* (`apps/api/src/auth/strategies/jwt.strategy.ts`), which is
|
||||
* `sub: user.id` at token-issue time (`apps/api/src/auth/auth.service.ts`,
|
||||
* lines 143/332) — the database identity, not a login name. The
|
||||
* Etappe-3-Entscheidung (1) (tenant-scoped uniqueness for
|
||||
* `User.username`/`User.email`) does NOT touch `User.id`, which remains
|
||||
* a platform-wide UUID no tenant can share. The key therefore stays
|
||||
* without a tenant component. If any link of this chain changes, the key
|
||||
* needs a tenant component — the decision follows the measurement, not
|
||||
* this comment.
|
||||
*/
|
||||
private readonly eventCache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(
|
||||
@@ -120,8 +154,9 @@ export class CalendarService {
|
||||
* Returns all calendar sources for a user WITHOUT encryptedPassword.
|
||||
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
|
||||
*/
|
||||
async getSources(userId: string) {
|
||||
const sources = await this.prisma.calendarSource.findMany({
|
||||
async getSources(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const sources = await tenantPrisma.calendarSource.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
...SOURCE_SAFE_SELECT,
|
||||
@@ -160,7 +195,8 @@ export class CalendarService {
|
||||
data.encryptedPassword = this.crypto.encrypt(dto.password);
|
||||
}
|
||||
|
||||
const created = await this.prisma.calendarSource.create({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const created = await tenantPrisma.calendarSource.create({
|
||||
data: data as any,
|
||||
select: SOURCE_SAFE_SELECT,
|
||||
});
|
||||
@@ -172,8 +208,9 @@ export class CalendarService {
|
||||
* Updates a calendar source. Ownership check ensures user can only modify their own sources.
|
||||
* Re-encrypts password if provided; T-05-12 ownership enforcement.
|
||||
*/
|
||||
async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) {
|
||||
const existing = await this.prisma.calendarSource.findUnique({
|
||||
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
@@ -207,7 +244,7 @@ export class CalendarService {
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await this.prisma.calendarSource.update({
|
||||
const updated = await tenantPrisma.calendarSource.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
select: {
|
||||
@@ -223,8 +260,9 @@ export class CalendarService {
|
||||
/**
|
||||
* Deletes a calendar source. Ownership check enforced (T-05-12).
|
||||
*/
|
||||
async deleteSource(id: string, userId: string) {
|
||||
const existing = await this.prisma.calendarSource.findUnique({
|
||||
async deleteSource(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.calendarSource.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
@@ -236,7 +274,7 @@ export class CalendarService {
|
||||
throw new ForbiddenException('Not your calendar source');
|
||||
}
|
||||
|
||||
await this.prisma.calendarSource.delete({ where: { id } });
|
||||
await tenantPrisma.calendarSource.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -244,8 +282,9 @@ export class CalendarService {
|
||||
* Test connection to a calendar source via its provider.
|
||||
* Updates lastSyncAt/lastSyncError on the source record.
|
||||
*/
|
||||
async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const source = await this.prisma.calendarSource.findUnique({ where: { id } });
|
||||
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
|
||||
if (!source) throw new NotFoundException('Calendar source not found');
|
||||
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
|
||||
|
||||
@@ -264,7 +303,7 @@ export class CalendarService {
|
||||
try {
|
||||
const success = await provider.testConnection(decryptedSource);
|
||||
|
||||
await this.prisma.calendarSource.update({
|
||||
await tenantPrisma.calendarSource.update({
|
||||
where: { id },
|
||||
data: {
|
||||
lastSyncAt: success ? new Date() : undefined,
|
||||
@@ -275,7 +314,7 @@ export class CalendarService {
|
||||
return { success };
|
||||
} catch (error) {
|
||||
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
|
||||
await this.prisma.calendarSource.update({
|
||||
await tenantPrisma.calendarSource.update({
|
||||
where: { id },
|
||||
data: { lastSyncError: errorMsg },
|
||||
});
|
||||
@@ -326,6 +365,7 @@ export class CalendarService {
|
||||
*/
|
||||
async aggregateEvents(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
@@ -339,13 +379,13 @@ export class CalendarService {
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
// Serve cached immediately, trigger background refresh if close to expiry
|
||||
if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) {
|
||||
this.refreshCacheInBackground(userId, fromDate, toDate, cacheKey);
|
||||
this.refreshCacheInBackground(userId, tenantId, fromDate, toDate, cacheKey);
|
||||
}
|
||||
return cached.events;
|
||||
}
|
||||
|
||||
// Fetch fresh
|
||||
const events = await this.fetchAndCacheEvents(userId, fromDate, toDate, cacheKey);
|
||||
const events = await this.fetchAndCacheEvents(userId, tenantId, fromDate, toDate, cacheKey);
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -354,11 +394,13 @@ export class CalendarService {
|
||||
*/
|
||||
private async fetchAndCacheEvents(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
cacheKey: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const sources = await this.prisma.calendarSource.findMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const sources = await tenantPrisma.calendarSource.findMany({
|
||||
where: { userId, isVisible: true },
|
||||
});
|
||||
|
||||
@@ -384,7 +426,7 @@ export class CalendarService {
|
||||
const events = await provider.fetchEvents(decryptedSource, from, to);
|
||||
|
||||
// Update sync status on success
|
||||
await this.prisma.calendarSource.update({
|
||||
await tenantPrisma.calendarSource.update({
|
||||
where: { id: source.id },
|
||||
data: { lastSyncAt: new Date(), lastSyncError: null },
|
||||
});
|
||||
@@ -395,7 +437,7 @@ export class CalendarService {
|
||||
this.logger.warn(
|
||||
`Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.prisma.calendarSource.update({
|
||||
await tenantPrisma.calendarSource.update({
|
||||
where: { id: source.id },
|
||||
data: { lastSyncError: 'Event fetch failed' },
|
||||
});
|
||||
@@ -426,14 +468,23 @@ export class CalendarService {
|
||||
|
||||
/**
|
||||
* Refreshes cache in the background without blocking the response.
|
||||
*
|
||||
* This is a request context that outlives the request (Befund B,
|
||||
* 260911-cwh): it is NOT the "read across tenants, then bind per tenant"
|
||||
* shape of the background-service section in
|
||||
* docs/mandantentrennung-zugriffsklassifikation.md — it carries the
|
||||
* tenant of the ORIGINAL request that triggered it (`aggregateEvents`)
|
||||
* and cannot have any other tenant, because it never reads across
|
||||
* tenants in the first place.
|
||||
*/
|
||||
private refreshCacheInBackground(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
cacheKey: string,
|
||||
): void {
|
||||
this.fetchAndCacheEvents(userId, from, to, cacheKey).catch((error) => {
|
||||
this.fetchAndCacheEvents(userId, tenantId, from, to, cacheKey).catch((error) => {
|
||||
this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2000,6 +2000,265 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
|
||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||
Schemaänderung in dieser Etappe.
|
||||
|
||||
## Bereich calendar
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar`
|
||||
(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen
|
||||
Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern —
|
||||
die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers.
|
||||
Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung
|
||||
der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte
|
||||
Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer
|
||||
Kalender — und das Frontend verstärkt das, siehe (k3).
|
||||
|
||||
### (k1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften
|
||||
Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach
|
||||
`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen.
|
||||
Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN
|
||||
17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus
|
||||
Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig
|
||||
JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit
|
||||
der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
|
||||
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
|
||||
Regelstand NACH der Migration
|
||||
`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese
|
||||
zweite Migration wird zur LAUFZEIT geprüft (Prüfung
|
||||
`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet:
|
||||
`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert
|
||||
`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der
|
||||
Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle
|
||||
Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer
|
||||
FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die
|
||||
Messfalle aus 260910-jab.
|
||||
|
||||
Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client
|
||||
(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit
|
||||
`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht
|
||||
`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8
|
||||
(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
|
||||
vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des
|
||||
Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle
|
||||
über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12
|
||||
und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden
|
||||
Client-Messungen sonst wertlos wären.
|
||||
|
||||
Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||||
`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen
|
||||
dieses Abschnitts sowie die abschließende Summenzeile):
|
||||
|
||||
```
|
||||
calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||||
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
|
||||
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
|
||||
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht
|
||||
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||||
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
|
||||
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
|
||||
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
|
||||
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
|
||||
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
|
||||
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
|
||||
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
|
||||
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
|
||||
Alle 101 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
|
||||
des Plans namentlich vorgezeichnet — die dreizehnte
|
||||
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
|
||||
Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa
|
||||
festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine
|
||||
dreizehnte Prüfung ergänzt hatte.
|
||||
|
||||
**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der
|
||||
IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges
|
||||
`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an
|
||||
der echten, ausgelieferten Policy gemessen.
|
||||
|
||||
**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen —
|
||||
und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein
|
||||
gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })`
|
||||
unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine
|
||||
**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to
|
||||
update not found") — NICHT die `PrismaClientUnknownRequestError`, die
|
||||
`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem
|
||||
Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des
|
||||
Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT`
|
||||
aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE
|
||||
`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches
|
||||
`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht
|
||||
unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe
|
||||
Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma
|
||||
grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei
|
||||
Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`)
|
||||
braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad,
|
||||
über den ein Nutzer diese Zeile erreicht (die vorgeschaltete
|
||||
`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException`
|
||||
ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife
|
||||
(Befund K, siehe (k2)).
|
||||
|
||||
### (k2) Signaltabelle je umgestelltem Pfad
|
||||
|
||||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? |
|
||||
|---|---|---|---|
|
||||
| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand |
|
||||
| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — |
|
||||
| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand |
|
||||
| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt |
|
||||
| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt |
|
||||
| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht |
|
||||
| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet |
|
||||
|
||||
### (k3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert
|
||||
bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall
|
||||
in dieser Etappe. `CalendarService.fetchAndCacheEvents`,
|
||||
`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück,
|
||||
ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten,
|
||||
sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter
|
||||
Cache-Treffer, der fünf Minuten stehen bleibt. Die drei
|
||||
Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind
|
||||
dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`.
|
||||
|
||||
**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund
|
||||
J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei
|
||||
Dateien etwas:**
|
||||
|
||||
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile
|
||||
37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als
|
||||
`emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50:
|
||||
`catch { // Silent fail — show empty state }` fängt jeden Fehler von
|
||||
`fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt
|
||||
`hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events`
|
||||
wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig
|
||||
`events.length === 0` fällt und `emptyNoEvents` ("keine Termine")
|
||||
zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500,
|
||||
Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events`
|
||||
ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade
|
||||
keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`.
|
||||
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile
|
||||
49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt
|
||||
`sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine
|
||||
echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127:
|
||||
`sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine
|
||||
Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine
|
||||
schärfere Form derselben Verschluckung als im Widget.
|
||||
3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149:
|
||||
`if (password) payload.password = password;` — nicht Leere, sondern
|
||||
Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe
|
||||
(k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt
|
||||
WEGGELASSEN, nicht als leere Zeichenkette übertragen.
|
||||
|
||||
**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender
|
||||
oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist
|
||||
kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle
|
||||
NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder
|
||||
CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre
|
||||
es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei
|
||||
`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen
|
||||
automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die
|
||||
Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene
|
||||
Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein
|
||||
System ein, das er für kaputt hält, und sobald die Ursache behoben ist
|
||||
(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für
|
||||
denselben Server vor — Dubletten bei Quellen UND bei Terminen.
|
||||
|
||||
**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER
|
||||
Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403,
|
||||
500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu
|
||||
unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des
|
||||
Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
||||
|
||||
### (k4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied
|
||||
einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit
|
||||
`${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus
|
||||
`calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut
|
||||
`apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34)
|
||||
wörtlich `id: payload.sub`; `payload.sub` ist laut
|
||||
`apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide
|
||||
`sub: user.id`) die Datenbankkennung `User.id`; die trägt laut
|
||||
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`.
|
||||
**Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein
|
||||
Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen
|
||||
eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und
|
||||
`User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT.
|
||||
Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als
|
||||
Kommentar unmittelbar über der `eventCache`-Zuweisung in
|
||||
`calendar.service.ts` (Aufgabe 2).
|
||||
- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen
|
||||
zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource`
|
||||
besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu
|
||||
zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu
|
||||
verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)`
|
||||
entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf →
|
||||
`encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit
|
||||
in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null`
|
||||
gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite
|
||||
(`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes
|
||||
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
|
||||
Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über
|
||||
einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die
|
||||
beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`)
|
||||
entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts
|
||||
Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
|
||||
- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in
|
||||
(k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`):
|
||||
die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen
|
||||
DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die
|
||||
Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel
|
||||
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
|
||||
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
|
||||
und die drei Besitzprüfungen der EINZIGE Schutz.
|
||||
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
|
||||
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
|
||||
Besitz `ForbiddenException('Not your calendar source')` (403), bei
|
||||
unbekannter Kennung `NotFoundException('Calendar source not found')`
|
||||
(404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz
|
||||
einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt
|
||||
nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar).
|
||||
Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem
|
||||
Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
|
||||
- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle
|
||||
nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200,
|
||||
keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für
|
||||
Etappe 4 (`rls-preflight.mjs`): physisch vorhandene
|
||||
`CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit
|
||||
der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein
|
||||
Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an
|
||||
`getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit
|
||||
derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und
|
||||
bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`:
|
||||
keine Quelle ist auf einer frischen Installation oder für einen neuen
|
||||
Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm
|
||||
und verlöre ihr Signal, bevor sie gebraucht wird.
|
||||
|
||||
### (k5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen,
|
||||
keine Datei dieses Plans. `calendar-widget.tsx`,
|
||||
`calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT
|
||||
geändert.
|
||||
- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`,
|
||||
`exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2
|
||||
NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als
|
||||
`vi.fn`) verwendet.
|
||||
- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant
|
||||
(prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
|
||||
- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung,
|
||||
nicht Teil dieses Plans.
|
||||
- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert.
|
||||
- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`**
|
||||
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) —
|
||||
zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in
|
||||
STATE.md), nicht zu ändern.
|
||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||
Schemaänderung in dieser Etappe.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
@@ -124,11 +124,11 @@ autoritative Quelle.
|
||||
| module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| 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 | 12 | 0 | unverändert |
|
||||
| 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 |
|
||||
| favorites | 7 | 0 | unverändert |
|
||||
| settings | 4 | 0 | unverändert |
|
||||
| **Summe** | **95** | **147** | 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), jetzt 95 nach 260910-krx (`dashboard` 13→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), jetzt 147 nach 260910-krx (zusätzlich 12 in `dashboard`). 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** | **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 |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare)
|
||||
|
||||
@@ -178,6 +178,16 @@ ohne diesen Vermerk waere von einer vergessenen Nachziehung nicht zu
|
||||
unterscheiden — deshalb steht die Abwesenheit einer Aenderung hier
|
||||
ausdruecklich, statt stillschweigend uebersprungen zu werden.
|
||||
|
||||
**Stand 260911-cwh (Aufgabe 3): unveraendert, ausdruecklich festgehalten
|
||||
statt uebersprungen.** Weiterhin 63 Paare, keine Klasse verschoben sich. Das
|
||||
eine Paar des Bereichs `calendar` (`calendar.service.ts`/`calendarSource`)
|
||||
war bereits vor diesem Durchlauf korrekt klassifiziert (`muss-mandantengebunden`)
|
||||
— Aufgabe 2 (260911-cwh) aendert nur seine `Stand`-Spalte (`ungebunden` auf
|
||||
`gebunden`), nicht seine Klasse. Eine unveraenderte Tabelle ohne diesen
|
||||
Vermerk waere von einer vergessenen Nachziehung nicht zu unterscheiden —
|
||||
deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt
|
||||
stillschweigend uebersprungen zu werden.
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 31 |
|
||||
@@ -304,6 +314,25 @@ bekannten Nutzers heraus aufgerufen — die Bauform dieses Abschnitts
|
||||
(übergreifend LESEN über alle Mandanten, dann je Mandant BINDEN) kommt in
|
||||
diesem Bereich an keiner Stelle vor.
|
||||
|
||||
**Stand 260911-cwh — auch der Bereich `calendar` fügt diesem Abschnitt
|
||||
keinen sechsten Fall hinzu, gemessen statt angenommen (260911-cwh, Aufgabe 1,
|
||||
Befund B).** `grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/calendar --include=*.ts`
|
||||
liefert außerhalb von Testdateien genau einen Treffer,
|
||||
`providers/ics.provider.ts:100` — ein `setTimeout` für den Abbruch eines
|
||||
HTTP-Abrufs nach acht Sekunden, kein Planer. Der einzige Dienst dieses
|
||||
Bereichs, `calendar.service.ts`, enthält keinen Hintergrunddienst, keinen
|
||||
Planer und keinen Start-Hook — die Bauform dieses Abschnitts (übergreifend
|
||||
LESEN über alle Mandanten, dann je Mandant BINDEN) kommt an keiner Stelle
|
||||
vor. Es gibt aber einen benannten Sonderfall, anderer Bauart als die fünf
|
||||
Fälle oben: `refreshCacheInBackground` (private Methode) ist eine
|
||||
ABGEKOPPELTE FORTSETZUNG einer Anfrage — `aggregateEvents` stößt sie an,
|
||||
wartet nicht auf sie, und sie ruft `fetchAndCacheEvents` mit den Parametern
|
||||
der ursprünglichen Anfrage auf. Sie iteriert NICHT über mehrere Mandanten
|
||||
und hat deshalb keine übergreifende Hälfte zu binden — sie trägt die
|
||||
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.
|
||||
|
||||
## Bestandsaufnahme
|
||||
|
||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||
@@ -316,7 +345,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||
|---|---|---|---|---|
|
||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
@@ -392,8 +421,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||
werden nicht zwischen Methoden weitergereicht. Der Bereich `dashboard`
|
||||
(260910-krx) hat sich für denselben dienst-internen Weg entschieden — jede
|
||||
der neun umgestellten Methoden in `dashboard.service.ts` erzeugt ihren
|
||||
eigenen `forTenant()`-Aufruf, wie alle sieben Bereiche vor ihm. Die Frage
|
||||
bleibt für alle übrigen Bereiche der Etappe 2 offen.
|
||||
eigenen `forTenant()`-Aufruf, wie alle sieben Bereiche vor ihm. Der Bereich
|
||||
`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.
|
||||
- ~~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
|
||||
|
||||
Reference in New Issue
Block a user