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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 10:27:14 +02:00
parent b848ba6baa
commit 888f66003c
7 changed files with 714 additions and 118 deletions
+173 -25
View File
@@ -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;
}
}