diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 53463d7..5ac5f80 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 11 +open_count: 14 waived_count: 1 fixed_count: 17 -total_count: 29 -last_updated: 2026-09-11T10:00:38.418Z +total_count: 32 +last_updated: 2026-09-11T11:57:50.484Z --- # Broken Windows Ledger @@ -44,6 +44,9 @@ last_updated: 2026-09-11T10:00:38.418Z | 27 | 2 | unmet-truth | apps/api/src/prisma/rls-access-inventory.spec.ts | | Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma. bzw. .. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht. | open | | 2026-09-11T09:08:00.435Z | | | 28 | quick-260911-fh9 | deviation | apps/web/src/components/layout/header.tsx | | Bereich auth: getMe liefert nach dem Scharfschalten null, der Controller antwortet 200 mit leerem Rumpf, fetchCurrentUser (auth-actions.ts) macht daraus null, header.tsx und account-settings-form.tsx tun bei null nichts — die Portalhuelle rendert ohne angemeldeten Benutzer; changePassword liest sich als networkError (nicht als falsches Kennwort); adminResetPassword als 'User not found' ohne UI-Aufrufer. 'nicht angemeldet' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert — an dieselbe Bedingung gebunden wie WINDOWS #18; Familie #23/#25/#26; Etappe-4-Vorabpruefung: bekannten Benutzer ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten. Das Frontend wird von 260911-fh9 NICHT geaendert. | open | | 2026-09-11T10:00:29.558Z | | | 29 | quick-260911-fh9 | unmet-truth | apps/api/src/user/user.controller.ts | | Bereich auth: adminResetPassword schliesst die Rechteausweitung ADMIN -> SUPER_ADMIN im eigenen Handler (T-FH9-04), der Schwesterweg PATCH /users/:id tut das nicht. UserController.update (T-02-08) prueft nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht ob das ZIEL diese Rolle bereits HAT — password/isActive gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch; remove prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist user.role === Role.SUPER_ADMIN und der Aufrufer nicht SUPER_ADMIN, ForbiddenException) — die Vorlage steht seit 260911-fh9 in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen. | open | | 2026-09-11T10:00:38.418Z | | +| 30 | quick-260911-gwh | deviation | apps/api/src/mail/mail.module.ts | | Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a). | open | | 2026-09-11T11:57:36.656Z | | +| 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 | | ````json [ @@ -394,6 +397,42 @@ last_updated: 2026-09-11T10:00:38.418Z "reason": "", "recorded_at": "2026-09-11T10:00:38.418Z", "resolved_at": null + }, + { + "id": 30, + "kind": "deviation", + "phase": "quick-260911-gwh", + "file": "apps/api/src/mail/mail.module.ts", + "line": null, + "description": "Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T11:57:36.656Z", + "resolved_at": null + }, + { + "id": 31, + "kind": "deviation", + "phase": "quick-260911-gwh", + "file": "apps/web/src/components/dashboard/widgets/favorites-widget.tsx", + "line": null, + "description": "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).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T11:57:50.276Z", + "resolved_at": null + }, + { + "id": 32, + "kind": "deviation", + "phase": "quick-260911-gwh", + "file": "apps/web/src/components/settings/smtp-settings-form.tsx", + "line": null, + "description": "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).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T11:57:50.484Z", + "resolved_at": null } ] ```` diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index 37cc5bb..75b1a44 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -17,7 +17,7 @@ import { MailService } from './mail.service'; * siehe deren Kopfkommentar in settings.service.ts fuer beide * Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer * alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie - * `null` und diese Rueckfallkette greift — WINDOWS #TBD-GWH) + * `null` und diese Rueckfallkette greift — WINDOWS #30) * 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS * 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD * 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default) diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index f61dbe1..4164314 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -225,7 +225,7 @@ export class SettingsService { * ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau, * NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt * Anschluss an #21 (andere Datei, andere Reparatur, andere - * Verdeckungsform) — siehe WINDOWS #TBD-GWH und + * Verdeckungsform) — siehe WINDOWS #30 und * `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt * "## Bereich settings", (s4)(a). * diff --git a/docs/anleitung-entwicklung.md b/docs/anleitung-entwicklung.md index 8338d12..bee8da0 100644 --- a/docs/anleitung-entwicklung.md +++ b/docs/anleitung-entwicklung.md @@ -308,21 +308,25 @@ Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt. > Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine > Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand. -`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber -**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`, -`LdapFieldMapping`, `Group`, `GroupMembership` und `ModuleGrant` (siehe die Migrationen -`20260618112133_rls_policies` und `20260804130918_groups_rls_policies`). Alle übrigen -mandantenbezogenen Tabellen — u. a. `DkvVehicleMaster`, `DkvInvoiceHistory`, `CalendarSource`, -`Tender`, `TenderSavedSearch`, `FavoriteLink` — tragen zwar eine `tenantId`-Spalte, aber **keine** -RLS-Policy. +`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies liegen +seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen (4 aus +`20260618112133_rls_policies`, 3 aus `20260804130918_groups_rls_policies`, 16 aus der +`_rls_remaining_tenant_tables`-Migration selbst — `grep -c "ENABLE ROW LEVEL SECURITY"` über die +drei Migrationen, zur Ausführungszeit nachzählen), darunter `FavoriteLink` und `SmtpConfig`. Ohne +eigene `tenantId`-Spalte bzw. bewusst plattformweit bleiben `Module`, `Tenant`, `Tender`, +`TenderSource` und `TenderSourcePollConfig` (siehe die Bestandsaufnahme in +`docs/mandantentrennung-zugriffsklassifikation.md`, Klasse `keine-mandantengebundene-tabelle`, für +die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten). -**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss -`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das -ist im Code auch der gelebte Stil: `DkvService.loadConfig()` -(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und -filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter -vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben -RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query +**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 +`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 +`docs/mandantentrennung-zugriffsklassifikation.md` für den vollständigen Stand je Datei/Modell. +Bei den RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über den globalen, ungebundenen `PrismaService`. diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index ce45caf..ac6703c 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -3025,14 +3025,22 @@ Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant` **Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59 -Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs (siehe Summenzeile in -`docs/mandantentrennung-zugriffsklassifikation.md`, DERIVIERT aus den -Bereichszeilen, nicht abgeschrieben): siehe dortige Summenzeile für die -Endzahl. +Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs — DERIVIERT aus der +Summenzeile in `docs/mandantentrennung-zugriffsklassifikation.md`, nicht +abgeschrieben: **68 ungebundene, 178 gebundene Rohtreffer** (Summe 246 — +mehr als 227, weil Aufgabe 2 dieses Laufs mit dem Widget-Besitzriegel einen +zusätzlichen gebundenen Rohtreffer einführt, der zur Planungszeit noch nicht +feststand). Jeder der 68 verbleibenden ungebundenen Rohtreffer ist einer der +in diesem Dokument (Abschnitte "## Bereich ...") oder im Klassifikationsdokument +namentlich benannten, bewusst ungebundenen Fälle — siehe die Liste unten. **Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`, -Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3) für die -Paarzahl und die Verteilung nach den vier Klassen. +Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3): **65 Paare** +(33 `muss-mandantengebunden`, 17 `keine-mandantengebundene-tabelle`, 13 +`beides`, 2 `bewusst-uebergreifend`) — ein neues Paar +(`favorites.service.ts`/`widgetInstance`) gegenüber den 64 Paaren vor +diesem Lauf, plus zwei Paare, die nur ihre `Stand`-Spalte ändern +(`favorites.service.ts`/`favoriteLink`, `settings.service.ts`/`smtpConfig`). **Bewusst ungebundene Reste je Bereich, mit Grund:** diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index b1e18e3..8a5e46e 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -143,11 +143,11 @@ autoritative Quelle. | auth | 3 | 10 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) | | calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | -| favorites | 7 | 0 | unverändert | -| settings | 4 | 0 | unverändert | -| **Summe** | **78** | **167** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), jetzt 78 nach 260911-fh9 (`auth` 8→3). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), jetzt 167 nach 260911-fh9 (zusätzlich 5 in `auth`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| favorites | 0 | 8 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | +| settings | 1 | 3 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer ist der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30) — bewusst, mit dreifacher Markierung; Befund K (`tenders`/`dkv` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt | +| **Summe** | **68** | **178** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 65 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -226,22 +226,35 @@ einer vergessenen Nachziehung nicht zu unterscheiden — deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt stillschweigend uebersprungen zu werden. +**Stand 260911-gwh (Aufgabe 3):** 65 Paare — 64 aus dem vorherigen Durchlauf +plus EIN neues Paar (`favorites.service.ts`/`widgetInstance`, Klasse +`muss-mandantengebunden`, Stand `gebunden`): der Besitzriegel in `create()` +(T-GWH-05, Aufgabe 1 Pruefung 7 hat den Fremdschluessel-Durchgriff +bestaetigt). Zwei Paare aendern nur ihre `Stand`-Spalte, keine ihrer Klasse: +`favorites.service.ts`/`favoriteLink` (`ungebunden` auf `gebunden`) und +`settings.service.ts`/`smtpConfig` (`ungebunden` auf `gemischt`). Das ist +der ENDSTAND der Etappe 2 — die Zahl ist der Ausgabe von +`rls-access-inventory.spec.ts` entnommen, nicht geschaetzt. + | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 32 | +| muss-mandantengebunden | 33 | | keine-mandantengebundene-tabelle | 17 | | beides | 13 | | bewusst-uebergreifend | 2 | -| **Summe** | **64** | +| **Summe** | **65** | -## Der Hintergrunddienst als Falle — fünf Fälle +## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — muss aber *innerhalb* der Schleife je Mandant binden. Vier Dateien sind in diesem Sinne `beides`-Fälle, davon einer (260910-das) der bislang EINZIGE, der auf BEIDEN Hälften bereits richtig ist; der fünfte, seit 260909-mir bekannte Fall ist von anderer Art und deshalb unten getrennt aufgeführt — er -iteriert gar nicht, sondern greift sich eine beliebige Zeile heraus: +iteriert gar nicht, sondern greift sich eine beliebige Zeile heraus. Der +sechste Fall (seit 260911-gwh) ist von DERSELBEN Bauart wie der fünfte — +kein Iterieren, eine beliebige-aber-vorhandene Zeile, kein Mandantenkontext +beim Start — mit einer zusätzlichen Verdeckungsschicht, siehe unten: - **`ldap.service.ts`** (AD-Abgleich) — **Stand 260909-ipc, Aufgaben 2/3: geschlossen.** Iteriert nicht selbst über alle Mandanten (der Sync läuft @@ -331,6 +344,61 @@ Verzweigung hinter einem optionalen Parameter, die jemand später `dkv-scheduler.service.ts`, Ledger-Eintrag WINDOWS #21. Das Signal für das Verstummen gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`). +**Der sechste Fall, gleicher Bauart wie der fünfte — `mail.module.ts` / +`SettingsService.loadAnySmtpConfigForStartupTransport()`** (260911-gwh, +Befund D, WINDOWS #30): offen, als benannte Altlast weitergeführt. Dieselbe +Form wie der fünfte Fall — `findFirst()` ohne jede Bedingung zieht bei +mehreren Mandanten EINEN beliebigen und bedient die übrigen NIE; **heute +bereits falsch**, weil der SMTP-Server und die Absenderadresse EINES +beliebigen Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails +ALLER Mandanten tragen (T-GWH-03, Nutzung fremder Zugangsdaten). Binden ist +auch hier keine Lösung: `useFactory` hat beim Start strukturell keinen +Mandantenkontext. Der Umbau auf Transport je Versand aus +`getDecryptedSmtpConfig(tenantId)` — die Form, die `DkvMailService`/ +`TenderMailService` bereits haben — ist eine Funktionsänderung +(Umbau des Mailmoduls), kein Bindungsumbau, deshalb NICHT in Etappe 2 +vorgenommen. + +Die UNSYMMETRIE zu BEIDEN Präzedenzfällen: `ldap.service.ts`/ +`getAllActiveConfigs` ist heute korrekt und verstummt erst später; der +DKV-Planer (fünfter Fall) ist heute bereits falsch und verstummt zusätzlich +später, ABER MIT einer Protokollzeile ("no active config found"). Der +Mail-Startpfad ist heute bereits falsch UND verstummt später OHNE +Protokollzeile, weil `mail.module.ts`s Rückfallkette (Priorität 2 `MAIL_*`, +3 `TESSERA_SMTP_*`, 4 `localhost:1025`) einen FALSCHEN, aber vorhandenen +Transport an die Stelle der Leere setzt — `MailService` fängt den +Transportfehler (T-02-12), der Controller antwortet `200`. Das Verstummen +ist damit DOPPELT verdeckt, eine dritte Ausprägung, die keiner der beiden +Vorlagen (`ldap`, `dkv`) vollständig entspricht. + +Dreifache Markierung: eigene benannte Methode mit Kopfkommentar (dkv- +Präzedenzfall, ein Name, den niemand für einen Anfrageweg hält), Modul- +kommentar in `mail.module.ts`, Ledger-Eintrag WINDOWS #30 (EIGENER Eintrag +statt Anschluss an #21: andere Datei, andere Reparatur, andere +Verdeckungsform). Das Signal für das Verstummen gehört ebenfalls in die +Vorabprüfung von Etappe 4 (`rls-preflight.mjs`). + +Befund K ist mit dieser Bindung ERFÜLLT: `getDecryptedSmtpConfig(tenantId)` +— der einzige Versandpfad von `tender-mail.service.ts` und +`dkv-mail.service.ts` — läuft seit 260911-gwh über `forTenant()` +(siehe Bestandsaufnahme-Zeile `settings.service.ts`/`smtpConfig` oben und +`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitte "## Bereich +tenders" (t4) und "## Bereich dkv" (d4), jeweils Nachtrag 260911-gwh); die +Etappe-4-Vorabprüfung muss diese Reihenfolgebedingung ab jetzt NICHT mehr +führen. + +**Stand 260911-gwh — der Bereich `favorites` fügt diesem Abschnitt keinen +weiteren Fall hinzu, gemessen statt angenommen (Befund K).** Anweisung: +`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/favorites apps/api/src/settings --include=*.ts | grep -v spec` +liefert außerhalb von Testdateien genau EINEN Treffer, +`icon-discovery.service.ts:255` — ein `setTimeout` für den Abbruch eines +HTTP-Abrufs, kein Planer (dieselbe Form wie `ics.provider.ts:100` in +260911-cwh). Die Bauform dieses Abschnitts (übergreifend LESEN über alle +Mandanten, dann je Mandant BINDEN) kommt in `favorites` an keiner Stelle +vor; der einzige Hintergrund-Zugriff des Bereichspaares ist der oben +beschriebene sechste Fall, und der lebt nicht in `favorites`, sondern in +`mail.module.ts`/`settings.service.ts`. + **Stand 260910-exd — kein sechster Fall, gemessen statt angenommen.** Der Bereich `module-registry` fügt diesem Abschnitt KEINEN sechsten Fall hinzu. `ModuleRegistryService.seedModule()` (die Katalogpflege beim Start, @@ -428,7 +496,8 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | 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 | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. | +| 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 | 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). | | apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden, einschliesslich des Zugriffs innerhalb des Standardgruppen-Aufbaus (Befund B). | @@ -450,7 +519,7 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. | | apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. | | apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. | -| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. | +| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gemischt | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — keine uebersehene Fundstelle, dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. | | apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. | | apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. | | apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. | @@ -533,3 +602,13 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). an `username`/`email`. Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth", (h4)(a). +- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der + Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst + ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe + oben) — ein Umbau auf Transport je Versand aus + `getDecryptedSmtpConfig(tenantId)`, die Form, die `DkvMailService`/ + `TenderMailService` bereits haben, ist eine Funktionsänderung + (Umbau des Mailmoduls), kein Bindungsumbau, und deshalb NICHT Gegenstand + dieser Etappe. Siehe + `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich settings", + (s4)(a).