diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index ff7eecd..427292c 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 14 +open_count: 15 waived_count: 1 fixed_count: 18 -total_count: 33 -last_updated: 2026-09-11T14:48:15.447Z +total_count: 34 +last_updated: 2026-09-11T15:46:08.295Z --- # Broken Windows Ledger @@ -48,6 +48,7 @@ last_updated: 2026-09-11T14:48:15.447Z | 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | | | 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | | | 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER ..(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | | +| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | | ````json [ @@ -446,6 +447,18 @@ last_updated: 2026-09-11T14:48:15.447Z "reason": "", "recorded_at": "2026-09-11T14:48:09.723Z", "resolved_at": null + }, + { + "id": 34, + "kind": "deviation", + "phase": "quick-260911-nke", + "file": "apps/api/src/prisma/prisma-tenant.extension.ts", + "line": null, + "description": "Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T15:46:08.295Z", + "resolved_at": null } ] ```` diff --git a/docs/anleitung-entwicklung.md b/docs/anleitung-entwicklung.md index bee8da0..3b44cc0 100644 --- a/docs/anleitung-entwicklung.md +++ b/docs/anleitung-entwicklung.md @@ -320,8 +320,20 @@ die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten **Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma` -(`apps/api/src/prisma/prisma-tenant.extension.ts`) — die zusätzlichen `where`-Filter über -`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (siehe +(`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)` +trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b, +260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen +(CalendarSource, DashboardLayout, FavoriteLink, SearchProvider, +TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, +TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension +in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`), +vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten +(GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur +Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege +rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie +wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über +`userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz, +kein Ersatz (siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId` (oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B. plattformweiter Katalog, kein Mandantenfilter nötig); siehe diff --git a/docs/mandantentrennung-datenbankrolle.md b/docs/mandantentrennung-datenbankrolle.md index 3b5f0f3..4e69069 100644 --- a/docs/mandantentrennung-datenbankrolle.md +++ b/docs/mandantentrennung-datenbankrolle.md @@ -67,6 +67,24 @@ noetig sind: `20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine Regel. +- **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben + `app.current_tenant` (Migration `20260618112133_rls_policies`, + `current_tenant_id()`) setzt die Rolle seit Migration + `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b, + 260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion + `current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der + Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring, + nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen + Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann. + Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt + PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln + der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout, + FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref, + TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance) + pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` — + ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht + weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige + Aufrufer wirkungslos. ## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index 92eb684..51062ec 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -460,6 +460,14 @@ Bereich brauchte: anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden Dienste bereits führen, bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt. + + **Nachtrag (260911-nke):** seit Migration `20260911120000_rls_user_dimension_personal_tables` + trägt die Regel auf `TenderSavedSearch` die Benutzerdimension — die alte + Messung bleibt unter dem Namen `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + als die gewollte Eigenschaft für Admin/Hintergrunddienst bestehen, die + Umkehrung `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden` + misst MIT Benutzer und erwartet das Gegenteil. Siehe Abschnitt + "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten. - `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B @@ -654,6 +662,15 @@ Warnung wäre Dauerlärm und verlöre ihr Signal. Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser Aufgabe. + **Nachtrag (260911-nke):** die Benutzerdimension der `TenderRssFeedSource`-Regeln + ist seit Migration `20260911120000_rls_user_dimension_personal_tables` + Teil der vier befehlsgetrennten Regeln (Lesen schließt gemeinsame/eigene + Zeilen ein, Schreiben verlangt weiterhin `userId = current_user_id()`), + gemessen in `tenderrssfeed-gemeinsame-zeile-*`. Befund G (Admin eines + beliebigen Mandanten entfernt eine plattformweite Zeile) bleibt + unverändert — WINDOWS #24 hält das offen, siehe Abschnitt "Regelschluss + Benutzerdimension (Etappe 3b, 260911-nke)" unten. + ### (t5) Was dieser Durchlauf bewusst nicht anfasst Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03, @@ -1749,6 +1766,13 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F: eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die anwendungsseitige `userId`-Filterung bleibt der einzige Schutz. + + **Nachtrag (260911-nke): ÜBERHOLT — die zweite Sitzungsvariable ist + gebaut.** Migration `20260911120000_rls_user_dimension_personal_tables` + führt `app.current_user`/`current_user_id()` ein und trägt die + Benutzerdimension in die Regeln der zehn persönlichen Tabellen, darunter + `TenderSavedSearch`. Siehe Abschnitt "Regelschluss Benutzerdimension + (Etappe 3b, 260911-nke)" unten. - **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS #22)** — unverändert, nicht Gegenstand dieses Plans. @@ -1809,6 +1833,21 @@ searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebund Alle 87 Pruefungen bestanden. ``` +**Nachtrag (260911-nke):** die drei oben zitierten Ausgabezeilen +(`dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`, +`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`, +`searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`) +bleiben als historische Messung stehen — sie sind seit Migration +`20260911120000_rls_user_dimension_personal_tables` UMGEDREHT, nicht +gelöscht: die alte Messung lebt unter den neuen Namen +`dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`, +`widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` und +`searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` +weiter (jetzt als gewollte Eigenschaft für Admin/Hintergrunddienst), dazu je +eine neue Umkehrung `-benutzer-a-sieht-kollegen-nicht-gebunden` MIT +Benutzer. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, +260911-nke)" unten. + Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans namentlich vorgezeichnet — die dreizehnte (`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`) @@ -1984,6 +2023,15 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt. gebunden wie #18 — er wird erst nach dem Scharfschalten beobachtbar. + **Nachtrag (260911-nke):** die Benutzerdimension der Regeln auf + `DashboardLayout`/`WidgetInstance`/`SearchProvider` (Lesen UND Schreiben + je Kollegenzeile) ist seit Migration `20260911120000_rls_user_dimension_personal_tables` + geschlossen — siehe (w1) oben und Abschnitt "Regelschluss + Benutzerdimension (Etappe 3b, 260911-nke)" unten. Die plattformweite + Eindeutigkeit von `DashboardLayout.userId` (Befund K, dieser Punkt) ist + davon UNBERÜHRT und bleibt für Etappe 3 vorgemerkt — 3b ändert keine + Unique-Constraints. + ### (w5) Was dieser Durchlauf bewusst nicht anfasst - **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen, @@ -2086,6 +2134,16 @@ calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: be Alle 101 Pruefungen bestanden. ``` +**Nachtrag (260911-nke):** `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` +ist seit Migration `20260911120000_rls_user_dimension_personal_tables` +UMGEDREHT — die alte Messung lebt unter dem Namen +`calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` +weiter (gewollte Eigenschaft ohne Benutzer), die neue Umkehrung +`calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` misst MIT +Benutzer und bestätigt, dass `encryptedPassword` eines Kollegen jetzt NICHT +mehr lesbar ist. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe +3b, 260911-nke)" unten. + 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 @@ -2235,6 +2293,14 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche. 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. + + **Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der + Regel.** Migration `20260911120000_rls_user_dimension_personal_tables` + trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die + `CalendarSource`-Regel; `calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` + bestätigt, dass die verschlüsselten Zugangsdaten eines Kollegen jetzt + NICHT mehr lesbar sind. Der `userId`-Filter und die Besitzprüfungen + bleiben zusätzlich bestehen (zweites Netz, kein Ersatz). - **(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 @@ -2749,6 +2815,17 @@ favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.fav Alle 137 Pruefungen bestanden. ``` +**Nachtrag (260911-nke):** die Doppelaussage in +`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen` +ist GETRENNT — die Prüfung behält nur die erste Hälfte (die eigenen Zeilen +kommen). Die zweite Hälfte (Kollege sichtbar) lebt jetzt als eigene Prüfung +`favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` (alte +Messung, gewollte Eigenschaft ohne Benutzer) plus die Umkehrung +`favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden` (MIT Benutzer, +Kollege NICHT mehr sichtbar) — seit Migration +`20260911120000_rls_user_dimension_personal_tables`. Siehe Abschnitt +"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten. + Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`, während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem @@ -2817,6 +2894,14 @@ Scharfschalten neu entsteht. `userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten. + + **Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der + Regel.** Migration `20260911120000_rls_user_dimension_personal_tables` + trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die + `FavoriteLink`-Regel; `favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden` + bestätigt, dass die Kollegenzeile über die `widgetId` jetzt NICHT mehr + sichtbar ist. Die anwendungsseitige `userId`-Filterung bleibt zusätzlich + bestehen (zweites Netz, kein Ersatz). - **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert; Ledger-Eintrag in Aufgabe 3. - **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht @@ -3029,6 +3114,118 @@ unverändert und steht nicht in der Erlaubnisliste. (lokal gibt es keinen `mailhog`); in der neuen Testdatei per `vi.mock('nodemailer')` ersetzt. +## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke) + +Reiner Datenbank- und Helfer-Umbau: Migration `20260911120000_rls_user_dimension_personal_tables` +bringt die Funktion `current_user_id()` und die Benutzerdimension in die +Regeln der zehn persönlichen Tabellen. `forTenant(prisma, tenantId, userId?)` +bekommt einen optionalen dritten Parameter; 34 Nutzer-CRUD-Aufrufstellen in +acht Diensten reichen ihn durch. Der Schalter bleibt AUS — nichts hiervon +wirkt, bis Etappe 4 scharfschaltet. + +### (b1) Die Messung + +Wörtliche Werkzeugausgabe der drei Funktionsfälle: + +``` +current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null +current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null +current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1" +``` + +Wörtliche Werkzeugausgabe zweier der sechs Umkehrungen (eine Ein-Regel-Tabelle, +eine der beiden vier-Regel-Tabellen): + +``` +tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"] +calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar +searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern +tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch +Alle 203 Pruefungen bestanden. +``` + +`pg_policies` der lebenden Datenbank (`tessera-ctl-db-1`) für die zehn +umgestellten und die vier bewusst unveränderten Tabellen, gemessen nach dem +Anwenden von `20260911120000` (Tabelle#Regelname#Befehl#USING#WITH CHECK): + +``` +CalendarSource#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +DashboardLayout#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +FavoriteLink#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))# +ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))# +PasswordResetToken#tenant_isolation_policy#ALL#("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))# +SearchProvider#tenant_user_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +SearchProvider#tenant_user_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id()))) +SearchProvider#tenant_user_read_policy#SELECT#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))# +SearchProvider#tenant_user_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id()))) +TenderEmailConfig#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +TenderMatch#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())# +TenderNotificationPref#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +TenderRssFeedSource#tenant_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +TenderRssFeedSource#tenant_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id()))) +TenderRssFeedSource#tenant_platform_read_policy#SELECT#((("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL)) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))# +TenderRssFeedSource#tenant_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id()))) +TenderSavedSearch#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +TenderTriage#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +WidgetInstance#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# +``` + +Werkzeug-Endstand nach Aufgabe 2: `Alle 203 Pruefungen bestanden.` (Baseline vor +diesem Lauf: 137). Tests am Ende von Aufgabe 2: 1020 bestanden / 62 Dateien, +Typprüfung sauber. + +### (b2) Signaltabelle — beide Fehlerrichtungen je Regel + +| Fehlerrichtung | Erwartung | Gemessen | Befund | +|---|---|---|---| +| Zu streng: ein Aufruf OHNE Benutzer (Admin, Hintergrunddienst) sähe nur EINEN Nutzer statt des ganzen Mandanten | Regel müsste beide Nutzer weiterhin liefern | `-ohne-benutzer-sieht-beide` (zehn Tabellen) — JEDE Tabelle liefert beide Nutzer | NICHT der Fall — die `IS NULL OR`-Form wirkt wie entworfen | +| Zu locker: ein Aufruf MIT Benutzer sähe die Zeile eines Kollegen DESSELBEN Mandanten | Regel müsste die Kollegenzeile ausblenden | `-benutzer-a-sieht-kollegen-nicht(-gebunden)` (zehn Tabellen) — JEDE Tabelle blendet aus; `-schreiben-als-a-mit-kennung-b-abgelehnt` (SQLSTATE 42501) für alle zehn | NICHT der Fall — Lesen UND Schreiben sind durchgesetzt | +| Bewusst offene Flanke: ein Nutzer-CRUD-Aufrufer, der `userId` VERGISST | Sähe den ganzen Mandanten, keine Fehlermeldung | Nicht durch einen Test erzwungen — kein Wächter über das dritte Argument gebaut | Akzeptiert, aufgezeichnet (T-NKE-02, WINDOWS-Eintrag unten) — heute exakt der Stand vor dieser Migration | + +### (b3) Welcher Code Leere anders deutet als vorher + +Erst wirksam NACH dem Scharfschalten (Etappe 4) — heute mit BYPASSRLS ohne +Wirkung, hier vorab aufgezeichnet, weil der Codepfad schon jetzt geschrieben +ist. Methoden, die eine Zeile per `findUnique({ where: { id } })` holen und +danach `userId` gegen den Aufrufer vergleichen, sehen nach dem Scharfschalten +die Kollegenzeile bereits als `null` (die Regel blendet sie aus, BEVOR die +Anwendung überhaupt vergleicht) — die Anwendung meldet dann `NotFoundException` +statt der heutigen `Forbidden`-artigen Abweisung über den `userId`-Vergleich. +Betroffen: `dashboard.service.ts` (`removeWidget`, `updateWidgetConfig`, +`removeSearchProvider`), `calendar.service.ts` (`updateSource`, +`deleteSource`), `favorites.service.ts` (`update`, `remove`). Beides ist eine +Abweisung — kein Informationsleck entsteht, nur die Fehlerart ändert sich von +Forbidden zu NotFound. + +### (b4) Was dieser Durchlauf bewusst nicht löst + +- Systemkontext für Hintergrunddienste (Etappe 3c) — `tender-digest.scheduler.ts` + bleibt bewusst zweistellig, mit Kommentar. +- Anmeldenamen pro Mandant (Etappe 3a) — nicht Teil dieses Laufs. +- Kein Wächter, der jede Nutzer-CRUD-Aufrufstelle auf das dritte Argument von + `forTenant()` prüft. Die Bestandsaufnahme (`rls-access-inventory.spec.ts`) + unterscheidet heute nur mandanten-gebunden/ungebunden, nicht + benutzer-gebunden — ein Aufrufer, der `userId` vergisst, ist für sie + unsichtbar. Netz bis dahin: die dreistelligen Spec-Zusicherungen je Dienst. + Siehe der neue WINDOWS-Eintrag unten. +- WINDOWS #24 (Admin-Erstellung/-Entfernen plattformweiter `TenderRssFeedSource`-Zeilen + bleibt ungebunden) bleibt unverändert offen — diese Migration ändert daran + nichts, `tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar` + bestätigt das lediglich erneut. + +### (b5) Was dieser Durchlauf bewusst nicht anfasst + +- Die vier Tabellen mit `userId`-Spalte, die KEINE persönlichen Daten tragen + (`GroupMembership`, `ModuleGrant`, `PasswordResetToken`, `TenderMatch`) — + Verwaltungsobjekte, Anmelde-Artefakt, Hintergrunddienst-Schreibweg, + begründet im Kopf der Migration. +- Die drei SECURITY-DEFINER-Anmeldefunktionen (`auth_lookup_user_by_username`, + `auth_lookup_user_by_email`, `auth_lookup_reset_token`) — unangetastet. +- `schema.prisma` — unverändert, Gate gegen `8829999` in jeder Aufgabe. +- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS. +- Compose-/Umgebungsdateien — unangetastet. + ## Etappe 2 — Abschluss Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig: @@ -3111,6 +3308,15 @@ dieser Aufgabe. Etappe-3-Entscheidung (1)) — siehe (h4)(a). - Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung. + **Nachtrag (260911-nke): ERLEDIGT.** Migration + `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) trägt die + Benutzerdimension in die Regeln aller zehn persönlichen Tabellen; 34 + Nutzer-CRUD-Aufrufstellen reichen `userId` an `forTenant()` durch. Siehe + Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" oben. + Was davon bewusst offenbleibt: ein Aufrufer, der `userId` vergisst, sieht + weiterhin den ganzen Mandanten (kein Wächter gebaut, siehe + `.planning/WINDOWS.md`); Systemkontext für Hintergrunddienste (Etappe 3c) + und Anmeldenamen pro Mandant (Etappe 3a) bleiben offen. - Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald eine Regel eingeführt wird, müssen die heute bewusst ungebundenen Katalogzugriffe nachgezogen werden. diff --git a/docs/mandantentrennung-etappe3-auftrag.md b/docs/mandantentrennung-etappe3-auftrag.md index 6be6732..8bc1a35 100644 --- a/docs/mandantentrennung-etappe3-auftrag.md +++ b/docs/mandantentrennung-etappe3-auftrag.md @@ -38,6 +38,19 @@ der Grundlagen beginnen koennen. Alles hier ist gemessen, nicht erinnert. ### 3b zuerst: Benutzerdimension in den Regeln +**Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser +Aufgabe):** Migration +`20260911120000_rls_user_dimension_personal_tables` bringt `current_user_id()` +und die Benutzerdimension in die Regeln der zehn persoenlichen Tabellen; +`forTenant(prisma, tenantId, userId?)` bekommt den optionalen dritten +Parameter, 34 Nutzer-CRUD-Aufrufstellen in acht Diensten reichen ihn durch. +Gemessene Zahl der umgedrehten Loch-Pruefungen: SECHS, nicht drei wie unten +noch angenommen. Endzahlen: Tests 1020/62 Dateien, Werkzeug +`rls-scratch-check.mjs` 203/203 bestanden (Baseline vor diesem Lauf: 137). +Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt +"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Der ursprüngliche +Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage). + Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als "Policy hat keine Benutzerdimension" festgehalten wurde. diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index f8df955..cc1085d 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -278,6 +278,15 @@ Unterabfrage-Form, die WINDOWS #27 aufgedeckt hat. Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen, nicht geschaetzt. +**Stand 260911-nke:** die Paarzahl (72) und die Klassen-Verteilung sind +UNVERÄNDERT — Etappe 3b (Benutzerdimension in den Regeln, `forTenant()` +bekommt ein drittes Argument) fügt keine neue Fundstelle hinzu und ändert +keine bestehende von mandanten-gebunden auf mandanten-ungebunden oder +umgekehrt; benutzer-gebunden ist keine eigene Klasse in diesem Schema. Die +Benutzerdimension steht stattdessen in der Begründungsspalte der drei +betroffenen Bestandsaufnahme-Zeilen (`calendarSource`, `widgetInstance`, +`favoriteLink`) oben und im Abschnitt "Was diese Etappe NICHT entscheidet". + | Klasse | Anzahl Paare | |---|---| | muss-mandantengebunden | 35 | @@ -572,15 +581,15 @@ werden. |---|---|---|---|---| | 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 | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | -| 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/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. Benutzerdimension seit 20260911120000 (260911-nke). | | 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). | -| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). | +| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | -| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. | +| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). | | apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. | | apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). | @@ -693,6 +702,19 @@ werden. an `username`/`email`. Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth", (h4)(a). +- **Wie die Benutzerdimension in die Regeln der zehn persönlichen Tabellen + kommt (Etappe-3-Entscheidung (2)).** **Aufgelöst (260911-nke):** Migration + `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) bringt + `app.current_user`/`current_user_id()` und die `IS NULL OR`-Form in die + Regeln aller zehn persönlichen Tabellen; `forTenant(prisma, tenantId, + userId?)` bekommt den optionalen dritten Parameter, 34 Nutzer-CRUD- + Aufrufstellen reichen ihn durch. Siehe + `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt + "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Was weiterhin + offen ist: ein Aufrufer, der `userId` vergisst, sieht den ganzen + Mandanten (kein Wächter gebaut, siehe `.planning/WINDOWS.md`); + Systemkontext (Etappe 3c) und Anmeldenamen pro Mandant (Etappe 3a) bleiben + offen. - **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe