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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user