Files
tessera-ctl/apps/api/src/groups/groups.service.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

521 lines
20 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
/**
* Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit
* zwischen ensureDefaultGroup() (legt sie an, falls der Mandant noch keine
* Gruppe hat) und reassignDefaultBeforeDelete() (bevorzugtes Handoff-Ziel,
* D-06) — zwei getrennte Literale würden bei einer Umbenennung
* auseinanderlaufen.
*/
export const DEFAULT_GROUP_NAME = 'Alle Benutzer';
/**
* Service für Gruppen-CRUD, Mitgliederverwaltung und die automatische
* Standardgruppen-Mitgliedschaft (PERM-01/PERM-06, D-11/D-12/D-13/D-17/D-19).
*
* Zentrale Regel: jede Lookup-Query mit einer Gruppen-ID filtert zusätzlich
* auf tenantId, nach dem Ownership-Check-Muster aus
* DashboardService.removeWidget — eine ID aus einem fremden Mandanten
* liefert nie einen Treffer, sondern NotFoundException. RLS (aus 15-01) ist
* das zweite Netz, nicht der primäre Schutz (T-15-02/T-15-12).
*
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-jts): jede Methode
* erzeugt ihren Mandantenkontext aus dem uebergebenen Mandanten und fuehrt
* ihre Abfragen darauf aus, wie im Bereich `ldap` (ldap.service.ts,
* ldap-config.service.ts) vorgemacht. Gebundene Clients werden NICHT
* zwischen Methoden weitergereicht — jede Methode erzeugt ihren eigenen.
*
* Die beiden mehrschrittigen Aenderungen (Standardmarkierung umsetzen in
* update(); vor einer Loeschung verschieben in reassignDefaultBeforeDelete())
* sowie der Aufbau der Standardgruppe (ensureDefaultGroup()) laufen ueber
* `withTenantTransaction()` statt ueber die Array-Form von `$transaction`
* auf einem gebundenen Client — gemessen in Aufgabe 1 (260909-jts):
* die Array-Form auf dem gebundenen Client verteilt jede enthaltene
* Modell-Operation auf eine EIGENE Teiltransaktion (siehe
* prisma-tenant.extension.ts), `withTenantTransaction()` ist die einzige
* der drei gemessenen Formen, die sowohl die Einzelmessung als auch eine
* Lastprobe unter echter Nebenlaeufigkeit bestand.
*/
@Injectable()
export class GroupsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Gruppen eines Mandanten, alphabetisch nach Name, jeweils mit der
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
*/
async listForTenant(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
// Explizit als any[] annotiert (nicht nur der Rueckgabewert von await):
// ohne diese Array-Verankerung inferiert TypeScript den Rueckgabewert
// dieser Methode als bloss `any` statt `any[]`, und Aufrufer, die auf
// dem Ergebnis `.find()` aufrufen, wuerden TS7006 (impliziter any-Typ
// im Callback-Parameter) melden, obwohl der gebundene Client bewusst
// `any` ist (siehe forTenant()-Aufrufe in dieser Datei).
const groups: any[] = await tenantPrisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { memberships: true } } },
});
return groups.map((g: any) => ({
id: g.id,
tenantId: g.tenantId,
name: g.name,
internalName: g.internalName,
ldapDn: g.ldapDn,
isDefault: g.isDefault,
createdAt: g.createdAt,
updatedAt: g.updatedAt,
memberCount: g._count.memberships,
}));
}
/**
* Legt eine Gruppe im Mandanten an. Führender/abschließender Leerraum
* wird entfernt; der Name selbst wird weder normalisiert noch
* kleingeschrieben (bewusst anders als Benutzernamen). P2002 (Unique-
* Verletzung auf (tenantId, name)) wird in ConflictException übersetzt —
* der Unique-Index aus 15-01 ist der eigentliche Durchsetzungspunkt.
*/
async create(tenantId: string, data: { name: string }) {
const name = data.name?.trim();
if (!name) {
throw new BadRequestException('Gruppenname darf nicht leer sein');
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await tenantPrisma.group.create({
data: { tenantId, name },
});
} catch (err: any) {
if (err?.code === 'P2002') {
throw new ConflictException(
`Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`,
);
}
throw err;
}
}
/**
* Lädt eine Gruppe, mandantengescoped. Eine ID aus einem fremden
* Mandanten liefert NotFoundException statt eines Treffers.
*/
private async findOwned(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const group = await tenantPrisma.group.findFirst({
where: { id, tenantId },
});
if (!group) {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
}
return group;
}
/**
* Aktualisiert Name, Standardmarkierung und/oder internen Anzeigenamen.
*
* isDefault:true läuft über `withTenantTransaction()`: zuerst updateMany
* auf alle Gruppen des Mandanten mit isDefault:false, dann update der
* Zielgruppe auf true (D-13) — beide Schritte auf demselben gebundenen
* Transaktionsparameter, gemessen in Aufgabe 1 als tragfaehige Form. Der
* partielle Unique-Index Group_one_default_per_tenant aus 15-01 ist das
* Sicherheitsnetz gegen parallele Aufrufe, die Transaktion ist der
* normale Pfad. isDefault:false schaltet die Markierung nur an dieser
* einen Gruppe ab, ohne sie irgendwo anders zu setzen.
*
* Namenssperre (D-03/D-07): trägt die geladene Gruppe einen gesetzten
* ldapObjectGuid ODER ldapDn, ist sie aus dem Verzeichnis importiert —
* ein `name` im Request wird dann mit BadRequestException abgelehnt,
* BEVOR die Leerstring-Prüfung läuft. Das ldapDn-Kriterium deckt eine
* Alt-Bindung aus Plan 15-06 ab, die den neuen Rekonziliations-Schritt
* (syncBoundGroupsForTenant, Legacy-Backfill) noch nicht durchlaufen hat
* und deshalb noch keinen ldapObjectGuid trägt — ohne diese zweite
* Bedingung wäre die Sperre bis zum ersten Sync-Lauf nur eine
* UI-Konvention (WR-01, 16-REVIEW.md). Das ist eine Backend-Invariante,
* kein UI-Feld-Disable: ein direkter API-Aufruf kommt an ihr nicht
* vorbei.
*
* internalName (D-04) läuft unabhängig von dieser Sperre — jede Gruppe,
* importiert oder lokal, darf ihn setzen. undefined lässt die Spalte
* unangetastet, null oder ein leerer/nur-Leerzeichen-String normalisiert
* auf null (nie ein leerer Anzeigename), ein nicht-leerer String wird
* getrimmt gespeichert.
*/
async update(
tenantId: string,
id: string,
data: { name?: string; isDefault?: boolean; internalName?: string | null },
) {
const existing = await this.findOwned(tenantId, id);
const updateData: {
name?: string;
internalName?: string | null;
isDefault?: boolean;
} = {};
if (data.name !== undefined) {
if (existing.ldapObjectGuid || existing.ldapDn) {
throw new BadRequestException(
'Der Name einer aus dem Verzeichnis übernommenen Gruppe wird dort gepflegt und kann hier nicht geändert werden',
);
}
const trimmed = data.name.trim();
if (!trimmed) {
throw new BadRequestException('Gruppenname darf nicht leer sein');
}
updateData.name = trimmed;
}
if (data.internalName !== undefined) {
if (data.internalName === null) {
updateData.internalName = null;
} else {
const trimmed = data.internalName.trim();
updateData.internalName = trimmed === '' ? null : trimmed;
}
}
try {
if (data.isDefault === true) {
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
});
return tx.group.update({
where: { id },
data: { ...updateData, isDefault: true },
});
});
return updated;
}
if (data.isDefault === false) {
updateData.isDefault = false;
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return await tenantPrisma.group.update({
where: { id },
data: updateData,
});
} catch (err: any) {
if (err?.code === 'P2002') {
throw new ConflictException(
`Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
);
}
throw err;
}
}
/**
* Zahlenmaterial für den Löschdialog (D-17): Mitgliederzahl und Anzahl
* der Modul-Freigaben dieser Gruppe.
*/
async getImpact(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [memberCount, grantCount] = await Promise.all([
tenantPrisma.groupMembership.count({ where: { groupId: id } }),
tenantPrisma.moduleGrant.count({ where: { groupId: id } }),
]);
return { memberCount, grantCount };
}
/**
* Löscht eine Gruppe. Mitgliedschaften und Grants verschwinden über die
* in 15-01 definierten onDelete:Cascade-Regeln — kein zusätzliches
* anwendungsseitiges Aufräumen, sonst gäbe es zwei Wahrheiten über das
* Aufräumverhalten. Ein zweiter Aufruf auf dieselbe ID (z.B. bei zwei
* gleichzeitigen DELETE-Anfragen) wirft NotFoundException statt eines
* unbehandelten P2025-Fehlers.
*/
async remove(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await tenantPrisma.group.delete({ where: { id } });
} catch (err: any) {
if (err?.code === 'P2025') {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
}
throw err;
}
}
/**
* Mitglieder einer Gruppe inkl. Kern-Benutzerdaten, für die Anzeige im
* Gruppen-Detail (D-14).
*/
async listMembers(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.groupMembership.findMany({
where: { groupId: id },
include: {
user: {
select: { id: true, username: true, displayName: true, email: true },
},
},
orderBy: { createdAt: 'asc' },
});
}
/**
* Fügt Mitglieder manuell hinzu (source: MANUAL). Eine userId eines
* fremden Mandanten wird übersprungen und nicht aufgenommen (T-15-12,
* Cross-Tenant-Grant-Injection-Schutz). Bereits vorhandene Mitgliedschaften
* sind über skipDuplicates gegen @@unique([groupId, userId]) folgenlos.
*/
async addMembers(tenantId: string, id: string, userIds: string[]) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const validUsers = await tenantPrisma.user.findMany({
where: { id: { in: userIds }, tenantId },
select: { id: true },
});
const validIds = validUsers.map((u: any) => u.id);
if (validIds.length === 0) {
return { added: 0 };
}
const result = await tenantPrisma.groupMembership.createMany({
data: validIds.map((userId: string) => ({
groupId: id,
userId,
source: MembershipSource.MANUAL,
})),
skipDuplicates: true,
});
return { added: result.count };
}
/**
* Entfernt ein Mitglied — ausschließlich Mitgliedschaften mit
* source: MANUAL (D-19: eine über AD gesteuerte Mitgliedschaft entfernt
* ausschließlich der Sync, nie diese Route). Für ein nicht vorhandenes
* Mitglied folgenlos, wirft nicht.
*/
async removeMember(tenantId: string, id: string, userId: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
await tenantPrisma.groupMembership.deleteMany({
where: { groupId: id, userId, source: MembershipSource.MANUAL },
});
}
/**
* Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her,
* den die drei Backfill-INSERTs der Migration
* 20260804130130_add_groups_and_module_grants pro Mandant herstellen:
* eine Gruppe DEFAULT_GROUP_NAME (isDefault:true), alle Bestandsbenutzer als
* MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer:
* TenantService.create (frischer Mandant) und
* AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur
* für Installationen, die die Migration ohne Mandanten durchlaufen haben).
*
* Der Wächter prüft AUSSCHLIESSLICH auf group.count === 0 — niemals auf
* das Fehlen der isDefault-Markierung. D-13 erlaubt dem Admin
* ausdrücklich, die Markierung abzuhängen oder auf eine andere Gruppe
* umzuhängen; ein Mandant mit mindestens einer Gruppe hat diese
* Entscheidung bereits getroffen und wird hier nie wieder angefasst.
* Rückgabe null bedeutet in jedem Fall "nichts zu tun".
*
* Zaehler UND Transaktion sind BEIDE ueber denselben Mandanten gebunden
* (T-JTS-05, 260909-jts): der Waechter ist umgekehrt gepolt — ein zu
* kleines Leseergebnis wuerde hier zu ZU VIEL Schreiben fuehren (eine
* zweite Standardgruppe samt Mitgliedschaften ALLER Benutzer und
* Freigaben ALLER aktiven Module). Zaehler und Schreibteil duerfen
* deshalb nie unterschiedlich gebunden sein.
*
* Race-Sicherheit: zwei gleichzeitige Aufrufe (z.B. Startup-Reparatur und
* eine parallele Mandanten-Anlage) können beide group.count === 0 lesen.
* Der partielle Unique-Index Group_one_default_per_tenant (15-01) bleibt
* der eigentliche Durchsetzungspunkt; hier wird nur der resultierende
* P2002 des Verlierers abgefangen und in null übersetzt, statt ihn zu
* propagieren.
*/
async ensureDefaultGroup(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existingCount = await tenantPrisma.group.count({ where: { tenantId } });
if (existingCount > 0) {
return null;
}
try {
return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
const group = await tx.group.create({
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
});
const users = await tx.user.findMany({
where: { tenantId },
select: { id: true },
});
if (users.length > 0) {
await tx.groupMembership.createMany({
data: users.map((u: any) => ({
groupId: group.id,
userId: u.id,
source: MembershipSource.MANUAL,
})),
skipDuplicates: true,
});
}
const activations = await tx.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
select: { moduleId: true },
});
if (activations.length > 0) {
await tx.moduleGrant.createMany({
data: activations.map((a: any) => ({
tenantId,
moduleId: a.moduleId,
groupId: group.id,
userId: null,
})),
skipDuplicates: true,
});
}
return group;
});
} catch (err: any) {
if (err?.code === 'P2002') {
return null;
}
throw err;
}
}
/**
* Verschiebt die Standardmarkierung weg von der übergebenen Gruppe, BEVOR
* der Aufrufer sie löscht (D-06). Löscht selbst nichts — meldet
* ausschließlich per Rückgabewert, ob sie etwas verschoben hat.
*
* Bewusst NICHT findOwned(): dessen NotFoundException ist für HTTP
* gebaut und würde einen Batch-Sync-Lauf (Plan 16-03) abbrechen. Eine
* Gruppen-ID aus einem fremden Mandanten oder eine nicht markierte
* Gruppe ist stattdessen ein folgenloses No-Op mit Rückgabe false.
*
* Zielauswahl: zuerst DEFAULT_GROUP_NAME (falls im Mandanten vorhanden
* und nicht die zu löschende Gruppe selbst), sonst die älteste andere
* Gruppe (orderBy createdAt asc) — dieser Determinismus garantiert, dass
* zwei Läufe über denselben Bestand dieselbe Gruppe wählen. Existiert
* keine andere Gruppe, gibt die Methode false zurück; der Aufrufer ruft
* danach ensureDefaultGroup(tenantId), um den Mandanten neu aufzubauen.
*
* Die drei Lesezugriffe UND die zweischrittige Änderung (updateMany auf
* isDefault:false, dann update der Zielgruppe auf isDefault:true) laufen
* auf demselben gebundenen Mandanten — die Änderung über
* `withTenantTransaction()` (260909-jts, Aufgabe 1/2), dasselbe Muster
* wie bei update()'s isDefault:true-Zweig. Der partielle Unique-Index
* Group_one_default_per_tenant (15-01) bleibt der eigentliche
* Durchsetzungspunkt; ein daraus resultierender P2002 wird als "hat sich
* schon jemand anderes gekümmert" behandelt und liefert false statt zu
* werfen — exakt das Muster aus ensureDefaultGroup().
*/
async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group?.isDefault) {
return false;
}
const target =
(await tenantPrisma.group.findFirst({
where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME },
})) ??
(await tenantPrisma.group.findFirst({
where: { tenantId, id: { not: groupId } },
orderBy: { createdAt: 'asc' },
}));
if (!target) {
return false;
}
try {
await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
});
await tx.group.update({
where: { id: target.id },
data: { isDefault: true },
});
});
return true;
} catch (err: any) {
if (err?.code === 'P2002') {
return false;
}
throw err;
}
}
/**
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,
* tut die Methode nichts und wirft nicht — wird von UserService.create
* aufgerufen (Plan 15-02 Task 2), muss deshalb aus GroupsModule
* exportiert sein.
*
* Prüft zusätzlich, dass der Zielbenutzer zu DIESEM Mandanten gehört
* (T-JTS-02, 260909-jts/260910-jab): die Policy auf GroupMembership prüfte
* bis 260910-jab ausschließlich die Gruppenseite
* (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`) — die
* Benutzerseite NICHT (Befund E, gemessen in Aufgabe 1 von 260909-jts).
* Seit 20260910120000_rls_widen_membership_grant_and_platform_read prüft
* die Datenbankregel selbst BEIDE Seiten — diese Anwendungsprüfung bleibt
* trotzdem bestehen: der Schalter ist weiterhin aus (#18), die
* Datenbankregel wirkt heute nicht. Nach dem Vorbild von addMembers() zwei
* Methoden höher: Zielbenutzer auf den Mandanten filtern, bei keinem
* Treffer folgenlos zurückkehren statt zu werfen.
*/
async addUserToDefaultGroup(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const defaultGroup = await tenantPrisma.group.findFirst({
where: { tenantId, isDefault: true },
});
if (!defaultGroup) {
return;
}
const targetUser = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!targetUser) {
return;
}
await tenantPrisma.groupMembership.createMany({
data: [{ groupId: defaultGroup.id, userId, source: MembershipSource.MANUAL }],
skipDuplicates: true,
});
}
}