import { ConflictException, Injectable, Logger } from '@nestjs/common'; import * as argon2 from 'argon2'; import type { User } from '@prisma/client'; import { GroupsService } from '../groups/groups.service'; import { getRunningRelease } from '../health/app-version'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { prismaErrorCode } from '../prisma/prisma-error'; 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. */ /** * Spaltenauswahl der plattformweiten Benutzerliste. Als eigene Konstante, * damit der Elementtyp der Sammelliste unten mit `Prisma.UserGetPayload` * aus GENAU dieser Auswahl hergeleitet wird — eine zweite Beschreibung * derselben Felder waere eine Behauptung, die beim naechsten Feld * auseinanderlaeuft. */ const PLATFORM_USER_SELECT = { id: true, username: true, email: true, displayName: true, role: true, isActive: true, tenantId: true, createdAt: true, lastLoginAt: true, welcomeMailSentAt: true, } as const; /** * Elementtyp der Sammelliste, aus PLATFORM_USER_SELECT hergeleitet statt * daneben beschrieben. Bewusst `Pick` und NICHT * `Prisma.UserGetPayload<{ select: ... }>`: die zweite Form traegt das Wort * select in eine Typangabe, und der Erkenner in rls-access-inventory.spec.ts * zaehlt jede select-Angabe ausserhalb eines Modellaufrufs als Verstoss * (gemessen, 260921-m34 Aufgabe 3b). Der Erkenner ist die Mandantenkontrolle * und wird nicht fuer eine Typschreibweise aufgeweicht (T-M34-03). */ type PlatformUserRow = Pick; /** * Felder, die `UserService.update()` entgegennimmt. Als eigener Typ, damit * das intern zusammengebaute `updateData` unten daraus abgeleitet werden * kann statt daneben noch einmal von Hand beschrieben zu werden. */ interface UpdateUserInput { username?: string; email?: string; password?: string; displayName?: string; role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; isActive?: boolean; mustChangePassword?: boolean; } @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( private prisma: PrismaService, private readonly groupsService: GroupsService, ) {} /** * 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. */ async findByUsername(username: string) { return this.prisma.user.findUnique({ where: { username: username.toLowerCase() }, }); } /** * 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(tenantId: string, id: string) { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.user.findUnique({ where: { id } }); } /** * 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 * im gesamten Backend — sowohl der Admin-UserController als auch * LdapService.upsertMappedUser und LdapService.importUsersByDn erzeugen * Benutzer ausschließlich hierüber. Nach der Anlage wird deshalb genau * an dieser einen Stelle die Mitgliedschaft in der markierten * Standardgruppe des Mandanten hergestellt, statt die Regel in jedem * Aufrufer zu wiederholen. Der Aufruf liegt in try/catch mit Logger * (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). * * "Was ist neu"-Fenster (quick-260925-bow, D-04): neue Benutzer (Admin- * Anlage, AD-Abgleich und AD-Import) bekommen hier die laufende * freigegebene Version (`getRunningRelease()`) als * `lastSeenReleaseVersion` eingetragen, damit sie kein Fenster mit * Aenderungen aus der Zeit vor ihrem Konto sehen; auf Staenden ohne * freigegebene Version (`dev`) `null`. Der Wert ist eine Eigenschaft des * Servers, kein Parameter dieser Methode. */ async create(data: { username: string; email?: string; password?: string; displayName?: string; role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; tenantId: string; mustChangePassword?: boolean; ldapDn?: string; }) { const { password, ...rest } = data; const tenantPrisma = forTenant(this.prisma, data.tenantId); // Der Rueckgabewert von user.create() ist das vollstaendige User-Modell. // Die Zuweisung steht im try, der catch endet ausnahmslos mit throw — // nach dem Block ist `created` deshalb belegt, ohne Behauptung. let created: User; try { created = await tenantPrisma.user.create({ data: { ...rest, username: rest.username.toLowerCase(), passwordHash: password ? await argon2.hash(password) : null, lastSeenReleaseVersion: getRunningRelease(), }, }); } catch (err: unknown) { if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', ); } throw err; } try { await this.groupsService.addUserToDefaultGroup(created.tenantId, created.id); } catch (err) { this.logger.error( `Standardgruppen-Zuordnung fehlgeschlagen für Benutzer '${created.id}': ${ err instanceof Error ? err.message : String(err) }`, ); } return created; } /** * 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: UpdateUserInput) { const { password, ...rest } = data; // Aus der Signatur hergeleitet: alles ausser `password`, dafuer der // daraus berechnete `passwordHash`. Nichts erfunden, nichts weggelassen. const updateData: Omit & { passwordHash?: string; } = { ...rest }; if (updateData.username) { updateData.username = updateData.username.toLowerCase(); } if (password) { updateData.passwordHash = await argon2.hash(password); } const tenantPrisma = forTenant(this.prisma, tenantId); try { return await tenantPrisma.user.update({ where: { id }, data: updateData, }); } catch (err: unknown) { if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', ); } throw err; } } /** * Deactivate a user (soft delete), gebunden an den uebergebenen * Mandanten. */ async deactivate(tenantId: string, id: string) { const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma.user.update({ where: { id }, data: { isActive: false }, }); } /** * Hard delete a user, gebunden an den uebergebenen Mandanten. */ async delete(tenantId: string, id: string) { const tenantPrisma = forTenant(this.prisma, tenantId); 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: PlatformUserRow[] = []; for (const tenant of tenants) { const tenantPrisma = forTenant(this.prisma, tenant.id); const users = await tenantPrisma.user.findMany({ where: { tenantId: tenant.id }, select: PLATFORM_USER_SELECT, }); 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); const user = await tenantPrisma.user.findUnique({ where: { id } }); if (user) { return user; } } return null; } }