3892c5f3c6
- auth.service.ts:393: (response as any).cookie war schlicht ueberfluessig.
response ist in derselben Signatur bereits Response aus express, die
Schwesterstelle :190 kommt ohne Zusicherung aus. Ersatzlos entfernt.
- calendar.service.ts: das lokal gebaute data-Objekt traegt jetzt
Prisma.CalendarSourceUncheckedCreateInput bzw. ...UncheckedUpdateInput
statt Record<string, unknown> plus Zusicherung. Damit fallen beide
`data as any` weg, ohne dass ein Feld behauptet wird.
- user.service.ts: `let created: any` -> User (die Zuweisung steht im try,
der catch endet ausnahmslos mit throw). `const updateData: any` wird aus
der Signatur hergeleitet - Omit<UpdateUserInput, 'password'> plus dem
daraus berechneten passwordHash; die Parameterform ist dafuer als
UpdateUserInput benannt und nicht neu erfunden. `const results: any[]`
wird Pick<User, keyof typeof PLATFORM_USER_SELECT>[], die Spaltenauswahl
steht als Konstante daneben.
- tenant.controller.ts:69: Elementtyp aus dem hergeleitet, was die Schleife
hineinlegt (fuenf Tenant-Spalten plus userCount).
BEFUND 3 (D-03, gemeldet, kein Verhalten betroffen) Die naheliegende
Prisma-Schreibweise Prisma.UserGetPayload<{ select: typeof X }> laesst
rls-access-inventory.spec.ts rot werden: der Erkenner zaehlt JEDE
select:-Angabe ausserhalb eines erkannten Modellaufrufs als Verstoss und
unterscheidet Typposition nicht von Aufrufposition. Gemessen beim ersten
Versuch. Der Erkenner ist die Mandantenkontrolle (T-M34-03) und wurde
NICHT aufgeweicht - stattdessen leitet der Zeilentyp ueber Pick<User, ...>
her, was ohne das Wort select auskommt. Begruendung steht am Typ.
noExplicitAny in apps/api/src: 38 -> 31. type-check 4/4, lint 5/5 (0
error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30.
noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
310 lines
12 KiB
TypeScript
310 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 { 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,
|
|
} 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).
|
|
*/
|
|
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,
|
|
},
|
|
});
|
|
} 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;
|
|
}
|
|
}
|