From 888f66003cd1d6e48a8bb07e2bf33f1aab5b403b Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 10 Sep 2026 10:27:14 +0200 Subject: [PATCH] feat(quick-260910-das): user-Dienst binden, Linie ziehen, Startsperre entschaerfen - user.service.ts: findById/update/deactivate/delete bekommen einen Pflicht-Mandanten und laufen ueber forTenant(); create/update uebersetzen die plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen; zwei neue Methoden (findAllForPlatformAdmin, findByIdForPlatformAdmin) bilden die Plattform-Administratorsicht als Schleife ueber alle Mandanten mit je einem gebundenen Lesezugriff nach; findByUsername bleibt bewusst ungebunden, Kopfkommentar richtiggestellt (Anmeldeweg laeuft seit Etappe 1 ueber SECURITY-DEFINER-Funktionen, kein Aufrufer mehr) - admin-seed.service.ts: Erstanlage-Pruefung bleibt ungebunden (mit Begruendung), Erstanlage des Administrators bindet an den unmittelbar zuvor angelegten Mandanten (Befund J-Korrektur); P2002 bei der Anlage wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen -- jeder andere Fehler bricht weiterhin ab - user.controller.ts: die vier Aufrufstellen der geaenderten Signaturen auf currentUser.tenantId umgestellt (Signatur-Minimalanpassung; die Rollenlogik inkl. Plattform-Administratorsicht folgt in Aufgabe 3) - Zwei-Klienten-Nachweis in beiden Testdateien (Muster groups.service.spec.ts), Falsifizierungsnachweis fuer beide Bereiche durchgefuehrt und zurueckgenommen (siehe SUMMARY) - docs/mandantentrennung-zugriffsklassifikation.md: Zwischenstand fuer (user.service.ts, user) und (admin-seed.service.ts, user) auf gemischt korrigiert, neue Zeile (user.service.ts, tenant) ergaenzt -- volle Klassenkorrektur mit Begruendung sowie die vier handgepflegten Uebersichtstabellen folgen in Aufgabe 3 - .planning/WINDOWS.md: offener Eintrag fuer die plattformweite Eindeutigkeit von username/email (Produktentscheidung fuer Etappe 3) - 802 Tests gruen (13 neue in user.service.spec.ts, 4 neue in admin-seed.service.spec.ts), Typpruefung sauber, Wegwerf-Werkzeug meldet weiterhin alle 53 Pruefungen bestanden Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .planning/WINDOWS.md | 19 +- apps/api/src/user/admin-seed.service.spec.ts | 110 ++++- apps/api/src/user/admin-seed.service.ts | 89 +++- apps/api/src/user/user.controller.ts | 18 +- apps/api/src/user/user.service.spec.ts | 391 +++++++++++++++--- apps/api/src/user/user.service.ts | 198 +++++++-- ...andantentrennung-zugriffsklassifikation.md | 7 +- 7 files changed, 714 insertions(+), 118 deletions(-) diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 489c610..243baeb 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 4 +open_count: 5 waived_count: 1 fixed_count: 16 -total_count: 21 -last_updated: 2026-09-09T14:41:07.256Z +total_count: 22 +last_updated: 2026-09-10T08:17:20.009Z --- # Broken Windows Ledger @@ -36,6 +36,7 @@ last_updated: 2026-09-09T14:41:07.256Z | 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). | open | | 2026-09-09T08:08:19.293Z | | | 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | | | 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | | +| 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | | ````json [ @@ -290,6 +291,18 @@ last_updated: 2026-09-09T14:41:07.256Z "reason": "", "recorded_at": "2026-09-09T14:41:07.256Z", "resolved_at": null + }, + { + "id": 22, + "kind": "deviation", + "phase": "quick-260910-das", + "file": "apps/api/src/user/user.service.ts", + "line": null, + "description": "Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-10T08:17:20.009Z", + "resolved_at": null } ] ```` diff --git a/apps/api/src/user/admin-seed.service.spec.ts b/apps/api/src/user/admin-seed.service.spec.ts index ff680e9..3e09881 100644 --- a/apps/api/src/user/admin-seed.service.spec.ts +++ b/apps/api/src/user/admin-seed.service.spec.ts @@ -2,26 +2,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AdminSeedService } from './admin-seed.service'; /** - * AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage und - * Startup-Reparatur (quick-260805-fok). + * AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage, Startup- + * Reparatur (quick-260805-fok) UND Bindungsnachweis/Startsperren- + * Entschärfung (260910-das, Aufgabe 2, Befund C/I/J). * - * Deckt ab: seedAdmin() ruft ensureDefaultGroup NACH tenant.upsert und VOR - * user.create auf; beide frühen Rückkehrpfade (fehlende ENV, Admin existiert - * bereits) überspringen den Benutzer, lassen aber die Reparatur über ALLE - * Mandanten laufen; die Reparatur ist fehlerisoliert je Mandant; ein zweiter - * Bootstrap-Lauf löst keinen zusätzlichen user.create aus. + * Diese Datei hatte bisher KEINE Attrappe für das Bindungshilfsmittel — die + * Erstanlage des Administrators lief nach der Umstellung über + * `forTenant(this.prisma, tenant.id)`, ohne dass ein Test das bemerken + * konnte. Zwei-Klienten-Nachweis nach dem Muster aus + * `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an + * `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper. * - * Die Reihenfolgeprüfung läuft über ein gemeinsames Aufruf-Log-Array, in das - * jeder Mock beim Aufruf seinen Namen schiebt (nicht über bloße - * Aufrufzähler) — Mock-invocationCallOrder ist über drei unabhängige vi.fn's - * (tenant.upsert / ensureDefaultGroup / user.create) weniger lesbar als ein - * geteiltes Log. + * Die Reihenfolgeprüfung läuft weiterhin über ein gemeinsames + * Aufruf-Log-Array (`callLog`), in das jeder Mock beim Aufruf seinen Namen + * schiebt — die Ordering-Assertions der ursprünglichen Datei bleiben + * inhaltlich erhalten. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + describe('AdminSeedService', () => { let prisma: any; let configService: any; let groupsService: any; let callLog: string[]; + let boundCallLog: { tenantId: string; model: string; method: string }[]; + let userCreateImpl: (args: any) => Promise; let service: AdminSeedService; const tenant = { id: 't-default', slug: 'default' }; @@ -34,6 +41,11 @@ describe('AdminSeedService', () => { beforeEach(() => { vi.clearAllMocks(); callLog = []; + boundCallLog = []; + userCreateImpl = async () => { + callLog.push('user.create'); + return { id: 'u1' }; + }; configService = { get: vi.fn((key: string) => envValues[key]), @@ -45,10 +57,6 @@ describe('AdminSeedService', () => { callLog.push('user.findUnique'); return null; }), - create: vi.fn(async () => { - callLog.push('user.create'); - return { id: 'u1' }; - }), }, tenant: { upsert: vi.fn(async () => { @@ -60,6 +68,21 @@ describe('AdminSeedService', () => { return [tenant]; }), }, + __setUserCreateImpl(fn: (args: any) => Promise) { + userCreateImpl = fn; + }, + __makeBoundClient(tenantId: string) { + return { + __isBoundClient: true, + __tenantId: tenantId, + user: { + create: async (args: any) => { + boundCallLog.push({ tenantId, model: 'user', method: 'create' }); + return userCreateImpl(args); + }, + }, + }; + }, }; groupsService = { @@ -93,7 +116,7 @@ describe('AdminSeedService', () => { await service.onApplicationBootstrap(); expect(prisma.tenant.upsert).not.toHaveBeenCalled(); - expect(prisma.user.create).not.toHaveBeenCalled(); + expect(callLog).not.toContain('user.create'); expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); }); @@ -104,7 +127,7 @@ describe('AdminSeedService', () => { await service.onApplicationBootstrap(); expect(prisma.tenant.upsert).not.toHaveBeenCalled(); - expect(prisma.user.create).not.toHaveBeenCalled(); + expect(callLog).not.toContain('user.create'); expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); }); @@ -141,7 +164,7 @@ describe('AdminSeedService', () => { it('ein zweiter onApplicationBootstrap-Lauf ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus', async () => { await service.onApplicationBootstrap(); - expect(prisma.user.create).toHaveBeenCalledTimes(1); + expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1); // Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor // user.create auf, die Reparatur einmal danach fuer denselben Mandanten. expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2); @@ -152,7 +175,54 @@ describe('AdminSeedService', () => { prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' })); await service.onApplicationBootstrap(); - expect(prisma.user.create).toHaveBeenCalledTimes(1); + expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1); expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3); }); + + it('Test 9: die Erstanlage-Pruefung steht NICHT im Bindungsprotokoll, die Erstanlage des Administrators dagegen steht dort mit der Kennung des unmittelbar zuvor angelegten Mandanten', async () => { + await service.onApplicationBootstrap(); + + expect( + boundCallLog.some((c) => c.model === 'user' && c.method === 'findUnique'), + ).toBe(false); + expect(boundCallLog).toContainEqual({ + tenantId: tenant.id, + model: 'user', + method: 'create', + }); + }); + + it('Test 10: liefert die Erstanlage-Pruefung nichts, waehrend das Anlegen an der plattformweiten Eindeutigkeit scheitert, entsteht KEIN Startabbruch — der Dienst behandelt das wie "Administrator existiert bereits", protokolliert und laeuft weiter', async () => { + prisma.__setUserCreateImpl(async () => { + const err: any = new Error('Unique constraint failed on the fields: (`username`)'); + err.code = 'P2002'; + throw err; + }); + + await expect(service.onApplicationBootstrap()).resolves.not.toThrow(); + // Die Reparatur laeuft trotz der abgefangenen Kollision weiter: + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); + }); + + it('Test 11: jeder ANDERE Fehler beim Anlegen bricht den Start weiterhin ab — die Absicht des Dateikopfs bleibt erhalten', async () => { + prisma.__setUserCreateImpl(async () => { + throw new Error('connection refused'); + }); + + await expect(service.onApplicationBootstrap()).rejects.toThrow('connection refused'); + }); + + it('Test 12: die beiden Zugriffe auf die Mandantentabelle stehen NICHT im Bindungsprotokoll, und die Reparaturschleife ruft die Standardgruppen-Sicherung weiterhin je Mandant mit dessen Kennung auf', async () => { + const otherTenant = { id: 't-other', slug: 'other' }; + prisma.tenant.findMany = vi.fn(async () => { + callLog.push('tenant.findMany'); + return [tenant, otherTenant]; + }); + + await service.onApplicationBootstrap(); + + expect(boundCallLog.some((c) => c.model === 'tenant')).toBe(false); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id); + expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(otherTenant.id); + }); }); diff --git a/apps/api/src/user/admin-seed.service.ts b/apps/api/src/user/admin-seed.service.ts index caf19dd..c8fc304 100644 --- a/apps/api/src/user/admin-seed.service.ts +++ b/apps/api/src/user/admin-seed.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -54,7 +55,21 @@ export class AdminSeedService implements OnApplicationBootstrap { return; } - // Check if admin already exists + // Erstanlage-Pruefung beim Start (260910-das, Befund I/Aufgabe 2): + // bleibt bewusst UNGEBUNDEN. HEUTE arbeitet sie richtig, weil zu diesem + // Zeitpunkt noch kein Mandant existiert und `username` plattformweit + // eindeutig ist -- eine gebundene Suche waere hier ohnehin nicht + // formulierbar (es gibt noch keinen Mandanten, an den zu binden waere). + // NACH DEM SCHARFSCHALTEN (RLS scharf, WINDOWS #18) liefert dieselbe + // Abfrage fuer JEDEN Administrator `null`, weil ohne gesetzten + // Mandantenkontext keine Zeile der Benutzertabelle sichtbar ist + // (260910-das, Aufgabe 1, `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`). + // Leere wird dann als Abwesenheit gedeutet, die natuerliche + // Folgehandlung ist Anlegen (Schritt weiter unten laeuft), und das + // Anlegen trifft die plattformweite Eindeutigkeit von `username` -- + // siehe die Entschaerfung direkt an der Erstanlage unten. Details: + // docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt + // "Bereich user", (u3) Punkt 1. const exists = await this.prisma.user.findUnique({ where: { username }, }); @@ -64,7 +79,9 @@ export class AdminSeedService implements OnApplicationBootstrap { return; } - // Upsert default tenant + // Upsert default tenant. `Tenant` traegt keinen Zeilenschutz (Aufgabe 1, + // `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`) -- ungebunden lesen + // und schreiben ist hier korrekt, nicht uebersehen. const tenant = await this.prisma.tenant.upsert({ where: { slug: 'default' }, update: {}, @@ -77,19 +94,49 @@ export class AdminSeedService implements OnApplicationBootstrap { // repair below to backfill the membership afterwards. await this.groupsService.ensureDefaultGroup(tenant.id); - // Create Super-Admin user + // Erstanlage des Administrators (260910-das, Befund J-Korrektur, + // Aufgabe 2): GEBUNDEN an die Kennung des unmittelbar zuvor angelegten + // bzw. geholten Mandanten. Die bisherige Klassifikationsbegruendung + // ("es gibt strukturell keinen Mandanten zum Binden") war FALSCH -- der + // Mandant ist an dieser Stelle bereits bekannt (`tenant.id` oben). + // Ungebunden waere dieses Einfuegen nach dem Scharfschalten von der + // Policy abgewiesen worden (Aufgabe 1, + // `user-ungebundenes-einfuegen-abgelehnt`): eine FRISCHE Installation + // haette ihren allerersten Administrator gar nicht anlegen koennen. const passwordHash = await argon2.hash(password); - await this.prisma.user.create({ - data: { - username, - email, - passwordHash, - role: 'SUPER_ADMIN', - tenantId: tenant.id, - mustChangePassword: forceChange, - isActive: true, - }, - }); + const tenantPrisma = forTenant(this.prisma, tenant.id) as any; + try { + await tenantPrisma.user.create({ + data: { + username, + email, + passwordHash, + role: 'SUPER_ADMIN', + tenantId: tenant.id, + mustChangePassword: forceChange, + isActive: true, + }, + }); + } catch (err: any) { + // Entschaerfung der Startsperre (260910-das, Befund I): trifft die + // Erstanlage die plattformweite Eindeutigkeit von username/email + // (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein + // Treffer der vorgeschalteten Pruefung oben ("Administrator existiert + // bereits") -- die Pruefung hat ihn nur wegen der Unsichtbarkeit + // nicht gefunden. Dieser eine Fehlerfall wird deshalb wie der bereits + // vorhandene "existiert bereits"-Zweig behandelt: protokollieren, + // NICHT abbrechen. Das ist KEINE Aufweichung der im Dateikopf + // festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) -- + // JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine, + // an dieser Stelle gleichbedeutende Fall wird ergaenzt. + if (err?.code === 'P2002') { + this.logger.log( + `Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`, + ); + return; + } + throw err; + } this.logger.log( `Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`, @@ -105,6 +152,20 @@ export class AdminSeedService implements OnApplicationBootstrap { * own try/catch and is additionally wrapped as a whole, so a failing * tenant.findMany or a single tenant's ensureDefaultGroup call can never * block the API from starting. + * + * 260910-das, Befund K: dies ist der FUENFTE Fall der + * Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md, + * Abschnitt "Der Hintergrunddienst als Falle") und der bislang EINZIGE, + * der auf BEIDEN Haelften bereits richtig ist -- uebergreifender Treiber + * (`this.prisma.tenant.findMany`, UNGEBUNDEN, korrekt weil `Tenant` + * keinen Zeilenschutz traegt), gebundener Rumpf + * (`groupsService.ensureDefaultGroup(tenant.id)`, seit 260909-jts + * vollstaendig ueber `forTenant()`/`withTenantTransaction()`). Bewusst + * NICHT verschwiegen: der aeussere `try/catch` unten verschluckt jeden + * Fehler des Treibers in eine Protokollzeile -- laeuft die Mandantenliste + * nach dem Scharfschalten aus irgendeinem Grund leer, entsteht keine + * Fehlermeldung, sondern gar keine Ausgabe. Die Reparatur meldet nur, + * wenn sie etwas GETAN hat. */ private async ensureDefaultGroupsForAllTenants() { try { diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 8031870..59c527f 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -97,7 +97,13 @@ export class UserController { @Get(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) { - const user = await this.userService.findById(id); + // 260910-das, Aufgabe 2: UserService.findById() bekommt einen + // Pflicht-Mandanten (siehe user.service.ts). Nur die Signatur wird hier + // nachgezogen, damit die Typpruefung sauber bleibt -- die Rollenlogik + // (insbesondere die uebergreifende SUPER_ADMIN-Sicht ueber + // findByIdForPlatformAdmin()) wird erst in Aufgabe 3 vollstaendig + // verdrahtet. + const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } @@ -156,7 +162,8 @@ export class UserController { @Body() dto: UpdateUserDto, @CurrentUser() currentUser: any, ) { - const user = await this.userService.findById(id); + // 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben. + const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } @@ -174,7 +181,7 @@ export class UserController { throw new ForbiddenException('Cannot assign SUPER_ADMIN role'); } - const updated = await this.userService.update(id, { + const updated = await this.userService.update(currentUser.tenantId, id, { username: dto.username, email: dto.email, password: dto.password, @@ -194,7 +201,8 @@ export class UserController { @Delete(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async remove(@Param('id') id: string, @CurrentUser() currentUser: any) { - const user = await this.userService.findById(id); + // 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben. + const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } @@ -212,7 +220,7 @@ export class UserController { throw new ForbiddenException('Cannot delete users from other tenants'); } - await this.userService.delete(id); + await this.userService.delete(currentUser.tenantId, id); return { message: 'User deleted' }; } diff --git a/apps/api/src/user/user.service.spec.ts b/apps/api/src/user/user.service.spec.ts index da612b6..c4f7e37 100644 --- a/apps/api/src/user/user.service.spec.ts +++ b/apps/api/src/user/user.service.spec.ts @@ -1,74 +1,369 @@ +import { ConflictException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserService } from './user.service'; /** - * UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12, PERM-06). + * UserService — Zwei-Klienten-Nachweis und Linie je Methode (260910-das, + * Aufgabe 2, Befund C). * - * UserService.create ist der einzige Erzeugungspunkt für Benutzer im - * Backend (auch LdapService.upsertMappedUser/importUsersByDn rufen - * ausschließlich hierüber auf, unverändert in diesem Plan). Diese Tests - * decken ausschließlich die neue Standardgruppen-Anbindung ab, mit - * gemocktem PrismaService und gemocktem GroupsService. + * Diese Datei hatte bisher KEINE Attrappe fuer das Bindungshilfsmittel: der + * Prisma-Ersatz war ein nacktes Objekt mit genau einem Eintrag + * (`user.create`), es gab keine `forTenant`-Attrappe. Nach der Umstellung + * waeren die vier bestehenden Testfaelle rot geworden, aber aus dem + * FALSCHEN Grund (das Hilfsmittel bekaeme einen unbrauchbaren Klienten), + * nicht wegen einer vergessenen Bindung. Muster nach + * `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an + * `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper um + * DIESELBEN Maps wie der ungebundene Zugriff — der ungebundene Ersatz + * protokolliert nicht, der gebundene schon. + * + * Der Speicher bildet zusaetzlich die plattformweite Eindeutigkeit von + * `username` nach: ein Einfuegen mit einem bereits vergebenen Benutzernamen + * wirft einen Fehler mit dem Prisma-Fehlercode fuer Eindeutigkeitsverletzungen + * (P2002), UNABHAENGIG davon, welchem Mandanten der bestehende Halter + * gehoert — ohne diese Nachbildung ist die zentrale Aussage dieses Bereichs + * nicht pruefbar. */ -describe('UserService.create — Standardgruppen-Mitgliedschaft (D-11/D-12)', () => { - let prisma: any; - let groupsService: any; - let service: UserService; +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); - const createdUser = { id: 'u1', tenantId: 't1', username: 'alice' }; - const baseData = { - username: 'Alice', - email: 'alice@example.com', - tenantId: 't1', +function makeFakePrisma() { + const users = new Map(); + const tenants = new Map(); + let userCounter = 0; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + + function throwUnique(): never { + const err: any = new Error('Unique constraint failed on the fields: (`username`)'); + err.code = 'P2002'; + throw err; + } + + function throwNotFound(): never { + const err: any = new Error('Record to update/delete not found'); + err.code = 'P2025'; + throw err; + } + + function findByUsernameGlobal(username: string, excludeId?: string) { + return Array.from(users.values()).find( + (u) => u.username === username && u.id !== excludeId, + ); + } + + // Ungebundene (RLS-freie) Grundoperationen — fuer findByUsername() und + // jede andere direkte this.prisma.user.*-Nutzung. + const rawUser = { + findUnique: async ({ where }: any) => { + if (where.username !== undefined) { + return findByUsernameGlobal(where.username) ?? null; + } + if (where.id !== undefined) { + return users.get(where.id) ?? null; + } + return null; + }, + findMany: async ({ where }: any = {}) => { + let rows = Array.from(users.values()); + if (where?.tenantId !== undefined) { + rows = rows.filter((u) => u.tenantId === where.tenantId); + } + return [...rows].sort((a, b) => a.username.localeCompare(b.username)); + }, + create: async ({ data }: any) => { + if (findByUsernameGlobal(data.username)) throwUnique(); + userCounter += 1; + const record = { id: `u-${userCounter}`, createdAt: new Date(), ...data }; + users.set(record.id, record); + return record; + }, + update: async ({ where, data }: any) => { + const existing = users.get(where.id); + if (!existing) throwNotFound(); + if (data.username && findByUsernameGlobal(data.username, existing.id)) throwUnique(); + const record = { ...existing, ...data }; + users.set(where.id, record); + return record; + }, + delete: async ({ where }: any) => { + const existing = users.get(where.id); + if (!existing) throwNotFound(); + users.delete(where.id); + return existing; + }, }; - beforeEach(() => { - vi.clearAllMocks(); - prisma = { - user: { - create: vi.fn().mockResolvedValue(createdUser), + // Gebundene (RLS-simulierende) Fassung: jede Methode filtert zusaetzlich + // auf den Mandantenkontext, GENAU wie die ausgelieferte Policy es tut — + // unabhaengig davon, ob der Aufrufer selbst ein explizites tenantId ins + // where schreibt (UserService tut das bewusst NICHT fuer findById/ + // update/deactivate/delete, siehe Befund G). + function makeScopedUser(tenantId: string) { + return { + findUnique: async (args: any) => { + const row = await rawUser.findUnique(args); + return row && row.tenantId === tenantId ? row : null; + }, + findMany: async (args: any) => { + const rows = await rawUser.findMany(args); + return rows.filter((r: any) => r.tenantId === tenantId); + }, + create: async (args: any) => { + if (args.data.tenantId !== tenantId) { + const err: any = new Error( + 'new row violates row-level security policy for table "User"', + ); + err.code = '42501'; + throw err; + } + return rawUser.create(args); + }, + update: async (args: any) => { + const existing = users.get(args.where.id); + if (!existing || existing.tenantId !== tenantId) throwNotFound(); + return rawUser.update(args); + }, + delete: async (args: any) => { + const existing = users.get(args.where.id); + if (!existing || existing.tenantId !== tenantId) throwNotFound(); + return rawUser.delete(args); }, }; - groupsService = { - addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined), - }; - service = new UserService(prisma, groupsService); - }); + } - it('ruft nach der Benutzeranlage genau einmal GroupsService.addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => { - const result = await service.create(baseData); + const fake: any = { + __seedUser(user: any) { + users.set(user.id, user); + }, + __seedTenant(tenant: any) { + tenants.set(tenant.id, tenant); + }, + user: rawUser, + tenant: { + findMany: async () => Array.from(tenants.values()), + }, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const scoped = makeScopedUser(tenantId); + const wrap = (method: string, fn: (args: any) => any) => { + return async (args: any) => { + boundCallLog.push({ tenantId, model: 'user', method }); + return fn(args); + }; + }; + return { + __isBoundClient: true, + __tenantId: tenantId, + user: { + findUnique: wrap('findUnique', scoped.findUnique), + findMany: wrap('findMany', scoped.findMany), + create: wrap('create', scoped.create), + update: wrap('update', scoped.update), + delete: wrap('delete', scoped.delete), + }, + }; + }, + }; - expect(result).toEqual(createdUser); - expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); - expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', 'u1'); - }); + return fake; +} - it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => { - groupsService.addUserToDefaultGroup.mockResolvedValue(undefined); +function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => 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); +} - const result = await service.create(baseData); +function expectNotBoundCall(prisma: any, model: string, method: string) { + const found = prisma.__boundCallLog.some((c: any) => c.model === model && c.method === method); + expect( + found, + `unerwarteter gebundener Aufruf ${model}.${method} im Protokoll — diese Methode soll UNGEBUNDEN bleiben: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(false); +} - expect(result).toEqual(createdUser); - expect(prisma.user.create).toHaveBeenCalledTimes(1); - expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); - }); +describe('UserService', () => { + describe('create — Standardgruppen-Mitgliedschaft (D-11/D-12) und Bindung', () => { + let prisma: any; + let groupsService: any; + let service: UserService; - it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => { - groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom')); + const baseData = { username: 'Alice', email: 'alice@example.com', tenantId: 't1' }; - await expect(service.create(baseData)).resolves.toEqual(createdUser); - }); + beforeEach(() => { + prisma = makeFakePrisma(); + groupsService = { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) }; + service = new UserService(prisma, groupsService); + }); - it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => { - const result = await service.create(baseData); + it('Test 1: steht mit der uebergebenen Mandantenkennung im Bindungsprotokoll, und ruft nach der Anlage genau einmal addUserToDefaultGroup mit derselben tenantId und der frisch erzeugten userId auf', async () => { + const result = await service.create(baseData); - expect(prisma.user.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ + expectBoundCall(prisma, 't1', 'user', 'create'); + expect(result.tenantId).toBe('t1'); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledWith('t1', result.id); + }); + + it('legt den Benutzer bei einem Mandanten ohne markierte Standardgruppe trotzdem an — addUserToDefaultGroup bleibt folgenlos, die Anlage schlägt nicht fehl', async () => { + const result = await service.create(baseData); + + expect(result.username).toBe('alice'); + expect(groupsService.addUserToDefaultGroup).toHaveBeenCalledTimes(1); + }); + + it('gibt den Benutzer trotz werfendem addUserToDefaultGroup zurück — der Fehler wird protokolliert, nicht propagiert', async () => { + groupsService.addUserToDefaultGroup.mockRejectedValue(new Error('boom')); + + await expect(service.create(baseData)).resolves.toHaveProperty('username', 'alice'); + }); + + it('gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert', async () => { + const result = await service.create(baseData); + + expect(result).toHaveProperty('id'); + expect(result).toMatchObject({ username: 'alice', email: 'alice@example.com', tenantId: 't1', - }), + }); + }); + + it('Test 2: wirft bei einem Benutzernamen, den ein Benutzer eines ANDEREN Mandanten bereits hält, eine verständliche deutsche Konfliktmeldung statt eines durchgereichten Datenbankfehlers — und nennt weder den Halter noch dessen Mandanten', async () => { + prisma.__seedUser({ id: 'existing', username: 'bob', tenantId: 't2' }); + + let caught: any; + try { + await service.create({ username: 'Bob', email: 'bob2@example.com', tenantId: 't1' }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ConflictException); + expect(caught.message).not.toContain('t2'); + expect(caught.message).not.toContain('existing'); + expect(caught.message.toLowerCase()).toMatch(/benutzername|adresse/); + }); + }); + + describe('findById', () => { + let prisma: any; + let service: UserService; + + beforeEach(() => { + prisma = makeFakePrisma(); + prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' }); + service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); + }); + + it('Test 4: steht gebunden im Protokoll und liefert einen Benutzer eines anderen Mandanten NICHT', async () => { + const own = await service.findById('t1', 'u-a'); + expect(own).toMatchObject({ id: 'u-a' }); + expectBoundCall(prisma, 't1', 'user', 'findUnique'); + + const foreign = await service.findById('t1', 'u-b'); + expect(foreign).toBeNull(); + }); + }); + + describe('update', () => { + let prisma: any; + let service: UserService; + + beforeEach(() => { + prisma = makeFakePrisma(); + prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-b', username: 'bob', tenantId: 't2' }); + service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); + }); + + it('Test 3: steht gebunden im Protokoll, und ein Namenswechsel auf einen fremd gehaltenen Benutzernamen wirft dieselbe Art von Konfliktmeldung', async () => { + await expect(service.update('t1', 'u-a', { username: 'Bob' })).rejects.toBeInstanceOf( + ConflictException, + ); + expectBoundCall(prisma, 't1', 'user', 'update'); + }); + }); + + describe('deactivate/delete', () => { + let prisma: any; + let service: UserService; + + beforeEach(() => { + prisma = makeFakePrisma(); + prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); + service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); + }); + + it('Test 5: deactivate steht gebunden im Protokoll', async () => { + await service.deactivate('t1', 'u-a'); + expectBoundCall(prisma, 't1', 'user', 'update'); + }); + + it('Test 5: delete steht gebunden im Protokoll', async () => { + await service.delete('t1', 'u-a'); + expectBoundCall(prisma, 't1', 'user', 'delete'); + }); + }); + + describe('Plattform-Administratorsicht (Befund F)', () => { + let prisma: any; + let service: UserService; + + beforeEach(() => { + prisma = makeFakePrisma(); + prisma.__seedTenant({ id: 't1' }); + prisma.__seedTenant({ id: 't2' }); + prisma.__seedUser({ id: 'u-carol', username: 'carol', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-alice', username: 'alice', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-bob', username: 'bob', tenantId: 't2' }); + service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); + }); + + it('Test 6: liest die Mandanten UNGEBUNDEN und danach je Mandant GEBUNDEN — im Protokoll steht je Mandant genau ein Eintrag, das Ergebnis enthält die Benutzer beider Mandanten in der bisherigen Sortierung nach Benutzername', async () => { + const result = await service.findAllForPlatformAdmin(); + + expect(result.map((u: any) => u.username)).toEqual(['alice', 'bob', 'carol']); + expectBoundCall(prisma, 't1', 'user', 'findMany'); + expectBoundCall(prisma, 't2', 'user', 'findMany'); + + const entriesFor = (tenantId: string) => + prisma.__boundCallLog.filter( + (c: any) => c.tenantId === tenantId && c.model === 'user' && c.method === 'findMany', + ).length; + expect(entriesFor('t1')).toBe(1); + expect(entriesFor('t2')).toBe(1); + }); + + it('Test 7: findet einen Benutzer eines fremden Mandanten über einen GEBUNDENEN Lesezugriff je Mandant — im Protokoll nachweisbar, nicht nur am Ergebnis', async () => { + const found = await service.findByIdForPlatformAdmin('u-bob'); + + expect(found).toMatchObject({ id: 'u-bob', tenantId: 't2' }); + expectBoundCall(prisma, 't2', 'user', 'findUnique'); + }); + + it('liefert null, wenn keine Mandant die Kennung besitzt', async () => { + const found = await service.findByIdForPlatformAdmin('unbekannt'); + expect(found).toBeNull(); + }); + }); + + describe('findByUsername (bewusst UNGEBUNDEN)', () => { + it('Test 8: steht NICHT im Bindungsprotokoll — das Fehlen der Bindung ist hier die bestandene Erwartung, damit niemand sie später als vergessene Bindung "repariert"', async () => { + const prisma = makeFakePrisma(); + prisma.__seedUser({ id: 'u-a', username: 'alice', tenantId: 't1' }); + const service = new UserService(prisma, { addUserToDefaultGroup: vi.fn() } as any); + + const found = await service.findByUsername('Alice'); + + expect(found).toMatchObject({ id: 'u-a' }); + expectNotBoundCall(prisma, 'user', 'findUnique'); }); - expect(result).toHaveProperty('id', 'u1'); }); }); diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index a58bc7b..37d35da 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,8 +1,22 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { ConflictException, Injectable, Logger } from '@nestjs/common'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; +/** + * Bindung an forTenant() (WINDOWS #20 Etappe 2, 260910-das): dieser Bereich + * enthaelt als einziger BEIDE Formen gleichzeitig -- Wege, die binden + * MUESSEN (Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT + * DARF (Nachschlagen auf dem plattformweit eindeutigen Schluessel + * `username`). Der gebundene Klient heisst in jeder Methode `tenantPrisma` + * (Konvention aus `ldap`, `groups`, `dkv`, `auth`). + * + * `findById`/`update`/`deactivate`/`delete` bekommen einen PFLICHT-Mandanten + * als ersten Parameter -- die Steuerungsschicht (`user.controller.ts`) wird + * im selben Commit auf die neue Signatur umgestellt (260910-das, Aufgabe 3), + * damit die Typpruefung nach jeder Aufgabe sauber bleibt. + */ @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); @@ -13,8 +27,24 @@ export class UserService { ) {} /** - * Find user by username. Uses UNSCOPED Prisma (not tenant-scoped) - * because login must work across all tenants. + * Find user by username. Bleibt bewusst UNGEBUNDEN. + * + * Der Anmeldeweg laeuft seit Etappe 1 (260909-eor) ueber die drei + * SECURITY-DEFINER-Funktionen (`auth_lookup_user_by_username` u.a.) und + * beruehrt diese Methode nicht mehr -- gemessen zum Zeitpunkt der + * Umstellung (260910-das, Aufgabe 1, Teil 3): `findByUsername` hatte + * genau EINEN Treffer im gesamten Quelltext, die eigene Definition, kein + * Aufrufer. Sie darf trotzdem NICHT gebunden werden: `username` ist + * plattformweit eindeutig (`@unique`, nicht je Mandant), eine gebundene + * Suche saehe einen fremden Halter nicht mehr, meldete faelschlich + * "frei", und die naechste Handlung des (hypothetischen) Aufrufers liefe + * in einen harten Eindeutigkeitsfehler (Aufgabe 1, + * `user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile` + * und `user-eindeutigkeit-greift-trotz-unsichtbarkeit`). Derselbe Fall + * wie `resolveEmailForWrite` im Bereich `ldap` (260909-ipc, T-IPC-04). + * Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt + * "Bereich user". + * * Usernames are stored lowercase (case-insensitive login) -- normalize * the lookup input to match regardless of how it was typed. */ @@ -25,14 +55,20 @@ export class UserService { } /** - * Find user by ID. + * Find user by ID, gebunden an den uebergebenen Mandanten. Ein Benutzer + * eines anderen Mandanten liefert `null` -- nicht laut, sondern still, + * weil die Policy keine eigene Fehlermeldung fuer "unsichtbar" kennt + * (Aufgabe 1, `user-gebunden-nur-eigener-mandant`). */ - async findById(id: string) { - return this.prisma.user.findUnique({ where: { id } }); + async findById(tenantId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.user.findUnique({ where: { id } }); } /** - * Create a new user with hashed password. + * Create a new user with hashed password. Bindet an die im Datensatz + * uebergebene Mandantenkennung. + * * Username is normalized to lowercase so login is case-insensitive. * * D-11/D-12 (PERM-06): dies ist der EINZIGE Erzeugungspunkt für Benutzer @@ -45,6 +81,18 @@ export class UserService { * (T-15-14): eine gescheiterte Gruppenzuordnung darf weder die * Benutzeranlage noch einen LDAP-Sync-Lauf über hunderte Benutzer * abbrechen. + * + * Eindeutigkeitsverletzung (260910-das, Aufgabe 2): `username`/`email` + * sind plattformweit eindeutig, nicht je Mandant (Schema, keine + * Aenderung in diesem Plan). Ein P2002 wird deshalb HIER uebersetzt, + * nicht bei den Aufrufern -- dies ist der einzige Erzeugungspunkt fuer + * Benutzer im Backend, der AD-Abgleich (`ldap.service.ts`, + * `upsertMappedUser`) laeuft ebenfalls hierueber, und dessen + * Identitaetssuche ist bereits gebunden (seit 260909-ipc). Die Kette aus + * unsichtbarer Zeile, falschem "frei" und hartem Eindeutigkeitsfehler + * endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung + * nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine + * Aussage ueber einen fremden Mandanten waere (T-DAS-08). */ async create(data: { username: string; @@ -57,13 +105,25 @@ export class UserService { ldapDn?: string; }) { const { password, ...rest } = data; - const created = await this.prisma.user.create({ - data: { - ...rest, - username: rest.username.toLowerCase(), - passwordHash: password ? await argon2.hash(password) : null, - }, - }); + const tenantPrisma = forTenant(this.prisma, data.tenantId) as any; + + let created: any; + try { + created = await tenantPrisma.user.create({ + data: { + ...rest, + username: rest.username.toLowerCase(), + passwordHash: password ? await argon2.hash(password) : null, + }, + }); + } catch (err: any) { + if (err?.code === 'P2002') { + throw new ConflictException( + 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', + ); + } + throw err; + } try { await this.groupsService.addUserToDefaultGroup(created.tenantId, created.id); @@ -79,9 +139,13 @@ export class UserService { } /** - * Update user. If password is provided, hash it. + * Update user, gebunden an den uebergebenen Mandanten. If password is + * provided, hash it. Dieselbe Eindeutigkeitsuebersetzung wie `create()`, + * weil auch ein Namens- oder Adresswechsel auf denselben plattformweiten + * Schluessel treffen kann. */ async update( + tenantId: string, id: string, data: { username?: string; @@ -104,26 +168,110 @@ export class UserService { updateData.passwordHash = await argon2.hash(password); } - return this.prisma.user.update({ - where: { id }, - data: updateData, - }); + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + try { + return await tenantPrisma.user.update({ + where: { id }, + data: updateData, + }); + } catch (err: any) { + if (err?.code === 'P2002') { + throw new ConflictException( + 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', + ); + } + throw err; + } } /** - * Deactivate a user (soft delete). + * Deactivate a user (soft delete), gebunden an den uebergebenen + * Mandanten. */ - async deactivate(id: string) { - return this.prisma.user.update({ + async deactivate(tenantId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.user.update({ where: { id }, data: { isActive: false }, }); } /** - * Hard delete a user. + * Hard delete a user, gebunden an den uebergebenen Mandanten. */ - async delete(id: string) { - return this.prisma.user.delete({ where: { id } }); + async delete(tenantId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.user.delete({ where: { id } }); + } + + /** + * Plattform-Administratorsicht (Befund F, 260910-das): liest die + * Benutzer ALLER Mandanten als Schleife mit je EINEM gebundenen + * Lesezugriff im Rumpf -- dieselbe Form, die + * `AdminSeedService.ensureDefaultGroupsForAllTenants()` beim Start + * bereits benutzt (der fuenfte, bislang einzige bereits vollstaendig + * richtige Fall der Hintergrunddienst-Falle). Diese uebergreifende Sicht + * ist die bestehende, GEWOLLTE Funktion der obersten Rolle + * (`SUPER_ADMIN`) und darf deshalb NICHT an den Mandanten des Aufrufers + * gebunden werden -- das waere eine stille Funktionsminderung. Sie darf + * aber auch nicht ungebunden bleiben, weil sie nach dem Scharfschalten + * (RLS scharf) sonst gar nichts mehr liefert (Aufgabe 1, + * `user-ungebunden-null-zeilen`). + * + * Der Schleifentreiber (`this.prisma.tenant.findMany`) liest UNGEBUNDEN + * und DARF DAS: `Tenant` traegt keinen Zeilenschutz (Aufgabe 1, + * `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`, gemessen, nicht + * behauptet). + * + * Die Sortierung nach Benutzername wird NACH dem Zusammenfuehren + * hergestellt, weil je Mandant sortierte Teilmengen aneinandergehaengt + * nicht sortiert sind -- die eine Stelle, an der die Umstellung das + * Ergebnis verfaelschen koennte. + */ + async findAllForPlatformAdmin() { + const tenants = await this.prisma.tenant.findMany({ select: { id: true } }); + + const results: any[] = []; + for (const tenant of tenants) { + const tenantPrisma = forTenant(this.prisma, tenant.id) as any; + const users = await tenantPrisma.user.findMany({ + where: { tenantId: tenant.id }, + select: { + id: true, + username: true, + email: true, + displayName: true, + role: true, + isActive: true, + tenantId: true, + createdAt: true, + lastLoginAt: true, + }, + }); + results.push(...users); + } + + return results.sort((a, b) => a.username.localeCompare(b.username)); + } + + /** + * Loest eine Benutzerkennung fuer den Plattform-Administrator auf, indem + * je Mandant GEBUNDEN gesucht wird und beim ersten Treffer zurueckgekehrt + * wird. Derselbe Kopfkommentar-Grund wie `findAllForPlatformAdmin()` + * oben -- gehoert zusammen, weil beide die uebergreifende Sicht der + * obersten Rolle tragen. + */ + async findByIdForPlatformAdmin(id: string) { + const tenants = await this.prisma.tenant.findMany({ select: { id: true } }); + + for (const tenant of tenants) { + const tenantPrisma = forTenant(this.prisma, tenant.id) as any; + const user = await tenantPrisma.user.findUnique({ where: { id } }); + if (user) { + return user; + } + } + + return null; } } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 4fcc153..3a0fa01 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -288,9 +288,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. | | apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. | | apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. | -| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | ungebunden | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. | -| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. | -| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | ungebunden | Dieselbe Begründung. | +| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | gemischt | ZWISCHENSTAND nach Aufgabe 2 (260910-das): die Erstanlage-Pruefung bleibt bewusst ungebunden, die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten (Befund J). Die Klassenkorrektur auf `beides` samt Begruendung folgt in Aufgabe 3. | +| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. Wird in Aufgabe 3 (260910-das) gebunden. | +| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). | +| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | gemischt | ZWISCHENSTAND nach Aufgabe 2 (260910-das): `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen seither ueber `forTenant()`; ausschliesslich `findByUsername` bleibt bewusst ungebunden (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`). Die Klassenkorrektur auf `beides` samt Begruendung folgt in Aufgabe 3. | ## Was diese Etappe NICHT entscheidet