---
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."
---
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.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.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
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.
Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — an der Regel, wie sie nach der Migration 20260910120000 steht, durch den generierten Client
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.
apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md
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)
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 = ` 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: }, 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`.
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/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; }; }
`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.
Aufgabe 2: Die Testlage aus dem Nichts anlegen, dann alle zwoelf Zugriffe binden und den Mandanten durchreichen — Nachschlagen und Schreiben nie getrennt
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
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)
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.
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.
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; }; }
`calendar.service.spec.ts` existiert neu mit dem Zwei-Klienten-Nachweis, Attrappen fuer Verschluesselung und Provider, und deckt jeden in `` 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.
Aufgabe 3: Die uebrigen vier handgepflegten Dokumentstellen nachziehen, den Ledger-Eintrag anlegen und die Gates falsifizieren
docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md
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)
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/`.
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; }; }
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.
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. |
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`.
- 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.