Files
tessera-ctl/apps/api/src/user/user.service.ts
T
schalli 32441d77c7
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00

321 lines
12 KiB
TypeScript

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<User, keyof typeof ...>` 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<User, keyof typeof PLATFORM_USER_SELECT>;
/**
* 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<UpdateUserInput, 'password'> & {
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;
}
}