feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn) — die interaktive Transaktion auf dem UNgebundenen Client mit set_config als erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab). rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion (zwei Formen: direkter Empfaenger.$transaction(async...) und das neue Hilfsmittel withTenantTransaction(...)) — macht das Paar (groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang keine Pruefung dieses Projekts je gesehen hat. groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen (update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup()) laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup() prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite. groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen) und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen. Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
} 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
|
||||
@@ -25,6 +26,23 @@ export const DEFAULT_GROUP_NAME = 'Alle Benutzer';
|
||||
* 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 {
|
||||
@@ -35,13 +53,20 @@ export class GroupsService {
|
||||
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
|
||||
*/
|
||||
async listForTenant(tenantId: string) {
|
||||
const groups = await this.prisma.group.findMany({
|
||||
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) => ({
|
||||
return groups.map((g: any) => ({
|
||||
id: g.id,
|
||||
tenantId: g.tenantId,
|
||||
name: g.name,
|
||||
@@ -67,8 +92,9 @@ export class GroupsService {
|
||||
throw new BadRequestException('Gruppenname darf nicht leer sein');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
try {
|
||||
return await this.prisma.group.create({
|
||||
return await tenantPrisma.group.create({
|
||||
data: { tenantId, name },
|
||||
});
|
||||
} catch (err: any) {
|
||||
@@ -86,7 +112,8 @@ export class GroupsService {
|
||||
* Mandanten liefert NotFoundException statt eines Treffers.
|
||||
*/
|
||||
private async findOwned(tenantId: string, id: string) {
|
||||
const group = await this.prisma.group.findFirst({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!group) {
|
||||
@@ -98,13 +125,14 @@ export class GroupsService {
|
||||
/**
|
||||
* Aktualisiert Name, Standardmarkierung und/oder internen Anzeigenamen.
|
||||
*
|
||||
* isDefault:true läuft in einer Transaktion: zuerst updateMany auf alle
|
||||
* Gruppen des Mandanten mit isDefault:false, dann update der Zielgruppe
|
||||
* auf true (D-13). 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.
|
||||
* 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 —
|
||||
@@ -160,16 +188,16 @@ export class GroupsService {
|
||||
|
||||
try {
|
||||
if (data.isDefault === true) {
|
||||
const [, updated] = await this.prisma.$transaction([
|
||||
this.prisma.group.updateMany({
|
||||
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
await tx.group.updateMany({
|
||||
where: { tenantId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
this.prisma.group.update({
|
||||
});
|
||||
return tx.group.update({
|
||||
where: { id },
|
||||
data: { ...updateData, isDefault: true },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -177,7 +205,8 @@ export class GroupsService {
|
||||
updateData.isDefault = false;
|
||||
}
|
||||
|
||||
return await this.prisma.group.update({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return await tenantPrisma.group.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
@@ -198,9 +227,10 @@ export class GroupsService {
|
||||
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([
|
||||
this.prisma.groupMembership.count({ where: { groupId: id } }),
|
||||
this.prisma.moduleGrant.count({ where: { groupId: id } }),
|
||||
tenantPrisma.groupMembership.count({ where: { groupId: id } }),
|
||||
tenantPrisma.moduleGrant.count({ where: { groupId: id } }),
|
||||
]);
|
||||
|
||||
return { memberCount, grantCount };
|
||||
@@ -217,8 +247,9 @@ export class GroupsService {
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
try {
|
||||
return await this.prisma.group.delete({ where: { id } });
|
||||
return await tenantPrisma.group.delete({ where: { id } });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2025') {
|
||||
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
|
||||
@@ -234,7 +265,8 @@ export class GroupsService {
|
||||
async listMembers(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
return this.prisma.groupMembership.findMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
return tenantPrisma.groupMembership.findMany({
|
||||
where: { groupId: id },
|
||||
include: {
|
||||
user: {
|
||||
@@ -254,17 +286,18 @@ export class GroupsService {
|
||||
async addMembers(tenantId: string, id: string, userIds: string[]) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const validUsers = await this.prisma.user.findMany({
|
||||
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) => u.id);
|
||||
const validIds = validUsers.map((u: any) => u.id);
|
||||
if (validIds.length === 0) {
|
||||
return { added: 0 };
|
||||
}
|
||||
|
||||
const result = await this.prisma.groupMembership.createMany({
|
||||
data: validIds.map((userId) => ({
|
||||
const result = await tenantPrisma.groupMembership.createMany({
|
||||
data: validIds.map((userId: string) => ({
|
||||
groupId: id,
|
||||
userId,
|
||||
source: MembershipSource.MANUAL,
|
||||
@@ -283,7 +316,8 @@ export class GroupsService {
|
||||
async removeMember(tenantId: string, id: string, userId: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
await this.prisma.groupMembership.deleteMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
await tenantPrisma.groupMembership.deleteMany({
|
||||
where: { groupId: id, userId, source: MembershipSource.MANUAL },
|
||||
});
|
||||
}
|
||||
@@ -305,6 +339,13 @@ export class GroupsService {
|
||||
* 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
|
||||
@@ -313,13 +354,14 @@ export class GroupsService {
|
||||
* propagieren.
|
||||
*/
|
||||
async ensureDefaultGroup(tenantId: string) {
|
||||
const existingCount = await this.prisma.group.count({ where: { tenantId } });
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const existingCount = await tenantPrisma.group.count({ where: { tenantId } });
|
||||
if (existingCount > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
const group = await tx.group.create({
|
||||
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
|
||||
});
|
||||
@@ -330,7 +372,7 @@ export class GroupsService {
|
||||
});
|
||||
if (users.length > 0) {
|
||||
await tx.groupMembership.createMany({
|
||||
data: users.map((u) => ({
|
||||
data: users.map((u: any) => ({
|
||||
groupId: group.id,
|
||||
userId: u.id,
|
||||
source: MembershipSource.MANUAL,
|
||||
@@ -345,7 +387,7 @@ export class GroupsService {
|
||||
});
|
||||
if (activations.length > 0) {
|
||||
await tx.moduleGrant.createMany({
|
||||
data: activations.map((a) => ({
|
||||
data: activations.map((a: any) => ({
|
||||
tenantId,
|
||||
moduleId: a.moduleId,
|
||||
groupId: group.id,
|
||||
@@ -382,16 +424,20 @@ export class GroupsService {
|
||||
* keine andere Gruppe, gibt die Methode false zurück; der Aufrufer ruft
|
||||
* danach ensureDefaultGroup(tenantId), um den Mandanten neu aufzubauen.
|
||||
*
|
||||
* Dieselbe Zwei-Schritt-Transaktionsform wie update() (isDefault:true):
|
||||
* erst updateMany auf isDefault:false für den ganzen Mandanten, dann
|
||||
* update der Zielgruppe auf isDefault:true. Der partielle Unique-Index
|
||||
* 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 group = await this.prisma.group.findFirst({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id: groupId, tenantId },
|
||||
});
|
||||
if (!group || !group.isDefault) {
|
||||
@@ -399,10 +445,10 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
const target =
|
||||
(await this.prisma.group.findFirst({
|
||||
(await tenantPrisma.group.findFirst({
|
||||
where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME },
|
||||
})) ??
|
||||
(await this.prisma.group.findFirst({
|
||||
(await tenantPrisma.group.findFirst({
|
||||
where: { tenantId, id: { not: groupId } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}));
|
||||
@@ -412,16 +458,16 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.group.updateMany({
|
||||
await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
await tx.group.updateMany({
|
||||
where: { tenantId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
this.prisma.group.update({
|
||||
});
|
||||
await tx.group.update({
|
||||
where: { id: target.id },
|
||||
data: { isDefault: true },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
@@ -437,16 +483,32 @@ export class GroupsService {
|
||||
* 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
|
||||
* (Befund E, T-JTS-02, 260909-jts): die ausgelieferte Policy auf
|
||||
* GroupMembership prüft ausschließlich die Gruppenseite
|
||||
* (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`), gemessen
|
||||
* in Aufgabe 1 — die Benutzerseite prüft sie 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 defaultGroup = await this.prisma.group.findFirst({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const defaultGroup = await tenantPrisma.group.findFirst({
|
||||
where: { tenantId, isDefault: true },
|
||||
});
|
||||
if (!defaultGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.groupMembership.createMany({
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user