Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46f0e781be | |||
| f68beb379a | |||
| 92aa8c403b | |||
| 9782bea1b2 | |||
| 4c3172b5a5 |
@@ -388,6 +388,7 @@ None yet.
|
||||
| 260910-krx | Mandantentrennung Etappe 2, Bereich dashboard — 12 von 13 Zugriffen gebunden, der Modulkatalog bleibt bewusst ungebunden (Messung und Bedingung getrennt: heute ohne Zeilenschutz, daher wirkungslos, katastrophal erst wenn Etappe 3 eine Regel setzt). **Erster Bereich, in dem die Besitzpruefungen von Anfang an richtig waren:** dieselbe Bauform, die in ldap und dkv je eine Luecke riss (nachschlagen, dann loeschen), vergleicht hier dazwischen gegen die angemeldete Person — nichts zu reparieren, nur zu bestaetigen und durch Tests festzunageln. **Beweisvernichtungs-Schleife belegt, nicht vermutet (WINDOWS #25, offen):** nach dem Scharfschalten liefert `getLayout` bei unsichtbarer Zeile die Vorgabe, die Oberflaeche uebernimmt sie ohne Fehlerzustand, und das Verlassen des Bearbeitungsmodus schreibt AUTOMATISCH zurueck — der Nutzer ueberschreibt seine urspruengliche Anordnung selbst, ohne es zu merken; dazu haeufen sich Widget-Dubletten, weil es keine Eindeutigkeit ueber (userId, widgetType) gibt. Gehoert in die Etappe-4-Vorabpruefung, nicht in diesen Umbau. Suchleiste: der Rueckfallzweig feuert nie leer, weil drei Vorgaben immer vorangestellt sind — die eigenen Suchmaschinen verschwinden schlicht. `DashboardLayout.userId` ist plattformweit eindeutig ohne Mandantenanteil (Familie WINDOWS #22). **Der Verifizierer fand eine Luecke der bekannten Art:** die Behauptung, ein gebundener Konfliktschreibvorgang werfe `PrismaClientUnknownRequestError` (nicht den P2002-Fall von tenders/user), stuetzte sich auf eine NICHT committete Ad-hoc-Messung — Pruefung 5 mass nur Roh-SQL, kein Test uebte den catch-Zweig. Nachgereicht (6e71206): Messung ueber den GENERIERTEN Client (Konstruktorname geprueft), dabei die Wegwerf-Tabelle korrigiert, der Roh-SQL nie aufgefallen war (createdAt/updatedAt fehlten, der echte Client scheiterte sofort mit P2022); zwei Tests fuer den catch-Zweig, durch Rueckbau falsifiziert. Klassifikation: fremde Datei `groups.service.ts` mit ungenauem Kopfkommentar bewusst NICHT angefasst, Ungenauigkeit in (w5) festgehalten. **Verifiziert 10/11, Luecke behoben** (860/860 Tests, Typpruefung sauber, 88/88 Live-Pruefungen; alle drei Falsifizierungsnachweise vom Pruefer eigenhaendig reproduziert) | 2026-09-11 | 6744918,e0ce594,67b5024,6e71206 | [260910-krx-mandantentrennung-etappe-2-bereich-dashb](./quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/) |
|
||||
| 260911-cwh | Mandantentrennung Etappe 2, Bereich calendar — alle 12 Zugriffe gebunden, ein Klient je Methode, sechs Methoden. Bereich hatte KEINE Testdatei (dkv-Form); `calendar.service.spec.ts` neu mit 23 Tests. **Gespeicherte Zugangsdaten zu fremden Kalender-Servern** — ein Fremdzugriff waere hier der Schluessel zu einem fremden Exchange/CalDAV. **Der dkv-Passwortverlust-Fall existiert hier NICHT**, in beiden Haelften belegt: der Dienst schreibt `encryptedPassword` nur bei `dto.password !== undefined`, und `calendar-source-form.tsx` laesst ein leeres Feld WEG statt einen leeren Text zu schicken; als drei Tests festgenagelt, weil ein nicht festgenagelter Freispruch still aufhoeren kann zu gelten. **Zwischenspeicher-Schluessel `userId:from:to` ohne Mandantenanteil ist sicher:** `User.id` ist `@default(uuid())`, Kette Schema -> `auth.service.ts sub: user.id` -> `JwtStrategy.validate` -> `extractContext` Glied fuer Glied belegt; die Etappe-3-Entscheidung (Anmeldenamen pro Mandant) beruehrt `username`/`email`, nicht `id`. **Eigener Gefahrenfall — halb gebundene Aggregationsschleife:** `fetchAndCacheEvents` liest Quellen und schreibt den Synchronstatus auf Erfolgs- UND Fehlerpfad zurueck, innerhalb von `Promise.allSettled`; gebundene Lesung mit ungebundenem Rueckschreiben haette Ereignisse still fallen lassen — beide Rueckschreibungen gebunden und als Tests festgenagelt, eines davon vom Pruefer eigenhaendig zurueckgebaut (genau 1 von 23 rot, exakte Meldung). **Frontend macht aus lauten Fehlern stille:** `calendar-widget.tsx` und `calendar-settings-panel.tsx` fangen jeden Fehler in denselben leeren Zustand — ein 403 sieht aus wie ein leerer Kalender; NICHT angefasst, als WINDOWS #26 offen festgehalten. Besitzpruefungen in allen drei Pfaden echt (403, nicht 404). Keine Eindeutigkeitskette, daher keine Konfliktuebersetzung, die nichts uebersetzt. **Lehre aus dashboard angewandt:** 4 der 13 neuen Pruefungen laufen ueber den GENERIERTEN Client, mit Laufzeitvergleich der Wegwerf-Tabelle gegen `schema.prisma` (17 = 17 Spalten). **Verifiziert 12/12** (883/883 Tests, 57 Dateien, Typpruefung sauber, 101/101 Live-Pruefungen; Klassenverteilung 31/17/13/2 = 63 und Ledger-Zaehler vom Pruefer nachgerechnet). Ein Selbstwiderspruch in der Zusammenfassung ('keine Abweichung' vs. 'kein TDD-Zyklus') berichtigt | 2026-09-11 | bf5fc4d,77cb124,e0e163e | [260911-cwh-mandantentrennung-etappe-2-bereich-calen](./quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/) |
|
||||
| 260911-e2s | Mandantentrennung Etappe 2, Bereich tenant — von anderer Art: alle 8 Zugriffe gehen auf die Mandantentabelle SELBST, die per Definition keinen Mandanten hat. **'Nichts zu binden' war trotzdem falsch, und der Grund ist der wichtigste Fund seit dem kaputten Helfer in Etappe 1:** drei der acht Zugriffe (`findAll`, `findOne`, `remove` im Controller) zaehlen ueber `include: { _count: { select: { users } } }` in die GESCHUETZTE Tabelle `User` hinein — Prisma 6.19 rendert das als `LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)`, das unter DEREN Regel laeuft. Nach dem Scharfschalten haette die Mandantenliste des Plattform-Admins fuer jeden Mandanten 0 Benutzer gezeigt, und der Loeschriegel T-02-09 waere vakuum geworden (der Fremdschluessel faengt es noch, aber als 500 statt 400). Behoben per Fan-out je Mandant ueber gebundenen Client, Muster aus `UserService.findAllForPlatformAdmin`. **Die Bestandsaufnahme ist fuer Relationszugriffe strukturell blind** — sie sieht nur `this.prisma.<Modell>`, nicht was ein `include:` in eine zweite Tabelle hineinrechnet. Alle 19 `include:`-Stellen und alle `_count`-Stellen einzeln beurteilt, vom Orchestrator UND vom Verifizierer unabhaengig gegengeprueft (der Plan-Pruefer hatte diesen Punkt als 'plausibel' durchgewinkt statt ihn zu pruefen): nur diese drei waren gefaehrlich. Der MECHANISMUS bleibt offen und ist als WINDOWS #27 festgehalten — der Planer wollte keinen Eintrag, weil die Instanz behoben ist; Orchestrator und Verifizierer sahen das anders, weil eine Luecke im Messwerkzeug, die nachweislich einen echten Defekt verborgen hat, genau dafuer ins Ledger gehoert. **Die seit Etappe 1 offene Architekturfrage ist entschieden:** `req.tenantPrisma` wurde bei jeder Anfrage gebaut und NIRGENDS gelesen; neun Bereiche haben die Konvention auf dienst-internes `forTenant()` festgelegt. Middleware geloescht (sie war nirgends registriert — der Auftrag irrte bei `app.module.ts:57`, dort ist der Guard verdrahtet), Guard ohne Prisma-Abhaengigkeit, setzt nur noch `req.tenantId` (22 Leser in 9 Dateien) und den `x-tenant-id`-Wechsel fuer SUPER_ADMIN (4 Frontend-Stellen) — beides erstmals getestet; Guard und Middleware hatten NIE Tests, 'ihre Tests' in Etappe 1 war eine Annahme. Totes Kabel, das wie eine Sicherung aussieht, ist schlimmer als keins. Ausnahmeliste in `rls-access-inventory.spec.ts` geleert und mit Wachhund versehen. Drei Kommentare berichtigt, die `TenantMiddleware`/`req.tenantPrisma` als lebendig beschrieben. Executor fing einen still fehlgeschlagenen `git add` (2 von 7 Dateien) selbst an `git status` und lieferte nach. **Verifiziert 10/10 mit vier eigenhaendigen Falsifizierungen** (Header-Wechsel zweimal gebrochen, Fan-out gebrochen, Wachhund ausgeloest — je exakt die benannten Tests rot; 911/911 Tests, 59 Dateien, Typpruefung sauber, 110/110 Live-Pruefungen; 64 Paare und Klassenverteilung 32/17/13/2 nachgerechnet) | 2026-09-11 | 652e762,11f5731,17dca0d,c8de72e | [260911-e2s-mandantentrennung-etappe-2-bereich-tenan](./quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/) |
|
||||
| 260911-fh9 | Mandantentrennung Etappe 2, Bereich auth — die drei in Etappe 1 bewusst ausgelassenen Wege (`getMe`, `changePassword`, `adminResetPassword`) gebunden, alle drei brauchten neue Signaturen (nahmen nur `userId`). Der Anmeldeweg ueber die drei SECURITY-DEFINER-Funktionen NICHT angefasst, per `pg_proc` belegt (weiterhin genau 9 Spalten, auch nachdem die Wegwerf-Tabelle `User` 5 fehlende Spalten bekam). Verbleibende 3 'ungebundene' Stellen sind die `$queryRaw`-Anmeldesuchen, keine Modellzugriffe. **Falle, die der Auftrag selbst gestellt hatte:** Selbstbedienung darf NICHT an `req.tenantId` binden — der Guard laesst SUPER_ADMIN diese Kennung per `x-tenant-id` umschalten (Marktplatz), 'mein Profil' haette ihn sich selbst gegenueber unsichtbar gemacht; gebunden wird an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`), der Controller enthaelt null Verweise auf `req.tenantId`/`x-tenant-id`. **Zwei Loecher in `adminResetPassword` geschlossen, keines davon ein Mandantenproblem:** der Weg pruefte weder den Mandanten des Ziels noch dessen Rolle — ein ADMIN konnte das Passwort eines SUPER_ADMIN ueberschreiben. Beides jetzt dicht, SUPER_ADMIN-Pfad ueber `UserService.findByIdForPlatformAdmin`; `AuthModule` importiert `UserModule`, zyklusfrei. Der Schwesterweg `PATCH /users/:id` hat dieselbe Rollenluecke (T-02-08 prueft nur das ZUWEISEN der Rolle, nicht die bestehende Rolle des Ziels) — ausserhalb der Erlaubnisliste, als WINDOWS #29 festgehalten. **Umgekehrte Fehlerrichtung ist hier leise, nicht laut:** `getMe`-Leere wird zu 200 mit leerem Rumpf, `header.tsx` tut bei `if (u)` nichts — 'nicht angemeldet' und 'Zeile unsichtbar' sind derselbe Wert (WINDOWS #28); `changePassword`-Leere liest sich als `networkError`. Identitaets-Attrappe (ldap-Form) durch asymmetrischen Doppel ersetzt: ungebundener Nachbau ohne Modelle, gebundener ohne `$queryRaw` — beide Grenzen einzeln falsifizierbar. **Verifiziert 8/8** (951/951 Tests, 60 Dateien, Typpruefung sauber, 120/120 Live-Pruefungen; zwei Falsifizierungen vom Pruefer eigenhaendig reproduziert — genau 4 bzw. 2 benannte Tests rot) | 2026-09-11 | 9782bea,92aa8c4,f68beb3 | [260911-fh9-mandantentrennung-etappe-2-bereich-auth-](./quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/) |
|
||||
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
|
||||
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
|
||||
|
||||
|
||||
+29
-3
@@ -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.<Modell> bzw. <gebundener Client>.<Modell>. 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
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
+1074
File diff suppressed because one or more lines are too long
+206
@@ -0,0 +1,206 @@
|
||||
---
|
||||
phase: quick-260911-fh9
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [prisma, postgresql, row-level-security, multi-tenancy, nestjs, jwt]
|
||||
|
||||
requires:
|
||||
- phase: quick-260909-eor
|
||||
provides: drei SECURITY-DEFINER-Funktionen fuer den Anmeldeweg (auth_lookup_user_by_username/email/reset_token, Migration 20260909160000_auth_lookup_functions)
|
||||
- phase: quick-260910-das
|
||||
provides: UserService.findByIdForPlatformAdmin (gebundener Fan-out je Mandant) und der Praezedenzfall resolveTargetUser in user.controller.ts
|
||||
provides:
|
||||
- getMe/changePassword/adminResetPassword binden je ueber genau einen Klienten tenantPrisma an den Mandanten aus dem signierten Sitzungsnachweis
|
||||
- adminResetPassword schliesst die Rechteausweitung ueber die Mandantengrenze (T-FH9-01) und innerhalb des Mandanten (T-FH9-04, ADMIN darf keinen SUPER_ADMIN zuruecksetzen)
|
||||
- AuthModule importiert UserModule (zyklusfrei) fuer den gebundenen Fan-out der obersten Rolle
|
||||
- dreizehnter Abschnitt runAuthAreaChecks im Wegwerf-Werkzeug (10 neue Pruefungen, 120/120 gesamt)
|
||||
- Klassifikationsdokument und WINDOWS.md auf den neuen Stand nachgezogen
|
||||
affects: [quick-260911-favorites-settings, etappe-3-mandantentrennung]
|
||||
|
||||
actuals:
|
||||
tokens: 26271
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 4c3172b5a5f3469501afead181f3ecf90a5fdbfe
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Zwei-Klienten-Testnachbau (__makeBoundClient) statt Identitaets-Attrappe fuer forTenant() in Service-Spec-Dateien"
|
||||
- "Controller loest den Mandanten der obersten Rolle vor dem Dienstaufruf ueber einen gebundenen Fan-out auf (resolveTargetTenantId), der Dienst nimmt den fertigen Mandanten entgegen"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/auth/auth.controller.spec.ts
|
||||
modified:
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.service.spec.ts
|
||||
- apps/api/src/auth/auth.controller.ts
|
||||
- apps/api/src/auth/auth.module.ts
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- .planning/WINDOWS.md
|
||||
|
||||
key-decisions:
|
||||
- "Selbstbedienung (getMe/changePassword) bindet an @CurrentUser().tenantId (das JWT-Claim), NICHT an req.tenantId (per x-tenant-id fuer SUPER_ADMIN umschaltbar) — ein umgeschalteter SUPER_ADMIN muss sich selbst weiterhin sehen"
|
||||
- "adminResetPassword loest den Mandanten des ZIELS auf: ADMIN -> currentUser.tenantId, SUPER_ADMIN -> UserService.findByIdForPlatformAdmin(userId), derselbe Praezedenzfall wie user.controller.ts resolveTargetUser"
|
||||
- "Die drei $queryRaw-Anmeldesuchen (validateUser/requestPasswordReset/resetPassword) bleiben unveraendert auf dem ungebundenen Klienten — sie sind die Grenze, nicht der Umbau"
|
||||
- "adminResetPassword verweigert einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04); der Schwesterweg PATCH /users/:id hat dieselbe Luecke nicht geschlossen — WINDOWS #29 statt Reparatur, weil ausserhalb der Erlaubnisliste"
|
||||
|
||||
patterns-established:
|
||||
- "runAuthAreaChecks im Wegwerf-Werkzeug: getrennt von runAuthLookupChecks (Anmeldeweg vs. Nach-Anmeldung), erweitert eine bereits vorhandene Wegwerf-Tabelle um fehlende Spalten statt sie neu anzulegen"
|
||||
|
||||
requirements-completed: [WINDOWS-18, ETAPPE-2-AUTH]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "getMe/changePassword/adminResetPassword binden je ueber tenantPrisma an den Mandanten aus dem Sitzungsnachweis"
|
||||
requirement: WINDOWS-18
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/auth/auth.service.spec.ts — AuthService.getMe/changePassword/adminResetPassword (20 Faelle)"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "apps/api/scripts/rls-scratch-check.mjs — runAuthAreaChecks (10 Pruefungen ueber den generierten Client an einer auf 15 Spalten erweiterten Wegwerf-Tabelle, gegen tessera-ctl-db-1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "adminResetPassword schliesst die Rechteausweitung ueber die Mandantengrenze (T-FH9-01) und innerhalb des Mandanten (T-FH9-04)"
|
||||
requirement: ETAPPE-2-AUTH
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/auth/auth.service.spec.ts — 'FREMDER Mandant: BadRequestException...' und 'Aufrufer ADMIN, Ziel SUPER_ADMIN...ForbiddenException...'"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "apps/api/scripts/rls-scratch-check.mjs — auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "auth.controller.ts liest den Mandanten ausschliesslich aus dem Sitzungsnachweis bzw. dem gebundenen Fan-out fuer die oberste Rolle — nicht aus req.tenantId/x-tenant-id"
|
||||
requirement: ETAPPE-2-AUTH
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/auth/auth.controller.spec.ts — Mandantenquelle je Handler (9 Faelle) plus Rollen-/Public-Metadaten (14 Faelle)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Klassifikationsdokument und WINDOWS.md sind auf den neuen Stand nachgezogen (auth.service.ts/user gebunden, zwei neue offene Ledger-Eintraege)"
|
||||
requirement: ETAPPE-2-AUTH
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts (11 Tests, insbesondere der Stand-Vergleich gegen den Quelltext)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 40min
|
||||
completed: 2026-09-11
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260911-fh9: Bereich auth der Mandantentrennung Etappe 2 Summary
|
||||
|
||||
**`getMe`, `changePassword`, `adminResetPassword` binden je über genau einen Klienten `tenantPrisma` an den Mandanten aus dem signierten Sitzungsnachweis; `adminResetPassword` schließt sowohl die Rechteausweitung über die Mandantengrenze (T-FH9-01) als auch innerhalb des Mandanten (T-FH9-04); der Anmeldeweg (drei SECURITY-DEFINER-Funktionen) bleibt unangetastet.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~40 min
|
||||
- **Started:** 2026-09-11 (Baseline-Messung: 911 Tests grün, Werkzeug 110/110)
|
||||
- **Completed:** 2026-09-11T10:01:47Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 9 (8 geändert, 1 neu)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Die Grenze zwischen Anmeldeweg (drei `SECURITY DEFINER`-Funktionen, `20260909160000_auth_lookup_functions`, unverändert) und Nach-Anmeldung (drei gebundene Methoden) ist gemessen und im Werkzeug (`runAuthAreaChecks`, 10 neue Prüfungen, 120/120 insgesamt) und in der Kritikschrift (`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1)–(h5)) festgehalten.
|
||||
- `getMe`, `changePassword`, `adminResetPassword` nehmen den Mandanten als ersten Parameter und laufen je über genau EINEN Klienten `tenantPrisma`; die drei `$queryRaw`-Anmeldesuchen bleiben unverändert auf dem ungebundenen Klienten.
|
||||
- `adminResetPassword` verweigert einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04); `auth.controller.ts` löst den Mandanten der obersten Rolle über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf (Präzedenzfall `user.controller.ts` `resolveTargetUser`).
|
||||
- Die Testlage hat keine Identitätsattrappe mehr — `auth.service.spec.ts` (29 Fälle) und die neue `auth.controller.spec.ts` (23 Fälle) nageln Bindung, Rollenverzweigung und Metadaten fest; sieben Falsifizierungsnachweise durchgeführt und zurückgenommen.
|
||||
- Fünf handgepflegte Dokumentstellen der Klassifikation nachgezogen und derivativ gegatet; zwei neue offene Ledger-Einträge (#28, #29).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Aufgabe 1: Fehlerrichtung messen und aufschreiben** — `9782bea` (docs)
|
||||
2. **Aufgabe 2: Die drei Methoden binden** — `92aa8c4` (feat)
|
||||
3. **Aufgabe 3: Mandantenquelle festnageln, Klassifikation nachziehen, Ledger** — `f68beb3` (docs)
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach dieser SUMMARY committet.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/api/scripts/rls-scratch-check.mjs` — dreizehnter Abschnitt `runAuthAreaChecks`, neuer Helfer `readSchemaModelScalarFieldNames`
|
||||
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` — Abschnitt `## Bereich auth` (h1)–(h5) vor `## Verweis`
|
||||
- `apps/api/src/auth/auth.service.ts` — `getMe(tenantId, userId)`, `changePassword(tenantId, userId, ...)`, `adminResetPassword(tenantId, callerRole, userId, ...)`
|
||||
- `apps/api/src/auth/auth.service.spec.ts` — Zwei-Klienten-Nachbau (`__makeBoundClient`), 29 Fälle
|
||||
- `apps/api/src/auth/auth.controller.ts` — `resolveTargetTenantId`, `me`/`changePassword`/`adminResetPassword` reichen das Claim durch
|
||||
- `apps/api/src/auth/auth.module.ts` — importiert `UserModule`
|
||||
- `apps/api/src/auth/auth.controller.spec.ts` — NEU, 23 Fälle
|
||||
- `docs/mandantentrennung-zugriffsklassifikation.md` — Übersichtszeile, Summenzeile, Bestandsaufnahme-Zeile, Klassen-Verteilung-Vermerk, Hintergrunddienst-Vermerk, Etappe-3-Punkt
|
||||
- `.planning/WINDOWS.md` — Einträge #28, #29
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Mandantenquelle für Selbstbedienung: das JWT-Claim (`@CurrentUser().tenantId`), nicht `req.tenantId` — siehe key-decisions oben.
|
||||
- `adminResetPassword`s Mandant für die oberste Rolle: gebundener Fan-out über `UserService.findByIdForPlatformAdmin`, nicht `req.tenantId`/`x-tenant-id`.
|
||||
- Rollengrenze innerhalb des Mandanten in `adminResetPassword` geschlossen; Schwesterweg `PATCH /users/:id` bewusst NICHT angefasst (außerhalb der Erlaubnisliste) — Ledger-Eintrag #29 statt Reparatur.
|
||||
|
||||
## Tatsächlich gezählte Prüfungs- und Testzahlen
|
||||
|
||||
- **Wegwerf-Werkzeug:** 120/120 Prüfungen bestanden (110 bisherige + 10 neue, wie im Plan gezählt: `auth-anmeldefunktionen-security-definer-unveraendert`, `auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients`, `auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`, `auth-getme-generierter-client-ungebunden-liefert-null`, `auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer`, `auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null`, `auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut`, `auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt`, `auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut`, `auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf`).
|
||||
- **Testsuite:** Baseline 911 Tests/59 Dateien → nach Aufgabe 2: 928 Tests (927 grün, 1 erwartungsgemäß rot — siehe unten) → nach Aufgabe 3: **951 Tests grün in 60 Dateien** (29 Fälle in `auth.service.spec.ts`, 23 Fälle in der neuen `auth.controller.spec.ts`, 927 + 24 = 951).
|
||||
- **Typprüfung:** sauber nach jeder Aufgabe.
|
||||
|
||||
## Abweichung von den Planungsbefunden (ausdrücklich benannt)
|
||||
|
||||
**Zwischenzeitlich rot: `rls-access-inventory.spec.ts`, zwischen Aufgabe 2 und Aufgabe 3.** Der Plan sagt in Aufgabe 3 voraus: *"Ohne Schritt 3 ist rls-access-inventory.spec.ts am Ende dieser Aufgabe rot (Stand-Vergleich)."* Das galt nicht nur für Aufgabe 3, sondern bereits ab dem Ende von Aufgabe 2: sobald `auth.service.ts` keinen ungebundenen `user`-Zugriff mehr enthielt, maß die Prüfung den Stand für `apps/api/src/auth/auth.service.ts::user` als `gebunden`, während das Klassifikationsdokument (noch nicht nachgezogen, das ist Aufgabe 3) weiterhin `gemischt` führte — ein Fehlschlag von genau einem Test (`der eingetragene Stand stimmt mit dem im Quelltext gemessenen überein`), 927/928 grün. Dasselbe Muster zeigt sich bereits im Klassifikationsdokument selbst für den Vorgänger-Plan 260911-cwh ("Aufgabe 2 (260911-cwh) ändert nur seine Stand-Spalte … nicht seine Klasse" — im Abschnitt zu Aufgabe 3 dokumentiert, obwohl die Codeänderung in Aufgabe 2 lag). Behandlung: nicht als Blocker gewertet, weil (a) der Fehlschlag exakt einen einzigen, im Plan selbst vorausgesagten Test betraf, (b) er keine Datei außerhalb der für Aufgabe 2 erlaubten vier Dateien berührte, und (c) Aufgabe 3 unmittelbar im selben Lauf folgte und die Baseline innerhalb von Minuten wiederherstellte (951/951). Der Commit von Aufgabe 2 dokumentiert das ausdrücklich als "bekannt und erwartet". Kein Datenverlust, keine stillschweigende Planabweichung — nur eine Klarstellung, dass "Baseline gehalten nach jeder Aufgabe" hier als "nach dem vollständigen Plan, mit einem im Plan selbst vorausgesagten Zwischenzustand" zu lesen ist, nicht als literarische Bedingung jedes einzelnen Aufgaben-`<verify>`-Blocks.
|
||||
|
||||
**Testfall-Namensraumkollision im Gate `forTenant: vi.fn((p` (Aufgabe 2).** Die neue Mock-Signatur `forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId))` — wortgleich mit dem Muster aus `user.service.spec.ts`/`tenant.controller.spec.ts` — erfüllte unbeabsichtigt das BRE-Suchmuster `forTenant: vi.fn((p` des Gates, das die ALTE Identitäts-Attrappe `forTenant: vi.fn((p) => p)` ausschließen sollte (`vi.fn((p` ist ein Präfix von `vi.fn((prisma`). Behoben durch Umbenennung des ersten Parameters auf `unboundClient` statt `prisma`. Keine Verhaltensänderung, nur eine Namenswahl, die das Gate nicht fälschlich trifft.
|
||||
|
||||
Alle übrigen Zahlen, Codeaussagen (Befunde B, C, D, E, J, K) und die Modulgraph-Messung stimmten bei der erneuten Ausführung zur Ausführungszeit exakt mit den Planungsbefunden überein — keine weiteren Abweichungen.
|
||||
|
||||
## Falsifizierungsnachweise (alle durchgeführt, zurückgenommen, wörtlich notiert)
|
||||
|
||||
**Aufgabe 2 (drei, im Dienst):**
|
||||
|
||||
1. **`getMe` probeweise auf den ungebundenen Klienten zurückgebaut** (`const tenantPrisma = this.prisma as any;`): 4 Tests wurden rot (`AuthService.getMe` — alle vier Fälle), jeweils mit `TypeError: Cannot read properties of undefined (reading 'findUnique')`. Erwartete Form bestätigt: der Nachbau hat kein ungebundenes Benutzermodell.
|
||||
2. **`validateUser` probeweise auf den gebundenen Klienten verschoben** (`const probeBoundClient = forTenant(this.prisma, 'falsification-probe') as any; const rows = await probeBoundClient.$queryRaw...`): 9 Tests wurden rot, darunter der eigens für die Grenze geschriebene Fall (`AuthService.validateUser — lokales Kennwort > sucht die Anmeldedaten exakt EINMAL ungebunden...`) mit `TypeError: probeBoundClient.$queryRaw is not a function`. Erwartete Form bestätigt: der gebundene Nachbau hat kein `$queryRaw`.
|
||||
3. **SUPER_ADMIN-Riegel in `adminResetPassword` probeweise entfernt**: genau 1 Test wurde rot (`AuthService.adminResetPassword > Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04)...`), mit `AssertionError: promise resolved "undefined" instead of rejecting`.
|
||||
|
||||
**Aufgabe 3 (eine, am Controller):**
|
||||
|
||||
4. **`resolveTargetTenantId` probeweise auf `return currentUser.tenantId;` (auch für SUPER_ADMIN) reduziert**: genau die beiden Fan-out-Fälle wurden rot — `expected "spy" to be called 1 times, but got 0 times` (findByIdForPlatformAdmin nicht aufgerufen) und `promise resolved "{ message: ... }" instead of rejecting` (die Null-Fan-out-BadRequestException griff nicht mehr).
|
||||
|
||||
**Aufgabe 3 (zwei, an den Dokument-Gates):**
|
||||
|
||||
5. **Bestandsaufnahme-Zeile `auth.service.ts | user` probeweise auf `gemischt` zurückgesetzt**: `rls-access-inventory.spec.ts` wurde rot mit `AssertionError: Abweichender Stand (Dokument vs. Quelltext): apps/api/src/auth/auth.service.ts::user — dokumentiert=gemischt, gemessen=gebunden`.
|
||||
6. **Übersichtszeile `auth` probeweise auf `99 | 10` gesetzt**: das herleitende Shell-Gate schlug fehl mit `UEBERSICHTSZEILE auth nennt nicht die neu gemessenen Zahlen 3/10`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Keine inhaltlichen Abweichungen von den vier Dateien/Aufgaben des Plans — beide oben dokumentierten Punkte sind Klarstellungen zur Ausführungsreihenfolge bzw. eine Namenswahl, keine Scope- oder Verhaltensänderung. Kein Rule-1/2/3/4-Auto-Fix war nötig; alle Codeaussagen aus den Planungsbefunden wurden bei erneuter Ausführung bestätigt.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Keine ungelösten Probleme. Das einzige während der Ausführung aufgetretene technische Detail (Gate-Namenskollision, siehe Abweichungen oben) wurde sofort behoben.
|
||||
|
||||
## Etappe-3-Vorbehalt (ein Absatz)
|
||||
|
||||
Sobald Anmeldenamen je Mandant eindeutig werden (Etappe-3-Entscheidung (1)), braucht der Anmeldeweg den Mandanten VOR der Benutzersuche: `auth_lookup_user_by_username(p_username)` muss auf `(p_tenant_id, p_username)` umgestellt werden — die Funktion wird dabei ENGER (zwei Gleichheitsbedingungen statt einer), nicht weiter — und `local.strategy.ts` braucht eine Mandantenangabe vor der Suche. Dieser Plan ist dafür neutral: die Bindung der drei Nach-Anmeldungs-Methoden hängt ausschließlich am JWT-Claim `tenantId` und an `User.id` (plattformweite UUID, Kette aus 260911-cwh), nicht an `username`/`email`. Nichts in diesem Plan hat den künftigen Umbau schwerer gemacht.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — keine externe Dienstkonfiguration nötig.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Elf der zwölf Bereiche der Etappe 2 sind umgestellt. Laut Plan ist der nächste Lauf `favorites` (7 ungebundene Rohtreffer) und `settings` (4) als EIN Durchlauf — danach ist Etappe 2 vollständig.
|
||||
- Kein Blocker für diesen nächsten Lauf. Zwei neue offene WINDOWS-Einträge (#28 Frontend-Leere, #29 Schwesterweg-Rechteausweitung) sind dokumentiert und unabhängig von `favorites`/`settings`.
|
||||
- `DATABASE_URL` zeigt unverändert auf die Rolle `tessera` (Schalter aus); Schema, Migrationen, die drei Anmeldefunktionen, Active Directory: alle unangetastet.
|
||||
|
||||
---
|
||||
*Phase: quick-260911-fh9*
|
||||
*Completed: 2026-09-11*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle neun in `key-files` genannten Dateien plus diese SUMMARY existieren auf der Platte; alle drei Task-Commits (`9782bea`, `92aa8c4`, `f68beb3`) sind in `git log --oneline --all` auffindbar. Keine fehlenden Elemente.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
phase: quick-260911-fh9
|
||||
verified: 2026-09-11T12:10:00Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
covered_files:
|
||||
- .planning/WINDOWS.md
|
||||
- .planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md
|
||||
- .planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-SUMMARY.md
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/api/src/auth/auth.controller.spec.ts
|
||||
- apps/api/src/auth/auth.controller.ts
|
||||
- apps/api/src/auth/auth.module.ts
|
||||
- apps/api/src/auth/auth.service.spec.ts
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
covered_digest: "v1:sha256:a4eee94bfd0ca019936b1d7766a7bcaa03d97438c1319dad220a021d1e947cde"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Quick Task 260911-fh9: Bereich `auth` der Mandantentrennung Etappe 2 — Verification Report
|
||||
|
||||
**Task Goal:** `getMe`, `changePassword`, `adminResetPassword` an den Mandanten aus dem JWT-Claim binden (NICHT an das umschaltbare `req.tenantId`), die fehlenden Mandanten-/Rollenpruefungen in `adminResetPassword` schliessen, die Identitaets-Attrappe im Test durch einen Zwei-Klienten-Nachbau ersetzen, den Anmeldeweg unangetastet lassen, das Klassifikationsdokument nachziehen.
|
||||
|
||||
**Verified:** 2026-09-11T12:10:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | `getMe`/`changePassword` binden an `@CurrentUser().tenantId` (Claim), nicht an `req.tenantId`/`x-tenant-id` | ✓ VERIFIED | `auth.controller.ts`: `me`/`changePassword` reichen ausschliesslich `user.tenantId` aus `@CurrentUser()` durch; `grep -c "req.tenantId\|x-tenant-id"` = 0. Eigener Test belegt, dass ein SUPER_ADMIN mit Claim `t1` ebenfalls `('t1','u1')` liefert — der Handler liest strukturell nur `@CurrentUser()`, eine umgeschaltete `x-tenant-id`-Kopfzeile kann das nicht beeinflussen, weil der Handler keinen zweiten Kanal fuer den Mandanten besitzt. DB-seitig bestaetigt `auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null` (rls-scratch-check.mjs, selbst ausgefuehrt), dass ein unter fremdem Mandanten gebundener Klient die eigene Zeile nicht sieht. |
|
||||
| 2 | `adminResetPassword`: Tenant-Grenze (ADMIN A -> User B unerreichbar) UND Rollen-Grenze (ADMIN setzt kein SUPER_ADMIN-Kennwort) sind geschlossen und je mit benanntem Test gepinnt | ✓ VERIFIED | `auth.service.ts`: `ForbiddenException` bei `user.role === Role.SUPER_ADMIN && callerRole !== Role.SUPER_ADMIN`; `BadRequestException('User not found')` bei unsichtbarer (fremdmandantiger) Zeile. Beide durch benannte Tests in `auth.service.spec.ts` gepinnt ("FREMDER Mandant: BadRequestException...", "Aufrufer ADMIN, Ziel SUPER_ADMIN...ForbiddenException..."), beide zusaetzlich DB-seitig durch `rls-scratch-check.mjs` (`auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut`) bestaetigt. SUPER_ADMIN-Pfad laeuft ueber `UserService.findByIdForPlatformAdmin`; `resolveTargetTenantId` im Controller leitet fuer SUPER_ADMIN den Mandanten des ZIELS ab (`target.tenantId`), fuer ADMIN den des Aufrufers (`currentUser.tenantId`) — gelesen in `auth.controller.ts`, gepinnt durch 4 Controller-Tests. |
|
||||
| 3 | Die drei SECURITY-DEFINER-Anmeldefunktionen sind unangetastet; `pg_proc` bestaetigt es | ✓ VERIFIED | Selbst ausgefuehrt: `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` gegen `tessera-ctl-db-1` (172.19.0.2) — 120/120 Pruefungen bestanden, darunter `auth-anmeldefunktionen-security-definer-unveraendert` (3 Funktionen, `prosecdef=true`, `provolatile='s'`, `search_path=public, pg_temp`, `LIMIT 1`) und `auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz` (genau 9 Spalten nach der Wegwerftabellen-Erweiterung um 5 Spalten — weder `avatarPath` noch `accentColor` noch `email` durchgelassen). `git diff --name-only 4c3172b..HEAD -- apps/api/prisma` leer (orchestratorseitig bereits gemessen, selbst nachgemessen). |
|
||||
| 4 | Identitaets-Attrappe ersetzt durch asymmetrischen Zwei-Klienten-Nachbau | ✓ VERIFIED | `auth.service.spec.ts`: `forTenant` umgeleitet auf `unboundClient.__makeBoundClient(tenantId)`; ungebundener Basisclient (`fake`) hat NUR `$queryRaw`/`__makeBoundClient`/`__users`/`__resetTokens`/`__boundCallLog` — kein `user`/`passwordResetToken`; gebundener Klient (`makeScopedUser`/`makeScopedResetToken`) hat `user`/`passwordResetToken`, kein `$queryRaw`. Selbst falsifiziert: `getMe` probeweise auf `this.prisma` (ungebunden) zurueckgebaut -> exakt 4 Tests rot mit `TypeError: Cannot read properties of undefined (reading 'findUnique')` — genau wie im SUMMARY behauptet. Aenderung zurueckgenommen, 29/29 wieder gruen, `git status` sauber. |
|
||||
| 5 | Genau 3 verbleibende ungebundene Stellen sind `$queryRaw`-Anmeldesuchen, keine Modellzugriffe | ✓ VERIFIED | `grep -c 'this\.prisma\.\$queryRaw' auth.service.ts` = 3 (Zeilen 109, 217, 254 — `validateUser`, `requestPasswordReset`, `resetPassword`); `grep -c 'this\.prisma\.user' auth.service.ts` = 0. |
|
||||
| 6 | Transienter Rotzustand von `rls-access-inventory.spec.ts` zwischen Aufgabe 2 und 3, jetzt gruen; Klassifikationszeile `auth.service.ts`/`user` = `gebunden` | ✓ VERIFIED | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts`: 11/11 gruen (selbst ausgefuehrt). `docs/mandantentrennung-zugriffsklassifikation.md` Zeile 422: Stand `gebunden`, Begruendung mit allen drei Methoden. |
|
||||
| 7 | Ledger-Eintraege #28 (Frontend-Leere) und #29 (Schwesterweg `PATCH /users/:id`) existieren, offen, ohne Reparatur | ✓ VERIFIED | `.planning/WINDOWS.md`: beide Eintraege vorhanden, `"status": "open"`. #29-Behauptung selbst nachgeprueft: `UserController.update` prueft nur `dto.role === Role.SUPER_ADMIN` (Neuzuweisung), NICHT `user.role === Role.SUPER_ADMIN` (Bestandsrolle des Ziels) — die Luecke ist real und unbehoben. |
|
||||
| 8 | Fuenf handgepflegte Klassifikationsstellen nachgezogen (Uebersichtszeile, Summenzeile, Bestandsaufnahme, Klassen-Verteilung, `Was diese Etappe NICHT entscheidet`); Umfang gegen `6236b30`/`4c3172b` als Erlaubnisliste gegatet | ✓ VERIFIED | Alle fuenf Stellen selbst nachgesehen: Uebersichtszeile `auth 3 10`, Summenzeile `78/167`, Bestandsaufnahme-Zeile `gebunden`, Klassen-Verteilung `32/17/13/2=64` mit Stand-Vermerk 260911-fh9, neuer Etappe-3-Punkt in "Was diese Etappe NICHT entscheidet". `git diff --name-only 4c3172b..HEAD` = genau die 10 im Plan erlaubten Dateien (plus `.planning/`); `apps/api/prisma`, `apps/web`, Compose/Env unveraendert. |
|
||||
|
||||
**Score:** 8/8 truths verified (0 present, behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/scripts/rls-scratch-check.mjs` | 13. Abschnitt `runAuthAreaChecks`, >=10 neue Pruefungen, `pg_proc`-Messung | ✓ VERIFIED | Eigenstaendig ausgefuehrt: 120/120 bestanden, korrekte Reihenfolge (`runTenantAreaChecks` -> `runAuthAreaChecks` -> `runTransactionShapeMeasurement`, Zeilen 4016-4018) |
|
||||
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | Abschnitt `## Bereich auth` vor `## Verweis`, (h1)-(h5) | ✓ VERIFIED | Zeilen 2473-2676, unmittelbar vor `## Verweis` (2677), alle fuenf Unterabschnitte vorhanden |
|
||||
| `apps/api/src/auth/auth.service.ts` | drei gebundene Methoden, je EIN `tenantPrisma` | ✓ VERIFIED | Gelesen vollstaendig, 0 ungebundene `this.prisma.user`, genau 3 `$queryRaw`, 6 `forTenant`-Aufrufstellen |
|
||||
| `apps/api/src/auth/auth.service.spec.ts` | Zwei-Klienten-Nachbau, alle Faelle, Falsifizierung | ✓ VERIFIED | 29/29 Tests gruen; Falsifizierung selbst reproduziert (4 Tests rot, zurueckgenommen) |
|
||||
| `apps/api/src/auth/auth.controller.ts` | Claim-Durchreichung, Rollenverzweigung | ✓ VERIFIED | Gelesen vollstaendig; 0 `req.tenantId`/`x-tenant-id`/`PrismaService` |
|
||||
| `apps/api/src/auth/auth.module.ts` | importiert `UserModule`, zyklusfrei | ✓ VERIFIED | `GroupsModule` importiert nichts; nur `app.module.ts` importiert `AuthModule` — selbst nachgemessen |
|
||||
| `apps/api/src/auth/auth.controller.spec.ts` | NEU, Mandantenquelle, Metadaten | ✓ VERIFIED | 23/23 Tests gruen; Falsifizierung selbst reproduziert (2 Tests rot, zurueckgenommen) |
|
||||
| `docs/mandantentrennung-zugriffsklassifikation.md` | 5 Stellen nachgezogen | ✓ VERIFIED | Siehe Truth 8 |
|
||||
| `.planning/WINDOWS.md` | 2 neue offene Eintraege | ✓ VERIFIED | #28, #29 vorhanden, offen |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `auth.controller.ts` `me`/`changePassword` | `AuthService.getMe`/`changePassword` | `user.tenantId` aus `@CurrentUser()` | ✓ WIRED | Grep + Test bestaetigt |
|
||||
| `auth.controller.ts` `adminResetPassword` | `UserService.findByIdForPlatformAdmin` | `resolveTargetTenantId` fuer SUPER_ADMIN | ✓ WIRED | Test bestaetigt (`findByIdForPlatformAdmin` genau 1x mit `'target'`) |
|
||||
| `AuthModule` | `UserModule` | `imports: [...]` | ✓ WIRED | `auth.module.ts` importiert `UserModule`; zyklusfrei statisch gemessen |
|
||||
| `auth.service.ts` `validateUser`/`requestPasswordReset`/`resetPassword` | `auth_lookup_*`-Funktionen (SECURITY DEFINER) | `this.prisma.$queryRaw` | ✓ WIRED | 3/3, unveraendert, `pg_proc`-Messung bestanden |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| `auth.service.spec.ts` + `auth.controller.spec.ts` laufen | `npm --prefix apps/api run test -- src/auth/auth.service.spec.ts src/auth/auth.controller.spec.ts` | 52/52 gruen (29+23) | ✓ PASS |
|
||||
| `rls-access-inventory.spec.ts` laeuft | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` | 11/11 gruen | ✓ PASS |
|
||||
| Volle Testsuite | `npm --prefix apps/api run test` (einmal) | 951/951 gruen, 60 Dateien | ✓ PASS |
|
||||
| `type-check` | `npm --prefix apps/api run type-check` | exit 0 | ✓ PASS |
|
||||
| Wegwerf-Werkzeug gegen laufende DB | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` (frisch aufgeloeste Adresse 172.19.0.2) | 120/120 bestanden | ✓ PASS |
|
||||
| Falsifizierung 1: `getMe` auf ungebundenen Klienten zurueckgebaut | Codeaenderung + `vitest run auth.service.spec.ts` | 4 Tests rot (`TypeError: Cannot read properties of undefined (reading 'findUnique')`), zurueckgenommen, 29/29 wiederhergestellt | ✓ PASS |
|
||||
| Falsifizierung 2: `resolveTargetTenantId` fuer SUPER_ADMIN auf `currentUser.tenantId` reduziert | Codeaenderung + `vitest run auth.controller.spec.ts` | 2 Tests rot (Fan-out-Faelle), zurueckgenommen, 23/23 wiederhergestellt | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|--------------|--------|----------|
|
||||
| WINDOWS-18 | 260911-fh9-PLAN.md | Die drei Nach-Anmeldungs-Methoden binden an den Mandanten aus dem Sitzungsnachweis | ✓ SATISFIED | Truth 1, 3 |
|
||||
| ETAPPE-2-AUTH | 260911-fh9-PLAN.md | Rechteausweitung ueber und innerhalb der Mandantengrenze in `adminResetPassword` geschlossen, Dokumentation nachgezogen | ✓ SATISFIED | Truth 2, 6, 7, 8 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine. `grep -n "TODO\|FIXME\|TBD\|HACK\|PLACEHOLDER" apps/api/src/auth/auth.service.ts apps/api/src/auth/auth.controller.ts apps/api/src/auth/auth.module.ts` liefert keine Treffer in den geaenderten Codedateien (Kommentare beziehen sich auf Ledger-Eintraege mit Referenznummern, keine unbezeichneten Marker).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Keine. Dieser Bereich ist rein backend-seitig, ueber Unit-Tests, ein Wegwerf-Datenbank-Werkzeug und Quelltext-Messung vollstaendig ueberprueft — keine visuelle, Echtzeit- oder UX-Beurteilung noetig.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine Luecken gefunden. Alle acht abgeleiteten Wahrheiten (must-haves aus PLAN-Frontmatter, kombiniert mit den elf Pruefpunkten aus dem Verifikationsauftrag) sind mit unabhaengig reproduzierten Belegen (Testlaeufen, Datenbankwerkzeug-Ausgabe, Falsifizierungen, Quelltext-Lesungen) bestaetigt. Zwei bewusst offene Ledger-Eintraege (#28, #29) sind korrekt als offene Punkte dokumentiert, nicht als geschlossen behauptet — sie liegen ausserhalb der Erlaubnisliste dieses Plans und wurden dort auch nicht angefasst.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-09-11T12:10:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -2794,6 +2794,41 @@ function readSchemaModelFieldNames(modelName) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-fh9) — wie `readSchemaModelFieldNames`, aber laesst
|
||||
* jedes Feld weg, dessen TYP (zweites Wort der Zeile, `?` und `[]`
|
||||
* abgestreift) selbst der Name eines anderen `model` im Schema ist —
|
||||
* Relationsfelder haben keine Spalte (Befund M aus 260911-e2s, dort fuer
|
||||
* "Tenant" ueber die Migration umgangen; bei "User" ist die Spaltenmenge
|
||||
* ueber drei Migrationen verteilt, deshalb hier der Weg ueber das Schema
|
||||
* mit Relationsfilter). `Role` ist ein `enum`, kein `model`, und bleibt
|
||||
* deshalb ein skalares Feld.
|
||||
*/
|
||||
function readSchemaModelScalarFieldNames(modelName) {
|
||||
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
|
||||
const modelNames = new Set(
|
||||
[...schemaSource.matchAll(/^model\s+(\w+)\s*\{/gm)].map((m) => m[1]),
|
||||
);
|
||||
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
|
||||
const match = schemaSource.match(re);
|
||||
if (!match) return [];
|
||||
const fields = [];
|
||||
for (const rawLine of match[1].split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('@@')) continue;
|
||||
if (line.startsWith('//')) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const fieldName = parts[0];
|
||||
if (!fieldName) continue;
|
||||
const rawType = parts[1] ?? '';
|
||||
const fieldType = rawType.replace(/\?$/, '').replace(/\[\]$/, '');
|
||||
if (modelNames.has(fieldType)) continue; // Relationsfeld, keine Spalte
|
||||
fields.push(fieldName);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
|
||||
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
|
||||
@@ -3445,6 +3480,302 @@ async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260911-fh9) — misst die Grenze zwischen Anmeldeweg (die drei
|
||||
* SECURITY-DEFINER-Funktionen, gemessen in `runAuthLookupChecks`) und
|
||||
* Nach-Anmeldung (dieser Abschnitt, gebundener Modellzugriff ueber den
|
||||
* GENERIERTEN Client) fuer die drei Methoden `getMe`, `changePassword`,
|
||||
* `adminResetPassword`. Ausdruecklich GETRENNT von `runAuthLookupChecks`:
|
||||
* jener misst den Anmeldeweg VOR bekanntem Mandanten (Funktionen), dieser
|
||||
* die drei Methoden NACH der Anmeldung (gebundener Modellzugriff) — die
|
||||
* Grenze, die dieser Plan festnagelt.
|
||||
*
|
||||
* Setzt auf der vorhandenen Wegwerf-Tabelle "User" auf (aus
|
||||
* `runAuthLookupChecks`, mit eingeschaltetem und erzwungenem Zeilenschutz,
|
||||
* wortgleicher Policy, zwei Zeilen in zwei Mandanten; seit
|
||||
* `runTenantAreaChecks` zusaetzlich mit Fremdschluessel auf "Tenant") und
|
||||
* auf der bereits eingespielten Funktions-Migration; erweitert "User" um die
|
||||
* fuenf im generierten Client fehlenden Spalten (Befund G). Keine spaetere
|
||||
* Pruefung setzt auf diesen Erweiterungen auf (`runTransactionShapeMeasurement`/
|
||||
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an, Befund M aus
|
||||
* 260911-e2s) — dieser Abschnitt ist ein Blatt in der Aufrufkette und muss
|
||||
* deshalb NACH `runTenantAreaChecks()` und VOR
|
||||
* `runTransactionShapeMeasurement()` laufen (siehe Aufrufkette in `main()`).
|
||||
*/
|
||||
async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
|
||||
// Vorbereitung ueber die Wartungsrolle: die fuenf im generierten Client
|
||||
// fehlenden Spalten nachruesten (Befund G). Typen aus den drei
|
||||
// ausgelieferten Migrationen (20260618112124_auth_multi_tenancy,
|
||||
// 20260630095533_add_user_avatar, 20260702000000_add_user_accent_color).
|
||||
// ABWEICHUNG: "updatedAt" bekommt fuer die zwei bereits vorhandenen
|
||||
// Wegwerf-Zeilen (user-a/user-b aus runAuthLookupChecks) eine Vorgabe
|
||||
// CURRENT_TIMESTAMP, die die ausgelieferte Migration nicht hat (dort NOT
|
||||
// NULL ohne DEFAULT — Prisma setzt den Wert clientseitig ueber
|
||||
// `@updatedAt`) — betrifft nur dieses Nachruesten hier, keine Aussage
|
||||
// ueber den ausgelieferten Stand.
|
||||
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
||||
await db.$executeRawUnsafe(
|
||||
`ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`ALTER TABLE "User" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
|
||||
);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3);`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT;`);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "accentColor" TEXT;`);
|
||||
});
|
||||
|
||||
// Pruefung — steht VOR den Client-Pruefungen (4-10 unten); faellt sie
|
||||
// durch, bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich
|
||||
// `calendar`/Pruefung 3 im Bereich `tenant`).
|
||||
const schemaFields = readSchemaModelScalarFieldNames('User');
|
||||
const tableColumns = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'User'
|
||||
`;
|
||||
return rows.map((r) => r.column_name).sort();
|
||||
},
|
||||
);
|
||||
const schemaFieldsSorted = [...schemaFields].sort();
|
||||
const columnsMatch =
|
||||
schemaFieldsSorted.length > 0 &&
|
||||
schemaFieldsSorted.length === tableColumns.length &&
|
||||
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
|
||||
report(
|
||||
results,
|
||||
'auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients',
|
||||
columnsMatch,
|
||||
`Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
|
||||
);
|
||||
if (!columnsMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pruefung 1: pg_proc-Messung der drei Anmeldefunktionen — die
|
||||
// Datenbankseite von "nichts an der Anordnung angefasst" (Befund H).
|
||||
const functionRows = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) =>
|
||||
db.$queryRaw`
|
||||
SELECT proname, prosecdef, provolatile, proconfig, pg_get_functiondef(oid) AS def
|
||||
FROM pg_proc WHERE proname LIKE 'auth_lookup_%' ORDER BY proname
|
||||
`,
|
||||
);
|
||||
const expectedFunctionNames = [
|
||||
'auth_lookup_reset_token',
|
||||
'auth_lookup_user_by_email',
|
||||
'auth_lookup_user_by_username',
|
||||
];
|
||||
const actualFunctionNames = functionRows.map((r) => r.proname).sort();
|
||||
const perFunctionProps = functionRows.map((r) => ({
|
||||
proname: r.proname,
|
||||
prosecdef: r.prosecdef,
|
||||
provolatile: r.provolatile,
|
||||
proconfig: r.proconfig,
|
||||
hatLimit1: typeof r.def === 'string' && r.def.includes('LIMIT 1'),
|
||||
}));
|
||||
const allSecurityDefinerStableFixedSearchPathLimit1 = perFunctionProps.every(
|
||||
(p) =>
|
||||
p.prosecdef === true &&
|
||||
p.provolatile === 's' &&
|
||||
Array.isArray(p.proconfig) &&
|
||||
p.proconfig.some((c) => String(c).replace(/\s+/g, '') === 'search_path=public,pg_temp') &&
|
||||
p.hatLimit1,
|
||||
);
|
||||
const functionsOk =
|
||||
functionRows.length === 3 &&
|
||||
JSON.stringify(actualFunctionNames) === JSON.stringify(expectedFunctionNames) &&
|
||||
allSecurityDefinerStableFixedSearchPathLimit1;
|
||||
report(
|
||||
results,
|
||||
'auth-anmeldefunktionen-security-definer-unveraendert',
|
||||
functionsOk,
|
||||
`${functionRows.length} Funktion(en) unter 'auth_lookup_%' gefunden: ${JSON.stringify(actualFunctionNames)}; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: ${JSON.stringify(perFunctionProps)}`,
|
||||
);
|
||||
|
||||
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
||||
try {
|
||||
// Pruefung 3: die Anmeldesuche findet den Benutzer weiterhin, mit dem
|
||||
// FESTEN Spaltensatz der Funktion — die Spaltenerweiterung oben laesst
|
||||
// weder avatarPath noch accentColor noch email durch.
|
||||
const lookupRows = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`;
|
||||
const lookupKeys = lookupRows.length === 1 ? Object.keys(lookupRows[0]).sort() : [];
|
||||
const lookupOk =
|
||||
lookupRows.length === 1 &&
|
||||
lookupKeys.length === 9 &&
|
||||
!lookupKeys.includes('avatarPath') &&
|
||||
!lookupKeys.includes('accentColor') &&
|
||||
!lookupKeys.includes('email');
|
||||
report(
|
||||
results,
|
||||
'auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz',
|
||||
lookupOk,
|
||||
`auth_lookup_user_by_username('alice') liefert ${lookupRows.length} Zeile(n) mit ${lookupKeys.length} Schluessel(n): ${JSON.stringify(lookupKeys)} — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch`,
|
||||
);
|
||||
|
||||
// Die tragende Belegzeile: getMe() als Client-Form, UNGEBUNDEN.
|
||||
const getMeSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
mustChangePassword: true,
|
||||
passwordHash: true,
|
||||
ldapDn: true,
|
||||
avatarPath: true,
|
||||
accentColor: true,
|
||||
};
|
||||
const unboundGetMe = await prisma.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-ungebunden-liefert-null',
|
||||
unboundGetMe === null,
|
||||
`ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert ${JSON.stringify(unboundGetMe)} — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert`,
|
||||
);
|
||||
|
||||
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
|
||||
const boundGetMeOwn = await boundA.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
const ownTenantOk =
|
||||
Boolean(boundGetMeOwn) &&
|
||||
boundGetMeOwn.tenantId === 'TENANT-A' &&
|
||||
Object.keys(boundGetMeOwn).sort().length === 10;
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer',
|
||||
ownTenantOk,
|
||||
`gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): ${JSON.stringify(boundGetMeOwn)}`,
|
||||
);
|
||||
|
||||
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
|
||||
const boundGetMeForeign = await boundB.user.findUnique({
|
||||
where: { id: 'user-a' },
|
||||
select: getMeSelect,
|
||||
});
|
||||
report(
|
||||
results,
|
||||
'auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null',
|
||||
boundGetMeForeign === null,
|
||||
`gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): ${JSON.stringify(boundGetMeForeign)} — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01`,
|
||||
);
|
||||
|
||||
// Die Schreibform, die changePassword heute stellt: UNGEBUNDEN.
|
||||
let ungebundenesUpdateWarf = false;
|
||||
let ungebundenesUpdateCtor = 'unbekannt';
|
||||
let ungebundenesUpdateCode;
|
||||
let ungebundenesUpdateMessage = '';
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-neu-ungebunden', mustChangePassword: false },
|
||||
});
|
||||
} catch (err) {
|
||||
ungebundenesUpdateWarf = true;
|
||||
ungebundenesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
|
||||
ungebundenesUpdateCode = err?.code;
|
||||
ungebundenesUpdateMessage = (err.message ?? '').toString().trim();
|
||||
}
|
||||
const passwordHashNachUngebundenemVersuch = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0]?.passwordHash;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut',
|
||||
ungebundenesUpdateWarf && passwordHashNachUngebundenemVersuch === 'hash-a',
|
||||
`ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft ${ungebundenesUpdateCtor}${ungebundenesUpdateCode ? ` (code ${ungebundenesUpdateCode})` : ''}: ${ungebundenesUpdateMessage} — die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachUngebundenemVersuch)}`,
|
||||
);
|
||||
|
||||
// Dasselbe update, GEBUNDEN unter TENANT-A (eigener Mandant): gelingt.
|
||||
await boundA.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-neu', mustChangePassword: false },
|
||||
});
|
||||
const nachGebundenemUpdate = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows =
|
||||
await db.$queryRaw`SELECT "passwordHash", "updatedAt" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0];
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt',
|
||||
nachGebundenemUpdate?.passwordHash === 'hash-a-neu' && nachGebundenemUpdate?.updatedAt != null,
|
||||
`gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash=${JSON.stringify(nachGebundenemUpdate?.passwordHash)}, updatedAt=${JSON.stringify(nachGebundenemUpdate?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt`,
|
||||
);
|
||||
|
||||
// Dieselbe Schreibform, GEBUNDEN unter TENANT-B (fremder Mandant): die
|
||||
// Form, die adminResetPassword fuer ein fremdmandantiges Ziel stellt.
|
||||
let fremdesUpdateWarf = false;
|
||||
let fremdesUpdateCtor = 'unbekannt';
|
||||
let fremdesUpdateCode;
|
||||
let fremdesUpdateMessage = '';
|
||||
try {
|
||||
await boundB.user.update({
|
||||
where: { id: 'user-a' },
|
||||
data: { passwordHash: 'hash-a-fremd' },
|
||||
});
|
||||
} catch (err) {
|
||||
fremdesUpdateWarf = true;
|
||||
fremdesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
|
||||
fremdesUpdateCode = err?.code;
|
||||
fremdesUpdateMessage = (err.message ?? '').toString().trim();
|
||||
}
|
||||
const passwordHashNachFremdemVersuch = await withAdminPrisma(
|
||||
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
||||
async (db) => {
|
||||
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
|
||||
return rows[0]?.passwordHash;
|
||||
},
|
||||
);
|
||||
report(
|
||||
results,
|
||||
'auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut',
|
||||
fremdesUpdateWarf && passwordHashNachFremdemVersuch === 'hash-a-neu',
|
||||
`gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) ${fremdesUpdateCtor}${fremdesUpdateCode ? ` (code ${fremdesUpdateCode})` : ''}: ${fremdesUpdateMessage} — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachFremdemVersuch)}`,
|
||||
);
|
||||
|
||||
// Fan-out je Mandant, gebunden: die Form, die Aufgabe 2/3 fuer die
|
||||
// oberste Rolle (SUPER_ADMIN) benutzt — Tenant ungebunden als Treiber
|
||||
// (Tenant ohne Regel, gemessen in runTenantAreaChecks), je Mandant EIN
|
||||
// gebundener findUnique.
|
||||
const tenantsForFanOut = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
|
||||
let fanOutHit = null;
|
||||
let fanOutTenantId = null;
|
||||
for (const t of tenantsForFanOut) {
|
||||
const boundForT = buildInlineExtendedClient(prisma, t.id);
|
||||
const hit = await boundForT.user.findUnique({ where: { id: 'user-a' } });
|
||||
if (hit) {
|
||||
fanOutHit = hit;
|
||||
fanOutTenantId = t.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
report(
|
||||
results,
|
||||
'auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf',
|
||||
Boolean(fanOutHit) && fanOutTenantId === 'TENANT-A' && fanOutHit.tenantId === 'TENANT-A',
|
||||
`Fan-out ueber ${tenantsForFanOut.length} Mandant(en): 'user-a' gefunden unter ${JSON.stringify(fanOutTenantId)}, tenantId der Zeile=${JSON.stringify(fanOutHit?.tenantId)} — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -3683,6 +4014,7 @@ async function main() {
|
||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} finally {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
@@ -23,7 +25,33 @@ import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Loest den Mandanten fuer `adminResetPassword` auf (260911-fh9,
|
||||
* Praezedenzfall `user.controller.ts` `resolveTargetUser`, 260910-das):
|
||||
* ein ADMIN wirkt auf seinen EIGENEN Mandanten (Claim), die oberste
|
||||
* Rolle (SUPER_ADMIN) behaelt ihre uebergreifende Reichweite ueber den
|
||||
* gebundenen Fan-out `UserService.findByIdForPlatformAdmin` — sonst
|
||||
* saehe ein SUPER_ADMIN nur noch den eigenen Mandanten, eine stille
|
||||
* Funktionsminderung (Befund D). Nicht gefunden: dieselbe
|
||||
* `BadRequestException('User not found')`, die der Dienst bisher ohne
|
||||
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
|
||||
* sieht wie vor dieser Umstellung.
|
||||
*/
|
||||
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
const target = await this.userService.findByIdForPlatformAdmin(userId);
|
||||
if (!target) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
return target.tenantId;
|
||||
}
|
||||
return currentUser.tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
@@ -55,10 +83,16 @@ export class AuthController {
|
||||
* GET /auth/me
|
||||
* Returns enriched user profile: public fields + isLocalUser + hasAvatar.
|
||||
* T-gbh-03: passwordHash and ldapDn are never serialised in the response.
|
||||
*
|
||||
* Mandant kommt ausschliesslich aus dem Sitzungsnachweis (`@CurrentUser()`,
|
||||
* das Claim), NICHT aus der Anfrageobjekt-Eigenschaft, die `TenantGuard`
|
||||
* fuer die oberste Rolle per Kopfzeile umschaltbar macht — ein
|
||||
* umgeschalteter SUPER_ADMIN muss sich selbst weiterhin sehen (260911-fh9,
|
||||
* Befund C).
|
||||
*/
|
||||
@Get('me')
|
||||
async me(@CurrentUser() user: any) {
|
||||
return this.authService.getMe(user.id);
|
||||
return this.authService.getMe(user.tenantId, user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +135,7 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
await this.authService.changePassword(
|
||||
user.tenantId,
|
||||
user.id,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
@@ -113,6 +148,13 @@ export class AuthController {
|
||||
* POST /auth/admin-reset-password/:userId
|
||||
* Admin resets a user's password (D-03 admin reset).
|
||||
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
|
||||
*
|
||||
* Der Mandant des Ziels kommt ausschliesslich aus dem Sitzungsnachweis
|
||||
* des AUFRUFERS bzw. aus dem gebundenen Fan-out fuer die oberste Rolle
|
||||
* (`resolveTargetTenantId` oben) — NICHT aus Pfad, Rumpf oder Kopfzeile
|
||||
* (T-FH9-02). `AdminResetPasswordDto` traegt bewusst kein Mandantenfeld.
|
||||
* Kein Frontend-Aufrufer (gemessen, 260911-fh9 Befund D); der
|
||||
* Schwesterweg ist `PATCH /users/:id`.
|
||||
*/
|
||||
@Post('admin-reset-password/:userId')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
@@ -121,8 +163,12 @@ export class AuthController {
|
||||
async adminResetPassword(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: AdminResetPasswordDto,
|
||||
@CurrentUser() currentUser: any,
|
||||
) {
|
||||
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
|
||||
await this.authService.adminResetPassword(
|
||||
tenantId,
|
||||
currentUser.role,
|
||||
userId,
|
||||
dto.newPassword,
|
||||
dto.mustChangePassword ?? true,
|
||||
|
||||
@@ -4,11 +4,24 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { LdapModule } from '../ldap/ldap.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
|
||||
/**
|
||||
* Importiert `UserModule` fuer `AuthController.resolveTargetTenantId`
|
||||
* (260911-fh9): `adminResetPassword` loest den Mandanten der obersten
|
||||
* Rolle (SUPER_ADMIN) ueber `UserService.findByIdForPlatformAdmin` auf.
|
||||
* Zyklusfrei gemessen: `UserModule` importiert nur `GroupsModule`,
|
||||
* `GroupsModule` importiert nichts (`grep -n "imports:"
|
||||
* apps/api/src/groups/groups.module.ts`: null Treffer), und kein Modul
|
||||
* ausser `AppModule` importiert `AuthModule` (`grep -rn "AuthModule"
|
||||
* apps/api/src --include=*.module.ts`: nur `app.module.ts` und diese
|
||||
* Datei selbst). `LdapModule`, das `AuthModule` bereits importiert,
|
||||
* importiert `UserModule` unabhaengig davon selbst.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
@@ -21,6 +34,7 @@ import { LocalStrategy } from './strategies/local.strategy';
|
||||
}),
|
||||
MailModule,
|
||||
LdapModule,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Role } from '@prisma/client';
|
||||
import { AuthService } from './auth.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
|
||||
// ist hier nicht die Pruefung) — dasselbe Muster wie in
|
||||
// ldap.service.spec.ts.
|
||||
/**
|
||||
* Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der
|
||||
* Nachbau bekommt zwei UNTERSCHEIDBARE Klienten, in der Form von
|
||||
* `user.service.spec.ts`/`tenant.controller.spec.ts`: `forTenant()` wird
|
||||
* auf `__makeBoundClient(tenantId)` umgeleitet. Der UNGEBUNDENE Nachbau
|
||||
* (der ungebundene Basisclient selbst) hat `$queryRaw` und
|
||||
* `__makeBoundClient` — aber KEIN `user`- und KEIN `passwordResetToken`-
|
||||
* Modell: ein versehentlich ungebundener Modellzugriff scheitert mit
|
||||
* "Cannot read properties of undefined" (die dkv-Form der Falsifizierung).
|
||||
* Der GEBUNDENE Klient hat `user`/`passwordResetToken`, aber KEIN
|
||||
* `$queryRaw`: eine gebundene Anmeldesuche scheitert ebenso hart.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -25,6 +36,122 @@ function fakeQueryRaw(resultsByCall: unknown[][]) {
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
interface FakeUserRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
passwordHash: string | null;
|
||||
ldapDn: string | null;
|
||||
isActive: boolean;
|
||||
role: string;
|
||||
displayName: string | null;
|
||||
mustChangePassword: boolean;
|
||||
avatarPath?: string | null;
|
||||
accentColor?: string | null;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
model: 'user' | 'passwordResetToken';
|
||||
method: string;
|
||||
args: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei-Klienten-Nachbau (Muster `user.service.spec.ts`): `users` und
|
||||
* `resetTokens` sind das gemeinsame Gedaechtnis, der ungebundene Klient
|
||||
* (`$queryRaw`) und der gebundene Klient (`__makeBoundClient`) greifen auf
|
||||
* DIESELBEN Karten zu, protokollieren aber unterschiedlich — der
|
||||
* ungebundene protokolliert nicht, der gebundene schon.
|
||||
*/
|
||||
function makeFakePrisma(userRows: FakeUserRow[] = [], queryRawResults: unknown[][] = [[]]) {
|
||||
const users = new Map(userRows.map((u) => [u.id, { ...u }]));
|
||||
const resetTokens: any[] = [];
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
const queryRaw = fakeQueryRaw(queryRawResults);
|
||||
|
||||
function throwNotFound(): never {
|
||||
const err: any = new Error('Record to update not found');
|
||||
err.code = 'P2025';
|
||||
throw err;
|
||||
}
|
||||
|
||||
function makeScopedUser(tenantId: string) {
|
||||
return {
|
||||
findUnique: async ({ where, select }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'user', method: 'findUnique', args: { where, select } });
|
||||
const row = users.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) return null;
|
||||
if (!select) return { ...row };
|
||||
const picked: any = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) picked[key] = (row as any)[key];
|
||||
}
|
||||
return picked;
|
||||
},
|
||||
update: async ({ where, data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'user', method: 'update', args: { where, data } });
|
||||
const row = users.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwNotFound();
|
||||
const updated = { ...row, ...data };
|
||||
users.set(where.id, updated);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeScopedResetToken(tenantId: string) {
|
||||
return {
|
||||
create: async ({ data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'create', args: { data } });
|
||||
const record = { id: `rt-${resetTokens.length + 1}`, usedAt: null, ...data };
|
||||
resetTokens.push(record);
|
||||
return record;
|
||||
},
|
||||
update: async ({ where, data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'update', args: { where, data } });
|
||||
const idx = resetTokens.findIndex((t) => t.id === where.id);
|
||||
if (idx === -1) throwNotFound();
|
||||
resetTokens[idx] = { ...resetTokens[idx], ...data };
|
||||
return resetTokens[idx];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fake: any = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
__users: users,
|
||||
__resetTokens: resetTokens,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
return {
|
||||
__isBoundClient: true,
|
||||
__tenantId: tenantId,
|
||||
user: makeScopedUser(tenantId),
|
||||
passwordResetToken: makeScopedResetToken(tenantId),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { prisma: fake, queryRaw };
|
||||
}
|
||||
|
||||
function expectBoundCall(
|
||||
prisma: any,
|
||||
tenantId: string,
|
||||
model: 'user' | 'passwordResetToken',
|
||||
method: string,
|
||||
) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: BoundCall) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
|
||||
* have no local passwordHash and must be authenticated by binding as their own
|
||||
@@ -49,17 +176,16 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
passwordHash: null,
|
||||
ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local',
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: null,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryRaw = fakeQueryRaw([[ldapUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: {
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]);
|
||||
prisma = built.prisma;
|
||||
queryRaw = built.queryRaw;
|
||||
ldapService = { verifyUserCredentials: vi.fn() };
|
||||
ldapConfigService = {
|
||||
getConfig: vi.fn().mockResolvedValue({
|
||||
@@ -92,10 +218,7 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
ldapUser.ldapDn,
|
||||
'ad-password',
|
||||
);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u1' },
|
||||
data: { lastLoginAt: expect.any(Date) },
|
||||
});
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
});
|
||||
|
||||
it('rejects an LDAP user when the directory bind fails', async () => {
|
||||
@@ -104,7 +227,7 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
const result = await service.validateUser('alice', 'wrong');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an LDAP user when no active LDAP config exists', async () => {
|
||||
@@ -117,8 +240,8 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
});
|
||||
|
||||
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
||||
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
|
||||
prisma.$queryRaw = queryRaw.fn;
|
||||
const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]);
|
||||
prisma.$queryRaw = built.queryRaw.fn;
|
||||
|
||||
const result = await service.validateUser('alice', 'pw');
|
||||
|
||||
@@ -153,6 +276,10 @@ describe('AuthService.validateUser — LDAP login', () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('scheitert an "Cannot read properties of undefined", wenn die Suche versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
||||
expect(prisma.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -172,6 +299,9 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
|
||||
passwordHash: string;
|
||||
ldapDn: null;
|
||||
isActive: boolean;
|
||||
role: string;
|
||||
displayName: null;
|
||||
mustChangePassword: boolean;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -186,13 +316,14 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
|
||||
passwordHash: await argon2.hash('correct-password'),
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: null,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
queryRaw = fakeQueryRaw([[localUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]);
|
||||
prisma = built.prisma;
|
||||
queryRaw = built.queryRaw;
|
||||
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||
});
|
||||
|
||||
@@ -200,11 +331,24 @@ describe('AuthService.validateUser — lokales Kennwort', () => {
|
||||
const result = await service.validateUser('bob', 'correct-password');
|
||||
|
||||
expect(result).toEqual(localUser);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
const updateCall = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
||||
);
|
||||
expect(updateCall.args).toEqual({
|
||||
where: { id: 'u2' },
|
||||
data: { lastLoginAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('sucht die Anmeldedaten exakt EINMAL ungebunden ueber $queryRaw und schreibt lastLoginAt exakt EINMAL gebunden unter dem Mandanten der Funktionszeile — die Grenze zwischen Anmeldeweg und Nach-Anmeldung', async () => {
|
||||
await service.validateUser('bob', 'correct-password');
|
||||
|
||||
expect(queryRaw.calls).toHaveLength(1);
|
||||
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
||||
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.requestPasswordReset', () => {
|
||||
@@ -213,15 +357,18 @@ describe('AuthService.requestPasswordReset', () => {
|
||||
let mailService: any;
|
||||
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
||||
|
||||
const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true };
|
||||
const emailUser = {
|
||||
id: 'u3',
|
||||
tenantId: 't1',
|
||||
email: 'bob@example.com',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryRaw = fakeQueryRaw([[emailUser]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
const built = makeFakePrisma([], [[emailUser]]);
|
||||
prisma = built.prisma;
|
||||
queryRaw = built.queryRaw;
|
||||
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
|
||||
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
|
||||
});
|
||||
@@ -229,7 +376,11 @@ describe('AuthService.requestPasswordReset', () => {
|
||||
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
|
||||
await service.requestPasswordReset('bob@example.com');
|
||||
|
||||
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
|
||||
expectBoundCall(prisma, 't1', 'passwordResetToken', 'create');
|
||||
const createCall = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'create',
|
||||
);
|
||||
expect(createCall.args).toEqual({
|
||||
data: {
|
||||
token: expect.any(String),
|
||||
userId: 'u3',
|
||||
@@ -248,7 +399,7 @@ describe('AuthService.requestPasswordReset', () => {
|
||||
|
||||
await service.requestPasswordReset('unknown@example.com');
|
||||
|
||||
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
|
||||
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -269,23 +420,48 @@ describe('AuthService.resetPassword', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryRaw = fakeQueryRaw([[resetTokenRow]]);
|
||||
prisma = {
|
||||
$queryRaw: queryRaw.fn,
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
const built = makeFakePrisma(
|
||||
[
|
||||
{
|
||||
id: 'u4',
|
||||
tenantId: 't1',
|
||||
username: 'dave',
|
||||
passwordHash: 'old-hash',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: null,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
],
|
||||
[[resetTokenRow]],
|
||||
);
|
||||
prisma = built.prisma;
|
||||
queryRaw = built.queryRaw;
|
||||
// Das Token selbst existiert im gebundenen Nachbau nicht automatisch —
|
||||
// fuer den Update-Zweig genuegt hier, dass das UPDATE ueber die
|
||||
// Kennung `rt1` gelingt; deshalb wird der Datensatz vorab ueber die
|
||||
// Anlage nachgebildet, bevor resetPassword() ihn aktualisiert.
|
||||
prisma.__resetTokens.push({ id: 'rt1', token: 'a-uuid-token', usedAt: null });
|
||||
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||
});
|
||||
|
||||
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
|
||||
await service.resetPassword('a-uuid-token', 'new-password');
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
expectBoundCall(prisma, 't1', 'passwordResetToken', 'update');
|
||||
const userUpdate = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
||||
);
|
||||
expect(userUpdate.args).toEqual({
|
||||
where: { id: 'u4' },
|
||||
data: { passwordHash: expect.any(String), mustChangePassword: false },
|
||||
});
|
||||
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
|
||||
const tokenUpdate = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'update',
|
||||
);
|
||||
expect(tokenUpdate.args).toEqual({
|
||||
where: { id: 'rt1' },
|
||||
data: { usedAt: expect.any(Date) },
|
||||
});
|
||||
@@ -300,3 +476,280 @@ describe('AuthService.resetPassword', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* getMe/changePassword/adminResetPassword — bisher OHNE einen einzigen
|
||||
* Testfall (Befund F). Alle drei binden seit Aufgabe 2 an den Mandanten
|
||||
* aus dem Sitzungsnachweis.
|
||||
*/
|
||||
describe('AuthService.getMe', () => {
|
||||
let service: AuthService;
|
||||
let prisma: any;
|
||||
|
||||
const localUserRow: FakeUserRow = {
|
||||
id: 'u1',
|
||||
tenantId: 't1',
|
||||
username: 'alice',
|
||||
passwordHash: 'a-hash',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: 'Alice',
|
||||
mustChangePassword: false,
|
||||
avatarPath: 'avatars/u1.png',
|
||||
accentColor: '#3b82f6',
|
||||
};
|
||||
|
||||
const ldapUserRow: FakeUserRow = {
|
||||
id: 'u2',
|
||||
tenantId: 't1',
|
||||
username: 'bob',
|
||||
passwordHash: null,
|
||||
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: 'Bob',
|
||||
mustChangePassword: false,
|
||||
avatarPath: null,
|
||||
accentColor: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const built = makeFakePrisma([localUserRow, ldapUserRow]);
|
||||
prisma = built.prisma;
|
||||
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||
});
|
||||
|
||||
it('eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, isLocalUser true, hasAvatar true — passwordHash/ldapDn/avatarPath fehlen (T-gbh-03)', async () => {
|
||||
const result = await service.getMe('t1', 'u1');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'u1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
mustChangePassword: false,
|
||||
accentColor: '#3b82f6',
|
||||
isLocalUser: true,
|
||||
hasAvatar: true,
|
||||
});
|
||||
expect(result).not.toHaveProperty('passwordHash');
|
||||
expect(result).not.toHaveProperty('ldapDn');
|
||||
expect(result).not.toHaveProperty('avatarPath');
|
||||
});
|
||||
|
||||
it('eigener Mandant, LDAP-Benutzer (kein Hash, ldapDn gesetzt): isLocalUser false', async () => {
|
||||
const result = await service.getMe('t1', 'u2');
|
||||
|
||||
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
||||
});
|
||||
|
||||
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
||||
const result = await service.getMe('t2', 'u1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
|
||||
await service.getMe('t1', 'u1');
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
||||
expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.changePassword', () => {
|
||||
let service: AuthService;
|
||||
let prisma: any;
|
||||
let jwtService: any;
|
||||
let configService: any;
|
||||
let response: any;
|
||||
let localUserRow: FakeUserRow;
|
||||
let ldapUserRow: FakeUserRow;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const argon2 = await import('argon2');
|
||||
localUserRow = {
|
||||
id: 'u1',
|
||||
tenantId: 't1',
|
||||
username: 'alice',
|
||||
passwordHash: await argon2.hash('current-password'),
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: 'Alice',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
ldapUserRow = {
|
||||
id: 'u2',
|
||||
tenantId: 't1',
|
||||
username: 'bob',
|
||||
passwordHash: null,
|
||||
ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local',
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: 'Bob',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
const built = makeFakePrisma([localUserRow, ldapUserRow]);
|
||||
prisma = built.prisma;
|
||||
jwtService = { sign: vi.fn().mockReturnValue('signed.jwt.token') };
|
||||
configService = { get: vi.fn().mockReturnValue('development') };
|
||||
response = { cookie: vi.fn() };
|
||||
service = new AuthService(prisma, jwtService, configService, {} as any, {} as any, {} as any);
|
||||
});
|
||||
|
||||
it('eigener Mandant, richtiges aktuelles Kennwort: gebundenes update traegt neuen Hash und mustChangePassword=false, JWT signiert mit tenantId/mustChangePassword, Cookie gesetzt', async () => {
|
||||
const argon2 = await import('argon2');
|
||||
|
||||
await service.changePassword('t1', 'u1', 'current-password', 'brand-new-password', response);
|
||||
|
||||
const updateCall = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall.args.where).toEqual({ id: 'u1' });
|
||||
expect(updateCall.args.data.mustChangePassword).toBe(false);
|
||||
const isNewHashValid = await argon2.verify(
|
||||
updateCall.args.data.passwordHash,
|
||||
'brand-new-password',
|
||||
);
|
||||
expect(isNewHashValid).toBe(true);
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tenantId: 't1', mustChangePassword: false }),
|
||||
);
|
||||
expect(response.cookie).toHaveBeenCalledWith(
|
||||
'session',
|
||||
'signed.jwt.token',
|
||||
expect.objectContaining({ httpOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FREMDER Mandant: UnauthorizedException, kein Schreibzugriff, kein Cookie', async () => {
|
||||
await expect(
|
||||
service.changePassword('t2', 'u1', 'current-password', 'new-password', response),
|
||||
).rejects.toThrow('User not found or has no local password');
|
||||
|
||||
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
||||
expect(response.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falsches aktuelles Kennwort: Current password is incorrect, kein Schreibzugriff', async () => {
|
||||
await expect(
|
||||
service.changePassword('t1', 'u1', 'wrong-password', 'new-password', response),
|
||||
).rejects.toThrow('Current password is incorrect');
|
||||
|
||||
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('LDAP-Benutzer (kein Hash): UnauthorizedException, kein Schreibzugriff', async () => {
|
||||
await expect(
|
||||
service.changePassword('t1', 'u2', 'anything', 'new-password', response),
|
||||
).rejects.toThrow('User not found or has no local password');
|
||||
|
||||
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('erzeugt genau EINEN gebundenen Klienten je Aufruf (Suche und Schreiben auf demselben)', async () => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
|
||||
await service.changePassword('t1', 'u1', 'current-password', 'new-password', response);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.adminResetPassword', () => {
|
||||
let service: AuthService;
|
||||
let prisma: any;
|
||||
let targetUserRow: FakeUserRow;
|
||||
let superAdminTargetRow: FakeUserRow;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
targetUserRow = {
|
||||
id: 'target',
|
||||
tenantId: 't1',
|
||||
username: 'carol',
|
||||
passwordHash: 'old-hash',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
displayName: 'Carol',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
superAdminTargetRow = {
|
||||
id: 'boss',
|
||||
tenantId: 't1',
|
||||
username: 'dora',
|
||||
passwordHash: 'old-hash',
|
||||
ldapDn: null,
|
||||
isActive: true,
|
||||
role: 'SUPER_ADMIN',
|
||||
displayName: 'Dora',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
const built = makeFakePrisma([targetUserRow, superAdminTargetRow]);
|
||||
prisma = built.prisma;
|
||||
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||
});
|
||||
|
||||
it('eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundenes update mit neuem Hash, mustChangePassword TRUE (Vorgabe, Parameter weggelassen)', async () => {
|
||||
const argon2 = await import('argon2');
|
||||
|
||||
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
|
||||
|
||||
const updateCall = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
||||
);
|
||||
expect(updateCall.args.where).toEqual({ id: 'target' });
|
||||
expect(updateCall.args.data.mustChangePassword).toBe(true);
|
||||
const ok = await argon2.verify(updateCall.args.data.passwordHash, 'fresh-password');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('mustChangePassword: false wird durchgereicht', async () => {
|
||||
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password', false);
|
||||
|
||||
const updateCall = prisma.__boundCallLog.find(
|
||||
(c: BoundCall) => c.model === 'user' && c.method === 'update',
|
||||
);
|
||||
expect(updateCall.args.data.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it('FREMDER Mandant: BadRequestException, Meldung woertlich "User not found", KEIN Schreibzugriff — nennt weder Halter noch Mandanten', async () => {
|
||||
await expect(
|
||||
service.adminResetPassword('t2', Role.ADMIN, 'target', 'fresh-password'),
|
||||
).rejects.toThrow('User not found');
|
||||
|
||||
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04), KEIN Schreibzugriff', async () => {
|
||||
await expect(
|
||||
service.adminResetPassword('t1', Role.ADMIN, 'boss', 'fresh-password'),
|
||||
).rejects.toThrow(); // ForbiddenException
|
||||
|
||||
expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt', async () => {
|
||||
await service.adminResetPassword('t1', Role.SUPER_ADMIN, 'boss', 'fresh-password');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
});
|
||||
|
||||
it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
|
||||
await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password');
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Response } from 'express';
|
||||
@@ -48,6 +50,33 @@ interface AuthLookupResetTokenRow {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bindung an forTenant() (260911-fh9): dieser Bereich traegt die Grenze
|
||||
* der gesamten Mandantentrennung. `validateUser`, `requestPasswordReset`,
|
||||
* `resetPassword` suchen VOR bekanntem Mandanten — sie bleiben deshalb auf
|
||||
* dem ungebundenen Klienten und laufen ueber die drei
|
||||
* SECURITY-DEFINER-Funktionen aus `20260909160000_auth_lookup_functions`
|
||||
* (Etappe 1, 260909-eor). `getMe`, `changePassword`, `adminResetPassword`
|
||||
* laufen NACH der Anmeldung: der Mandant steht im signierten
|
||||
* Sitzungsnachweis (dem JWT-Claim `tenantId`, das `login()` aus der
|
||||
* Funktionszeile signiert) und wird je Methode ueber GENAU EINEN Klienten
|
||||
* `tenantPrisma` gebunden. Woher der Mandant der drei gebundenen Methoden
|
||||
* kommt: das Claim (`@CurrentUser().tenantId` im Controller) — NICHT die
|
||||
* Anfrageobjekt-Eigenschaft, die `TenantGuard` fuer die oberste Rolle per
|
||||
* Kopfzeile umschaltbar macht (die eigene Zeile liegt immer im eigenen
|
||||
* Mandanten, ein umgeschalteter SUPER_ADMIN muss sich selbst sehen). Fuer
|
||||
* die oberste Rolle bei `adminResetPassword` kommt der Mandant des ZIELS
|
||||
* stattdessen aus dem gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
|
||||
* (Controller-seitig, Praezedenzfall `user.controller.ts` `resolveTargetUser`,
|
||||
* 260910-das).
|
||||
*
|
||||
* Etappe-3-Vorbehalt: sobald Anmeldenamen je Mandant eindeutig werden,
|
||||
* braucht der Anmeldeweg den Mandanten VOR der Suche — ein Umbau der drei
|
||||
* Funktionen (zwei Gleichheitsbedingungen statt einer, ENGER, nicht
|
||||
* weiter), nicht dieser Bereich. Die Bindung der drei Methoden hier haengt
|
||||
* ausschliesslich am Claim `tenantId` und an `User.id` (plattformweite
|
||||
* UUID) und bleibt davon unberuehrt.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -268,9 +297,17 @@ export class AuthService {
|
||||
* Return enriched profile for the currently authenticated user.
|
||||
* T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never
|
||||
* passwordHash or ldapDn.
|
||||
*
|
||||
* Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9): der
|
||||
* Aufrufer sucht seine EIGENE Zeile, die per Definition im eigenen
|
||||
* Mandanten liegt. Eine fremdmandantige Kennung (kann strukturell nicht
|
||||
* vorkommen, weil der Controller ausschliesslich `user.id` aus dem Claim
|
||||
* durchreicht) liefert unter dem gebundenen Klienten `null`, nicht die
|
||||
* Zeile.
|
||||
*/
|
||||
async getMe(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
async getMe(tenantId: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -302,14 +339,20 @@ export class AuthService {
|
||||
/**
|
||||
* Change password for the currently logged-in user.
|
||||
* Verifies current password before allowing change.
|
||||
*
|
||||
* Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9), EIN
|
||||
* Klient `tenantPrisma` fuer Suche UND Schreiben — dieselbe Begruendung
|
||||
* wie bei getMe() oben: die eigene Zeile liegt im eigenen Mandanten.
|
||||
*/
|
||||
async changePassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
@@ -323,7 +366,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
});
|
||||
@@ -350,13 +393,29 @@ export class AuthService {
|
||||
/**
|
||||
* Admin reset of a user's password (D-03 admin reset).
|
||||
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
|
||||
*
|
||||
* Bindet an den Mandanten des ZIELS (260911-fh9), EIN Klient
|
||||
* `tenantPrisma`: fuer einen ADMIN-Aufrufer ist das dessen eigener
|
||||
* Mandant aus dem Sitzungsnachweis, fuer SUPER_ADMIN der ueber den
|
||||
* gebundenen Fan-out (Controller, `UserService.findByIdForPlatformAdmin`)
|
||||
* aufgeloeste Mandant des Ziels — beide kommen als `tenantId`-Parameter
|
||||
* bereits fertig aufgeloest hier an. Ein fremdmandantiges Ziel ist unter
|
||||
* dem gebundenen Klienten unsichtbar (T-FH9-01); die
|
||||
* `BadRequestException` nennt weder Halter noch Mandanten. Der Riegel
|
||||
* unten schliesst zusaetzlich die Rechteausweitung INNERHALB des
|
||||
* Mandanten (T-FH9-04): ein Nicht-SUPER_ADMIN darf das Kennwort eines
|
||||
* SUPER_ADMIN nicht setzen. Der Schwesterweg `PATCH /users/:id` hat
|
||||
* dieselbe Luecke nicht geschlossen — offener Ledger-Eintrag T-FH9-05.
|
||||
*/
|
||||
async adminResetPassword(
|
||||
tenantId: string,
|
||||
callerRole: Role,
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
mustChangePassword: boolean = true,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
@@ -364,8 +423,12 @@ export class AuthService {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (user.role === Role.SUPER_ADMIN && callerRole !== Role.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Cannot reset password of a SUPER_ADMIN user');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
|
||||
@@ -2470,6 +2470,210 @@ im Bereich `user` — kein eigener Eintrag noetig.
|
||||
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
|
||||
KEINE Regel.
|
||||
|
||||
## Bereich auth
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `auth`
|
||||
(Quick-Task 260911-fh9) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||
Anders als jeder Bereich davor traegt dieser Bereich die Grenze, an der die
|
||||
gesamte Mandantentrennung haengt: der Anmeldeweg (Benutzer suchen, BEVOR der
|
||||
Mandant bekannt ist) wurde bereits in Etappe 1 (260909-eor) ueber drei enge
|
||||
SECURITY-DEFINER-Funktionen geloest und bleibt in diesem Durchlauf
|
||||
unangetastet; gegenstand dieses Durchlaufs sind ausschliesslich die drei
|
||||
Methoden NACH der Anmeldung (`getMe`, `changePassword`,
|
||||
`adminResetPassword`), die in Etappe 1 bewusst liegen gelassen wurden.
|
||||
|
||||
### (h1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen dreizehnten
|
||||
Abschnitt (`runAuthAreaChecks`) erweitert — ausdruecklich GETRENNT vom
|
||||
bestehenden `runAuthLookupChecks`: jener misst den Anmeldeweg VOR bekanntem
|
||||
Mandanten (die drei Funktionen ueber `$queryRaw`), dieser die drei Methoden
|
||||
NACH der Anmeldung (gebundener Modellzugriff ueber den GENERIERTEN Client).
|
||||
Der neue Abschnitt setzt auf der von `runAuthLookupChecks` bereits angelegten
|
||||
Wegwerf-Tabelle `"User"` auf (eingeschalteter und erzwungener Zeilenschutz,
|
||||
wortgleiche Policy, zwei Zeilen in zwei Mandanten; seit `runTenantAreaChecks`
|
||||
zusaetzlich mit Fremdschluessel auf `"Tenant"`) und ruestet sie um die fuenf
|
||||
Spalten nach, die der generierte Client zusaetzlich braucht (`createdAt`,
|
||||
`updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` — Befund G): dafuer
|
||||
liest ein neuer Helfer `readSchemaModelScalarFieldNames('User')` die
|
||||
skalaren Felder aus `schema.prisma` und filtert Relationsfelder ueber ihren
|
||||
Typ heraus (`tenant`, `passwordResetTokens`, `groupMemberships`,
|
||||
`moduleGrants` fallen weg, `role` bleibt — `Role` ist ein `enum`, kein
|
||||
`model`). Diese Spaltenpruefung steht VOR den Client-Pruefungen und bricht
|
||||
den Abschnitt ab, wenn sie durchfaellt (Lehre aus Pruefung 8 im Bereich
|
||||
`calendar`).
|
||||
|
||||
Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||||
`tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||
|
||||
```
|
||||
auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, 15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]
|
||||
auth-anmeldefunktionen-security-definer-unveraendert: bestanden — 3 Funktion(en) unter 'auth_lookup_%' gefunden: ["auth_lookup_reset_token","auth_lookup_user_by_email","auth_lookup_user_by_username"]; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: [{"proname":"auth_lookup_reset_token","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_email","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_username","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true}]
|
||||
auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz: bestanden — auth_lookup_user_by_username('alice') liefert 1 Zeile(n) mit 9 Schluessel(n): ["displayName","id","isActive","ldapDn","mustChangePassword","passwordHash","role","tenantId","username"] — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch
|
||||
auth-getme-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert null — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert
|
||||
auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer: bestanden — gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): {"id":"user-a","username":"alice","displayName":null,"role":"USER","tenantId":"TENANT-A","mustChangePassword":false,"passwordHash":"hash-a","ldapDn":null,"avatarPath":null,"accentColor":null}
|
||||
auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): null — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01
|
||||
auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut: bestanden — ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — die Wartungsrolle liest danach weiterhin passwordHash="hash-a"
|
||||
auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt: bestanden — gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash="hash-a-neu", updatedAt="2026-09-11T09:42:42.165Z" — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt
|
||||
auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut: bestanden — gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash="hash-a-neu"
|
||||
auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf: bestanden — Fan-out ueber 2 Mandant(en): 'user-a' gefunden unter "TENANT-A", tenantId der Zeile="TENANT-A" — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist
|
||||
Alle 120 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Die tragende Belegzeile ist `auth-getme-generierter-client-ungebunden-liefert-null`: der IDENTISCHE `findUnique`, den `getMe` heute stellt, liefert UNGEBUNDEN `null`, waehrend die Wartungsrolle die Zeile sieht — das ist der Wert, den `GET /auth/me` nach dem Scharfschalten als leeren Rumpf ausliefert. Die `pg_proc`-Messung
|
||||
(`auth-anmeldefunktionen-security-definer-unveraendert`) und der feste
|
||||
Spaltensatz (`auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`)
|
||||
belegen zusammen, dass die drei Anmeldefunktionen unangetastet sind und die
|
||||
Spaltenerweiterung von `"User"` nichts Zusaetzliches preisgibt: alle drei
|
||||
Funktionen sind weiterhin `SECURITY DEFINER`, `STABLE`, mit festem Suchpfad
|
||||
`search_path=public, pg_temp` und `LIMIT 1`, und `auth_lookup_user_by_username`
|
||||
liefert nach der Spaltenerweiterung weiterhin genau neun Spalten — weder
|
||||
`avatarPath` noch `accentColor` noch `email` sind darunter.
|
||||
|
||||
Sechs der zehn Pruefungen laufen ueber den GENERIERTEN CLIENT
|
||||
(`prisma.user.findUnique`/`update`, `bound.user.findUnique`/`update`), nicht
|
||||
ueber Roh-SQL — bewusst, weil `findUnique` ohne `select` (die Form von
|
||||
`changePassword`/`adminResetPassword`) und das `select` von `getMe` Client-
|
||||
Formen sind: Roh-SQL sieht sie strukturell nicht (Fehler 7 des Vorhabens).
|
||||
|
||||
### (h2) Signaltabelle je Pfad
|
||||
|
||||
| Pfad | `@Public()` | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|
||||
|---|---|---|---|---|
|
||||
| `validateUser` (`POST /auth/login`) | ja | Sucht ueber `auth_lookup_user_by_username` (Funktion, unveraendert) — findet den Benutzer weiterhin, dann gebundener `lastLoginAt`-Schreibzugriff | Anmeldung funktioniert unveraendert | — |
|
||||
| `requestPasswordReset` (`POST /auth/request-reset`) | ja | Sucht ueber `auth_lookup_user_by_email` (Funktion, unveraendert), dann gebundene Token-Anlage | Unveraendert, immer `200` (T-02-12) | — |
|
||||
| `resetPassword` (`POST /auth/reset-password`) | ja | Sucht ueber `auth_lookup_reset_token` (Funktion, unveraendert), dann gebundenes Kennwort-Update und Token-Markierung | Unveraendert | — |
|
||||
| `login`/`logout` | ja/nein | Kein Datenbankzugriff | Keins | — |
|
||||
| `getMe` (`GET /auth/me`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` (`auth-getme-generierter-client-ungebunden-liefert-null`) statt der eigenen Zeile | `200` mit leerem Rumpf statt der Profildaten | Ja — verschluckt, siehe (h3) |
|
||||
| `changePassword` (`POST /auth/change-password`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` | `UnauthorizedException('User not found or has no local password')`, `401` | Ja — als `networkError`, siehe (h3) |
|
||||
| `adminResetPassword`, ADMIN-Zweig (`POST /auth/admin-reset-password/:userId`) | nein | Vor diesem Plan: ungebundener `findUnique` sieht Ziele JEDES Mandanten — Rechteausweitung ueber die Mandantengrenze (T-FH9-01), kein Frontend-Aufrufer | `BadRequestException('User not found')` nur, wenn die Kennung gar nicht existiert; ein fremdmandantiges Ziel wuerde heute GEFUNDEN | Kein UI-Aufrufer (gemessen, Befund D) |
|
||||
| `adminResetPassword`, SUPER_ADMIN-Zweig | nein | Dieselbe Ausweitung; zusaetzlich keine Rollenpruefung des Ziels (T-FH9-04) | Dieselbe Meldung | Kein UI-Aufrufer |
|
||||
|
||||
Nach der Bindung (Aufgabe 2) loest der SUPER_ADMIN-Zweig den Mandanten des
|
||||
Ziels ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
|
||||
auf — denselben Praezedenzfall, den `user.controller.ts` (`resolveTargetUser`,
|
||||
260910-das) fuer seine eigene Rollenverzweigung benutzt; der ADMIN-Zweig
|
||||
bindet dagegen an `currentUser.tenantId` aus dem Sitzungsnachweis.
|
||||
|
||||
### (h3) Welcher Code Leere als Abwesenheit deutet
|
||||
|
||||
Die `getMe`-Kette, alle vier Glieder namentlich: `getMe` liefert `null` (die
|
||||
Belegzeile `auth-getme-generierter-client-ungebunden-liefert-null`) →
|
||||
`AuthController.me` gibt `null` zurueck, ohne zu werfen → NestJS'
|
||||
`ExpressAdapter.reply` sendet bei `isNil(body)` einen LEEREN Rumpf mit
|
||||
Status `200` (`apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js`,
|
||||
Methode `reply`) → `fetchCurrentUser` (`apps/web/src/lib/auth-actions.ts`)
|
||||
laeuft mit `response.json()` auf den leeren Rumpf, faengt im `catch` und
|
||||
liefert `null` → `apps/web/src/components/layout/header.tsx` (`if (u)
|
||||
setUser(...)`) und `apps/web/src/components/settings/account-settings-form.tsx`
|
||||
(`if (u) ...`) tun bei `null` NICHTS. Folge: die Portalhuelle rendert OHNE
|
||||
angemeldeten Benutzer — kein Name, kein Avatar, `isAdmin` falsch, Admin-
|
||||
Navigation weg. Der Auftrag vermutete hier "laut"; die Lesung ergibt
|
||||
**verschluckt**: `fetchCurrentUser` liefert fuer "nicht angemeldet" und
|
||||
"Zeile unsichtbar" denselben Wert `null` — das ist NICHT die Familie eines
|
||||
lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26. Die Seite
|
||||
`apps/web/src/app/(portal)/change-password/page.tsx` haengt an demselben
|
||||
Aufruf; `ForcePasswordChangeInterceptor` laesst `/auth/me` und
|
||||
`/auth/change-password` ausdruecklich durch, weil der erzwungene
|
||||
Kennwortwechsel `getMe` braucht.
|
||||
|
||||
`changePassword`: der gebundene `findUnique` liefert nach der Bindung `null`
|
||||
fuer ein fremdmandantiges Ziel (kommt hier praktisch nicht vor — der
|
||||
angemeldete Benutzer sucht sich selbst) → `UnauthorizedException('User not
|
||||
found or has no local password')`, `401` → `auth-actions.ts` uebersetzt NUR
|
||||
die woertliche Meldung `Current password is incorrect` in
|
||||
`wrongCurrentPassword`, jede andere 401-Meldung in `networkError` — der
|
||||
Nutzer saehe eine Netzwerkfehler-Meldung fuer einen Trennungsfehler. Der
|
||||
Auftrag vermutete "falsches altes Kennwort"; gemessen ist es `networkError`.
|
||||
|
||||
`adminResetPassword`: `BadRequestException('User not found')`, `400`, kein
|
||||
UI-Aufrufer (Befund D) — fuer einen API-Aufrufer bedeutet das "diesen
|
||||
Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das NACH der
|
||||
Bindung dieses Plans die RICHTIGE Antwort: sie macht keine Aussage ueber die
|
||||
Existenz oder den Mandanten eines fremden Halters (dieselbe Form wie
|
||||
T-DAS-08).
|
||||
|
||||
### (h4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
**(a) Etappe 3.** Die Etappe-3-Entscheidung (1) macht `username` (und
|
||||
`email`) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag:
|
||||
`auth_lookup_user_by_username(p_username)` (stuetzt sich heute auf `username
|
||||
@unique` plattformweit; braucht kuenftig `(p_tenant_id, p_username)` — die
|
||||
Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht
|
||||
weiter), gegebenenfalls `auth_lookup_user_by_email`, `local.strategy.ts`
|
||||
(kennt nur `username`/`password`, braucht eine Mandantenangabe VOR der
|
||||
Suche), die `@unique`-Indizes auf `User`, `UserService.findByUsername`,
|
||||
`resolveEmailForWrite` (ldap), die P2002-Uebersetzung in `UserService.create`/
|
||||
`update` (WINDOWS #22). Dieser Plan ist dafuer NEUTRAL: die Bindung der drei
|
||||
Methoden haengt am Claim `tenantId` (das es unabhaengig davon gibt, WIE der
|
||||
Anmeldeweg den Mandanten ermittelt) und an `User.id` (plattformweite UUID,
|
||||
Kette aus 260911-cwh: Schema → `auth.service.ts` `sub: user.id` →
|
||||
`JwtStrategy.validate` → `@CurrentUser().id`) — nicht an `username`/`email`.
|
||||
Unterlassen, damit Etappe 3 nicht schwerer wird: Selbstbedienung ueber
|
||||
`req.tenantId` binden; den Mandanten irgendwo nach der Anmeldung aus
|
||||
`username`/`email` ableiten; die Funktionen um Spalten erweitern.
|
||||
|
||||
**(b) Die Rechteausweitung ADMIN → SUPER_ADMIN im Schwesterweg.**
|
||||
`apps/api/src/user/user.controller.ts`, `update` (Zeilen 172-208) prueft mit
|
||||
T-02-08 nur, ob die Rolle `SUPER_ADMIN` NEU ZUGEWIESEN wird
|
||||
(`dto.role === Role.SUPER_ADMIN`), nicht, ob das ZIEL diese Rolle bereits
|
||||
HAT — `password`/`isActive` im `UpdateUserDto` gehen fuer ein bestehendes
|
||||
`SUPER_ADMIN`-Ziel ungeprueft durch. `remove` (Zeilen 220-244) prueft
|
||||
ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze.
|
||||
Erneut gelesen (260911-fh9, Aufgabe 1): beide Luecken bestehen unveraendert
|
||||
— `grep -n "Role.SUPER_ADMIN" apps/api/src/user/user.controller.ts` findet
|
||||
nur die eine Stelle in `update` (`dto.role === Role.SUPER_ADMIN`), keine im
|
||||
`user`-Objekt selbst. Kein Mandantenproblem, sondern Rechteausweitung
|
||||
INNERHALB des Mandanten — derselbe Fall wie T-FH9-04 in diesem Plan, nur am
|
||||
Schwesterweg. 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 dieser Aufgabe in
|
||||
`AuthService.adminResetPassword`. Ausserhalb der Erlaubnisliste dieses
|
||||
Plans, deshalb Ledger-Eintrag statt Reparatur (Aufgabe 3, T-FH9-05).
|
||||
|
||||
**(c) Das Frontend, das `null` verschluckt.** Nicht angefasst (siehe (h3));
|
||||
Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26.
|
||||
|
||||
**(d) Die fehlende Existenzpruefung des `x-tenant-id`-Werts.** Bekannt aus
|
||||
`(n4)(f)` des Abschnitts `## Bereich tenant`; hier nur genannt, weil sie die
|
||||
Entscheidung gegen `req.tenantId` fuer Selbstbedienung stuetzt — ein
|
||||
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken, sie
|
||||
bindet an einen leeren Kontext, kein Leck. Nicht dieser Auftrag.
|
||||
|
||||
**(e) Die Etappe-4-Vorabpruefung.** Fuer einen bekannten Benutzer die Zeile
|
||||
ueber die Wartungsrolle lesen und den gebundenen `findUnique` unter seinem
|
||||
Claim-Mandanten daneben halten — dieselbe Form wie bei den Bereichen `user`
|
||||
und `tenant`.
|
||||
|
||||
**(f) `changePassword` hat zwei verschiedene 401-Meldungen.**
|
||||
`User not found or has no local password` (kein lokales Kennwort, z. B.
|
||||
LDAP-Konto) vs. `Current password is incorrect` (falsches Kennwort) —
|
||||
bestehendes Verhalten, betrifft nur die eigene Zeile des angemeldeten
|
||||
Benutzers, unveraendert in diesem Plan.
|
||||
|
||||
### (h5) Was dieser Durchlauf bewusst nicht anfasst
|
||||
|
||||
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1
|
||||
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
|
||||
`auth_lookup_reset_token`) und ihre Migration `20260909160000_auth_lookup_functions`
|
||||
— belegt durch `git diff --name-only 6236b30 -- apps/api/prisma` (leer)
|
||||
und die `pg_proc`-Messung aus (h1).
|
||||
- Die drei `$queryRaw`-Aufrufe in `validateUser`, `requestPasswordReset`,
|
||||
`resetPassword` — bleiben auf dem ungebundenen Klienten `this.prisma`.
|
||||
- `local.strategy.ts` und `jwt.strategy.ts` — nur gelesen.
|
||||
- `apps/api/src/auth/dto/admin-reset-password.dto.ts` — nur gelesen, kein
|
||||
Mandantenfeld.
|
||||
- `docs/mandantentrennung-datenbankrolle.md`, Abschnitt 3 — beschreibt die
|
||||
Anordnung des Anmeldewegs korrekt, keine Aenderung noetig.
|
||||
- `docs/anleitung-entwicklung.md` — nennt keine der drei Methoden.
|
||||
- `apps/api/src/user/user.controller.ts` — nur gelesen (siehe (h4)(b)).
|
||||
- Das Frontend (`header.tsx`, `account-settings-form.tsx`, `auth-actions.ts`,
|
||||
`change-password/page.tsx`) — nur beschrieben, nicht geaendert.
|
||||
- Schema und Migrationen.
|
||||
- Die Kopfzeile in `auth.service.spec.ts` (Zeile 4-6), die auf ein Muster in
|
||||
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
|
||||
Befund F genannt.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
@@ -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 `<next_stages>` 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).
|
||||
|
||||
Reference in New Issue
Block a user