From f68beb379a833f2d003b3357e9addab500f09a3e Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 12:01:47 +0200 Subject: [PATCH] docs(quick-260911-fh9): Mandantenquelle im auth-Controller festnageln, Klassifikation nachziehen, Ledger-Eintraege anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.controller.spec.ts: NEU. Mandantenquelle je Handler (das Claim fuer me/changePassword; fuer die oberste Rolle der Mandant des Ziels aus dem Fan-out), unbekanntes Ziel, null-Durchreichung von me, Rollen-Metadaten (ROLES_KEY) und Public-Metadaten (IS_PUBLIC_KEY) fuer alle sieben Handler — 23 Faelle; Falsifizierungsnachweis am Fan-out-Zweig durchgefuehrt und zurueckgenommen - docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeile auth auf 3/10 (war 8/5), Summenzeile 78/167, Bestandsaufnahme-Zeile auth.service.ts/user auf gebunden (keine gemischt-Zeile mehr fuer diese Datei), Klassen-Verteilung unveraendert bei 64 Paaren mit Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, Etappe-3-Anmeldeweg-Punkt in "Was diese Etappe NICHT entscheidet"; beide Dokument-Falsifizierungen durchgefuehrt und zurueckgenommen - .planning/WINDOWS.md: zwei neue offene Eintraege (#28 verschluckte Leere im Frontend, Familie #23/#25/#26; #29 Rechteausweitung ADMIN->SUPER_ADMIN im Schwesterweg PATCH /users/:id, T-FH9-05) Baseline wiederhergestellt: 951/951 Tests gruen in 60 Dateien (927+23 neue Faelle plus der in Aufgabe 2 erwartungsgemaess rote Test), Werkzeug 120/120, Typpruefung sauber. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .planning/WINDOWS.md | 32 ++- apps/api/src/auth/auth.controller.spec.ts | 213 ++++++++++++++++++ ...andantentrennung-zugriffsklassifikation.md | 45 +++- 3 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.spec.ts diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index b157888..53463d7 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 9 +open_count: 11 waived_count: 1 fixed_count: 17 -total_count: 27 -last_updated: 2026-09-11T09:08:00.435Z +total_count: 29 +last_updated: 2026-09-11T10:00:38.418Z --- # Broken Windows Ledger @@ -42,6 +42,8 @@ last_updated: 2026-09-11T09:08:00.435Z | 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | | | 26 | quick-260911-cwh | deviation | apps/web/src/components/dashboard/widgets/calendar-widget.tsx | | Bereich calendar: zu kleines Leseergebnis auf getSources/fetchAndCacheEvents sieht aus wie 'keine Quelle eingerichtet' bzw. 'keine Termine' (calendar-widget.tsx, calendar-settings-panel.tsx); das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand (calendar-widget.tsx catch->setEvents([]), calendar-settings-panel.tsx .catch(()=>{}) auf fetchSources); der Nutzer legt seine Quelle neu an und tippt Exchange-/CalDAV-Zugangsdaten ein zweites Mal in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt unsichtbar liegen und wird nach Behebung zur Dublette; Vorabpruefung fuer Etappe 4: physisch vorhandene CalendarSource-Zeilen je Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung vergleichen (docs/mandantentrennung-etappe2-fehlerrichtung.md (k4)(e)); an dieselbe Bedingung gebunden wie WINDOWS #18; Familie mit #23 (module-registry) und #25 (dashboard); das Frontend wird von 260911-cwh NICHT geaendert. | open | | 2026-09-11T07:57:36.769Z | | | 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 | | ````json [ @@ -368,6 +370,30 @@ last_updated: 2026-09-11T09:08:00.435Z "reason": "", "recorded_at": "2026-09-11T09:08:00.435Z", "resolved_at": null + }, + { + "id": 28, + "kind": "deviation", + "phase": "quick-260911-fh9", + "file": "apps/web/src/components/layout/header.tsx", + "line": null, + "description": "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.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T10:00:29.558Z", + "resolved_at": null + }, + { + "id": 29, + "kind": "unmet-truth", + "phase": "quick-260911-fh9", + "file": "apps/api/src/user/user.controller.ts", + "line": null, + "description": "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.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T10:00:38.418Z", + "resolved_at": null } ] ```` diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..080bda8 --- /dev/null +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,213 @@ +import 'reflect-metadata'; +import { BadRequestException } from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; +import { IS_PUBLIC_KEY } from './decorators/public.decorator'; +import { ROLES_KEY } from './decorators/roles.decorator'; +import { AuthController } from './auth.controller'; + +/** + * auth.controller.spec.ts — NEU (260911-fh9, Aufgabe 3). Nagelt die + * Mandantenquelle je Handler fest: `me`/`changePassword` reichen + * ausschliesslich `user.tenantId` aus dem Sitzungsnachweis (`@CurrentUser()`) + * durch; `adminResetPassword` verzweigt nach Rolle des AUFRUFERS — ADMIN + * bindet an den eigenen Mandanten, SUPER_ADMIN loest den Mandanten des + * ZIELS ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` + * auf. Form: `tenant.controller.spec.ts` (Dienst-Attrappen, Rollen-Metadaten + * ueber `Reflect.getMetadata`, `reflect-metadata`). + */ +function makeFakeAuthService() { + return { + getMe: vi.fn(), + changePassword: vi.fn(), + adminResetPassword: vi.fn(), + } as any; +} + +function makeFakeUserService() { + return { + findByIdForPlatformAdmin: vi.fn(), + } as any; +} + +describe('AuthController.me', () => { + it('reicht den Mandanten des Aufrufers und dessen Kennung GENAU durch (das Claim, nicht die Guard-Kennung)', async () => { + const authService = makeFakeAuthService(); + authService.getMe.mockResolvedValue({ id: 'u1' }); + const controller = new AuthController(authService, makeFakeUserService()); + + await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' }); + + expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('SUPER_ADMIN, dessen Claim t1 traegt: ebenfalls (\'t1\', \'u1\') — keine Kopfzeile und keine Guard-Kennung koennten das aendern, weil der Handler nur @CurrentUser() liest', async () => { + const authService = makeFakeAuthService(); + authService.getMe.mockResolvedValue({ id: 'u1' }); + const controller = new AuthController(authService, makeFakeUserService()); + + await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN }); + + expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('liefert null, wenn der Dienst null liefert — wirft NICHT (das ist der Beginn des leeren Rumpfs, (h3))', async () => { + const authService = makeFakeAuthService(); + authService.getMe.mockResolvedValue(null); + const controller = new AuthController(authService, makeFakeUserService()); + + const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' }); + + expect(result).toBeNull(); + }); +}); + +describe('AuthController.changePassword', () => { + it('reicht Mandant, Kennung, beide Kennwoerter und die Antwort durch, liefert die Erfolgsmeldung', async () => { + const authService = makeFakeAuthService(); + const controller = new AuthController(authService, makeFakeUserService()); + const res = {} as any; + + const result = await controller.changePassword( + { id: 'u1', tenantId: 't1', role: 'USER' }, + { currentPassword: 'old', newPassword: 'new' } as any, + res, + ); + + expect(authService.changePassword).toHaveBeenCalledWith('t1', 'u1', 'old', 'new', res); + expect(result).toEqual({ message: 'Password changed successfully.' }); + }); +}); + +describe('AuthController.adminResetPassword', () => { + it('Aufrufer ADMIN (tenantId t1), Ziel "target": Dienst mit (\'t1\', \'ADMIN\', \'target\', \'new-password\', true) aufgerufen; findByIdForPlatformAdmin NICHT aufgerufen; Erfolgsmeldung', async () => { + const authService = makeFakeAuthService(); + const userService = makeFakeUserService(); + const controller = new AuthController(authService, userService); + + const result = await controller.adminResetPassword( + 'target', + { newPassword: 'new-password' } as any, + { id: 'admin-1', tenantId: 't1', role: Role.ADMIN }, + ); + + expect(authService.adminResetPassword).toHaveBeenCalledWith( + 't1', + Role.ADMIN, + 'target', + 'new-password', + true, + ); + expect(userService.findByIdForPlatformAdmin).not.toHaveBeenCalled(); + expect(result).toEqual({ message: 'User password has been reset.' }); + }); + + it('Aufrufer ADMIN, mustChangePassword: false im Rumpf: false wird durchgereicht', async () => { + const authService = makeFakeAuthService(); + const controller = new AuthController(authService, makeFakeUserService()); + + await controller.adminResetPassword( + 'target', + { newPassword: 'new-password', mustChangePassword: false } as any, + { id: 'admin-1', tenantId: 't1', role: Role.ADMIN }, + ); + + expect(authService.adminResetPassword).toHaveBeenCalledWith( + 't1', + Role.ADMIN, + 'target', + 'new-password', + false, + ); + }); + + it('Aufrufer SUPER_ADMIN (tenantId t1), Fan-out liefert { id: "target", tenantId: "t9", role: "USER" }: Dienst mit (\'t9\', \'SUPER_ADMIN\', \'target\', ...) — der Mandant des ZIELS, nicht der des Aufrufers; findByIdForPlatformAdmin genau einmal mit "target"', async () => { + const authService = makeFakeAuthService(); + const userService = makeFakeUserService(); + userService.findByIdForPlatformAdmin.mockResolvedValue({ + id: 'target', + tenantId: 't9', + role: 'USER', + }); + const controller = new AuthController(authService, userService); + + await controller.adminResetPassword( + 'target', + { newPassword: 'new-password' } as any, + { id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN }, + ); + + expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1); + expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('target'); + expect(authService.adminResetPassword).toHaveBeenCalledWith( + 't9', + Role.SUPER_ADMIN, + 'target', + 'new-password', + true, + ); + }); + + it('Aufrufer SUPER_ADMIN, Fan-out liefert null: BadRequestException mit Meldung "User not found", Dienst NICHT aufgerufen', async () => { + const authService = makeFakeAuthService(); + const userService = makeFakeUserService(); + userService.findByIdForPlatformAdmin.mockResolvedValue(null); + const controller = new AuthController(authService, userService); + + await expect( + controller.adminResetPassword( + 'unknown', + { newPassword: 'new-password' } as any, + { id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN }, + ), + ).rejects.toThrow(new BadRequestException('User not found')); + expect(authService.adminResetPassword).not.toHaveBeenCalled(); + }); +}); + +describe('AuthController — Rollen-Metadaten (T-FH9)', () => { + it('adminResetPassword traegt genau [Role.ADMIN, Role.SUPER_ADMIN]', () => { + const roles = Reflect.getMetadata(ROLES_KEY, AuthController.prototype.adminResetPassword); + expect(roles).toEqual([Role.ADMIN, Role.SUPER_ADMIN]); + }); + + it.each(['me', 'changePassword', 'logout', 'login', 'requestReset', 'resetPassword'] as const)( + 'Handler %s traegt KEINE Rollenmetadaten', + (handlerName) => { + const handlerRoles = Reflect.getMetadata( + ROLES_KEY, + (AuthController.prototype as any)[handlerName], + ); + expect(handlerRoles).toBeUndefined(); + }, + ); + + it('die Klasse selbst traegt KEINE Rollenmetadaten (die Grenze aus Befund B liegt je Handler, nicht klassenweit)', () => { + const classRoles = Reflect.getMetadata(ROLES_KEY, AuthController); + expect(classRoles).toBeUndefined(); + }); +}); + +describe('AuthController — Public-Metadaten (die Grenze aus Befund B als Metadaten-Test)', () => { + it.each(['login', 'requestReset', 'resetPassword'] as const)( + 'Handler %s (Anmeldeweg) ist @Public()', + (handlerName) => { + const isPublic = Reflect.getMetadata( + IS_PUBLIC_KEY, + (AuthController.prototype as any)[handlerName], + ); + expect(isPublic).toBe(true); + }, + ); + + it.each(['me', 'changePassword', 'adminResetPassword', 'logout'] as const)( + 'Handler %s (Nach-Anmeldung) ist NICHT @Public() — waere er es, liefe die Bindung an das Claim ins Leere', + (handlerName) => { + const isPublic = Reflect.getMetadata( + IS_PUBLIC_KEY, + (AuthController.prototype as any)[handlerName], + ); + expect(isPublic).toBeUndefined(); + }, + ); +}); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 92e65e9..b1e18e3 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -140,12 +140,12 @@ autoritative Quelle. | user | 8 | 14 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) | | module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) | | dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | -| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand | +| 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** | **83** | **162** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0) und unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern — nur die Gebunden-Spalte änderte sich). 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`), jetzt 162 nach 260911-e2s (zusätzlich 3 in `tenant`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **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 | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare) @@ -214,6 +214,18 @@ bestehende Klasse verschiebt sich — die beiden `tenant`-Paare `keine-mandantengebundene-tabelle`/`ungebunden`, nur ihre Begründung wird fortgeschrieben (siehe Fundstellentabelle unten). +**Stand 260911-fh9 (Aufgabe 3): unveraendert, ausdruecklich festgehalten +statt uebersprungen.** Weiterhin 64 Paare, keine Klasse verschiebt sich. Das +Paar `apps/api/src/auth/auth.service.ts`/`user` war bereits vor diesem +Durchlauf korrekt klassifiziert (`muss-mandantengebunden`) — Aufgabe 2 +(260911-fh9) aendert nur seine `Stand`-Spalte (`gemischt` auf `gebunden`), +nicht seine Klasse. Das Paar `apps/api/src/auth/auth.service.ts`/ +`passwordResetToken` bleibt `muss-mandantengebunden`/`gebunden`, +unveraendert. Eine unveraenderte Tabelle ohne diesen Vermerk waere von +einer vergessenen Nachziehung nicht zu unterscheiden — deshalb steht die +Abwesenheit einer Aenderung hier ausdruecklich, statt stillschweigend +uebersprungen zu werden. + | Klasse | Anzahl Paare | |---|---| | muss-mandantengebunden | 32 | @@ -371,6 +383,20 @@ Bereichs `groups` (`forTenant()`/`withTenantTransaction()`, siehe Fundstellentabelle unten, `groups.service.ts`/`group`, Stand `gebunden`) — nichts an dieser Übergabe musste in 260911-e2s umgestellt werden. +**Stand 260911-fh9 — auch der Bereich `auth` fügt diesem Abschnitt keinen +sechsten Fall hinzu, gemessen statt angenommen (Befund J).** Anweisung: +`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/auth --include=*.ts` +und `grep -rn '\$transaction(' apps/api/src/auth --include=*.ts` liefern je +null Treffer außerhalb von Testdateien — kein Hintergrunddienst, keine +Transaktion in diesem Bereich. `grep -rn "include:\|_count\|select:" +apps/api/src/auth --include=*.ts | grep -v spec` findet genau EINEN +Treffer, das `select` in `getMe` — ausschließlich skalare Felder von +`User`, keine Relation (WINDOWS #27 hier ohne Ausprägung). Die einzige +Stelle, an der dieser Bereich ohne Mandantenkontext liest, ist der +Anmeldeweg (`validateUser`, `requestPasswordReset`, `resetPassword`) — +geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform +"übergreifend lesen, dann je Mandant binden". + ## Bestandsaufnahme Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit @@ -393,7 +419,7 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | Datei | Modell | Klasse | Stand | Begründung | |---|---|---|---|---| | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | -| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | +| apps/api/src/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/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. | @@ -494,3 +520,16 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). - Die Reihenfolge und Zuschnitt der Etappe-2-Pläne — dafür ist die Klassen-Verteilung oben der Arbeitsvorrat, siehe `` im Plan `260909-eor-PLAN.md`. +- **Wie der Anmeldeweg unter je Mandant eindeutigen Anmeldenamen (Etappe-3- + Entscheidung (1)) den Mandanten VOR der Benutzersuche erfaehrt (260911-fh9).** + `auth_lookup_user_by_username(p_username)` stuetzt sich heute auf + `username @unique` plattformweit und braucht kuenftig + `(p_tenant_id, p_username)` — die drei SECURITY-DEFINER-Funktionen aus + Etappe 1 werden dabei ENGER (zwei Gleichheitsbedingungen statt einer), + nicht weiter; `local.strategy.ts` braucht dann eine Mandantenangabe VOR + der Suche. Die Bindung der drei Nach-Anmeldungs-Methoden dieses Bereichs + (`getMe`, `changePassword`, `adminResetPassword`) ist davon NEUTRAL — sie + haengt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht + an `username`/`email`. Siehe + `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth", + (h4)(a).