6 Commits

Author SHA1 Message Date
schalli f1017fa6e9 docs(quick-260911-cwh): Etappe 2 Bereich calendar abgeschlossen und verifiziert
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 53s
Tessera CI/CD / Build & Publish Images (push) Successful in 28s
2026-09-11 10:08:01 +02:00
schalli 06038b9dfa docs(quick-260911-cwh): complete Bereich calendar plan 2026-09-11 10:01:30 +02:00
schalli e0e163ec63 docs(quick-260911-cwh): Bereich calendar Etappe 2 abgeschlossen — restliche Dokumentstellen nachgezogen (Aufgabe 3)
- docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeile
  calendar auf 0/12 gezogen (war 12/0, kein ungebundener Rest — erster
  Bereich in Folge ohne begruendeten Rest), Summenzeile auf 83/159
  fortgeschrieben, Klassen-Verteilung unveraendert mit ausdruecklichem
  Stand-260911-cwh-Vermerk, Hintergrunddienst-Abschnitt um den
  Sonderfall refreshCacheInBackground (abgekoppelte Fortsetzung, kein
  sechster Fall) ergaenzt, Abschnitt "Was diese Etappe NICHT entscheidet"
  um calendar ergaenzt
- .planning/WINDOWS.md: neuer offener Eintrag (#26) zur lautlosen
  Auspraegung der umgekehrten Fehlerrichtung im Bereich calendar, ueber
  gsd-tools windows append angelegt
- Beide Falsifizierungsnachweise durchgefuehrt (falscher Stand macht
  rls-access-inventory.spec.ts rot; falsche Uebersichtszahl macht das
  herleitende Gate rot), zurueckgenommen
- Etappe 2, neunter Bereich (calendar) abgeschlossen: alle zwoelf
  Zugriffe gebunden, Baseline gehalten (883 Tests, 57 Dateien, 101
  Werkzeugpruefungen, Typpruefung sauber)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 09:59:17 +02:00
schalli 77cb124f59 feat(quick-260911-cwh): Bereich calendar binden — alle 12 Zugriffe ueber forTenant() (Aufgabe 2)
- calendar.service.spec.ts: NEU, Zwei-Klienten-Nachweis ueber __makeBoundClient
  (Muster dkv.service.spec.ts), Attrappen fuer CryptoService und die drei
  Provider, 23 Testfaelle: getSources/addSource, updateSource inkl. drei
  Erhaltungsfaelle, alle drei Besitzpruefungen je Ausnahmeart, testConnection
  Erfolgs-/Fehlerpfad, aggregateEvents/fetchAndCacheEvents inkl. beider
  Synchronstatus-Rueckschreibungen, Cache-Verhalten inkl. Nutzer-Trennung,
  testConnectionFromConfig ohne DB-Zugriff, Wachhund fuer genau einen
  gebundenen Klienten je Aufruf
- calendar.service.ts: alle 12 Zugriffe auf forTenant() umgestellt, ein
  tenantPrisma-Klient je Methode (getSources, addSource, updateSource,
  deleteSource, testConnection, fetchAndCacheEvents); Cache-Schluessel-Urteil
  und die kein-sechster-Hintergrunddienst-Begruendung als Kommentare
  festgehalten
- calendar.controller.ts: alle sechs kontextnutzenden Handler reichen
  Benutzer- UND Mandantenkennung aus extractContext durch, keine neue
  Vertrauensquelle
- Falsifizierungsnachweis durchgefuehrt: eine Rueckschreibung testweise
  entbunden, benannter Test ging rot ("aggregateEvents, Erfolgspfad"), Fund
  bestaetigt, zurueckgenommen
- docs/mandantentrennung-zugriffsklassifikation.md: Bestandsaufnahme-Zeile
  calendar.service.ts/calendarSource auf gebunden gezogen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 09:55:30 +02:00
schalli bf5fc4d4c7 feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1)
- rls-scratch-check.mjs: elfter Abschnitt runCalendarAreaChecks mit 13
  namentlich benannten Pruefungen gegen die aus 20260909140000_rls_remaining_tenant_tables
  geschnittene Regel, davon 4 ueber den generierten Client an einer
  schemagleichen Wegwerf-Tabelle (17 Spalten, gegen schema.prisma
  laufzeitgeprueft); Laufzeitpruefung, dass 20260910120000 keine eigene
  CalendarSource-Regel traegt (Messfalle 260910-jab)
- Alle 101 Pruefungen bestehen (88 bisherige + 13 neue)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitt
  "## Bereich calendar" mit (k1)-(k5) — Messung, Signaltabelle je Pfad,
  Leere-als-Abwesenheit in Backend UND Frontend samt Fehlerverschluckung,
  bewusst nicht geloest (Cache-Schluessel-Urteil, Zugangsdaten-Erhaltung,
  fehlende Benutzerdimension, 403/404), bewusst nicht angefasst
- Wettlauf-Fehlerklasse gemessen: PrismaClientKnownRequestError (P2025),
  NICHT PrismaClientUnknownRequestError wie im Bereich dashboard — Aufgabe
  2 braucht deshalb keine neue Fehleruebersetzung fuer die Besitzpruefungen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 09:48:44 +02:00
schalli 508d9e4301 docs(quick-260911-cwh): Plan fuer Etappe 2, Bereich calendar
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 09:36:12 +02:00
11 changed files with 2596 additions and 44 deletions
+8 -5
View File
@@ -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
View File
@@ -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
}
]
````
@@ -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>
@@ -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*
@@ -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)*
+332
View File
@@ -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 {
+19 -10
View File
@@ -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);
}
});
});
+72 -21
View File
@@ -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