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:
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { MembershipSource } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,28 @@ import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
|
||||
* keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025
|
||||
* (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein
|
||||
* echter Postgres-Client via Prisma-Fehlercodes.
|
||||
*
|
||||
* Bindung an forTenant()/withTenantTransaction() (260909-jts, Befund C):
|
||||
* anders als bei ldap-config.service.spec.ts (einfacher Identitaets-Mock
|
||||
* `forTenant: vi.fn((p) => p)`) braucht diese Datei einen Mock, der den
|
||||
* gebundenen Client als ZWEITES, von `prisma` UNTERSCHEIDBARES Objekt ueber
|
||||
* DEMSELBEN Speicher liefert — sonst waeren ein Aufruf ueber den
|
||||
* ungebundenen Fake und ein Aufruf ueber den (mit reiner Identitaet)
|
||||
* "gebundenen" Client nicht auseinanderzuhalten, und ein vergessener
|
||||
* Bindungsaufruf faellt in keinem Test auf. `makeFakePrisma()` bekommt dafuer
|
||||
* `__makeBoundClient(tenantId)` (liefert je Modell einen protokollierenden
|
||||
* Wrapper um dieselben Maps) und `__withTenantTransaction(tenantId, fn)`
|
||||
* (reicht denselben gebundenen Client als Transaktionsparameter durch,
|
||||
* Muster aus 260909-ipc uebertragen auf die interaktive Form dieses
|
||||
* Bereichs). `forTenant`/`withTenantTransaction` selbst werden gemockt,
|
||||
* damit der Fake nicht durch die echte `$extends`-Implementierung muss.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: (tx: any) => any) =>
|
||||
prisma.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
function makeFakePrisma() {
|
||||
const groups = new Map<string, any>();
|
||||
@@ -22,6 +43,7 @@ function makeFakePrisma() {
|
||||
let groupCounter = 0;
|
||||
let membershipCounter = 0;
|
||||
let grantCounter = 0;
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
|
||||
return Array.from(groups.values()).find(
|
||||
@@ -240,6 +262,13 @@ function makeFakePrisma() {
|
||||
}
|
||||
return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId);
|
||||
},
|
||||
findFirst: async ({ where }: any) => {
|
||||
return (
|
||||
Array.from(users.values()).find(
|
||||
(u) => u.id === where.id && u.tenantId === where.tenantId,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
},
|
||||
$transaction: async (opsOrFn: any) => {
|
||||
if (typeof opsOrFn === 'function') {
|
||||
@@ -247,11 +276,52 @@ function makeFakePrisma() {
|
||||
}
|
||||
return Promise.all(opsOrFn);
|
||||
},
|
||||
// --- Bindungsnachweis (260909-jts, Befund C) ---------------------------
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
|
||||
for (const modelName of BOUND_MODEL_NAMES) {
|
||||
const model = fake[modelName];
|
||||
const wrapped: any = {};
|
||||
for (const method of Object.keys(model)) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: modelName, method });
|
||||
return model[method](...args);
|
||||
};
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
return bound;
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
|
||||
const BOUND_MODEL_NAMES = ['group', 'groupMembership', 'moduleGrant', 'tenantModuleActivation', 'user'];
|
||||
|
||||
/**
|
||||
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
|
||||
* Fake). Ein vergessener `forTenant()`/`withTenantTransaction()`-Aufruf
|
||||
* hinterlaesst hier KEINEN Eintrag und laesst den Test fehlschlagen.
|
||||
*/
|
||||
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe('GroupsService', () => {
|
||||
// --- listForTenant ------------------------------------------------------
|
||||
|
||||
@@ -617,6 +687,10 @@ describe('GroupsService', () => {
|
||||
|
||||
const group = await service.create('t1', { name: 'Alle Benutzer' });
|
||||
await service.update('t1', group.id, { isDefault: true });
|
||||
// Befund E/T-JTS-02 (260909-jts): die Methode prueft seither, dass der
|
||||
// Zielbenutzer zum Mandanten gehoert — ohne einen seed hier waere
|
||||
// dieser Test wieder die Luecke, die er einst unbemerkt durchliess.
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addUserToDefaultGroup('t1', 'u1');
|
||||
|
||||
@@ -847,3 +921,164 @@ describe('GroupsService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant()/withTenantTransaction() (260909-jts, Aufgabe 2) ---
|
||||
|
||||
describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (260909-jts)', () => {
|
||||
it('listForTenant() bindet group.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
|
||||
await service.listForTenant('t1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findMany');
|
||||
});
|
||||
|
||||
it('create() bindet group.create an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
|
||||
await service.create('t1', { name: 'A' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'create');
|
||||
});
|
||||
|
||||
it('update() (Name/internalName, kein isDefault) bindet findOwned und group.update an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.update('t1', group.id, { internalName: 'X' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('update() mit isDefault:true laeuft als EINE withTenantTransaction — beide Teilschritte (updateMany, update) landen am gebundenen Client', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.update('t1', group.id, { isDefault: true });
|
||||
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'updateMany');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('getImpact() bindet findOwned, groupMembership.count und moduleGrant.count an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.getImpact('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'count');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'count');
|
||||
});
|
||||
|
||||
it('remove() bindet findOwned und group.delete an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.remove('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'group', 'delete');
|
||||
});
|
||||
|
||||
it('listMembers() bindet findOwned und groupMembership.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.listMembers('t1', group.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
|
||||
});
|
||||
|
||||
it('addMembers() bindet findOwned, user.findMany und groupMembership.createMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addMembers('t1', group.id, ['u1']);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
});
|
||||
|
||||
it('removeMember() bindet findOwned und groupMembership.deleteMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'A' });
|
||||
|
||||
await service.removeMember('t1', group.id, 'u1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'deleteMany');
|
||||
});
|
||||
|
||||
it('ensureDefaultGroup() bindet den Zaehler UND alle vier Schritte der Transaktion an denselben Mandanten (T-JTS-05)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true });
|
||||
|
||||
await service.ensureDefaultGroup('t1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'count');
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'create');
|
||||
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'createMany');
|
||||
});
|
||||
|
||||
it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const def = await service.create('t1', { name: DEFAULT_GROUP_NAME });
|
||||
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
|
||||
await service.update('t1', toDelete.id, { isDefault: true });
|
||||
|
||||
await service.reassignDefaultBeforeDelete('t1', toDelete.id);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
|
||||
expectBoundCall(prisma, 't1', 'group', 'updateMany');
|
||||
expectBoundCall(prisma, 't1', 'group', 'update');
|
||||
});
|
||||
|
||||
it('addUserToDefaultGroup() bindet group.findFirst und groupMembership.createMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'Alle Benutzer' });
|
||||
await service.update('t1', group.id, { isDefault: true });
|
||||
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||
|
||||
await service.addUserToDefaultGroup('t1', 'u1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
|
||||
});
|
||||
|
||||
it('addUserToDefaultGroup() mit einem Zielbenutzer eines fremden Mandanten legt KEINE Mitgliedschaft an und wirft nicht (Befund E, T-JTS-02)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new GroupsService(prisma as any);
|
||||
const group = await service.create('t1', { name: 'Alle Benutzer' });
|
||||
await service.update('t1', group.id, { isDefault: true });
|
||||
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
|
||||
|
||||
await expect(service.addUserToDefaultGroup('t1', 'u-fremd')).resolves.not.toThrow();
|
||||
|
||||
const members = await service.listMembers('t1', group.id);
|
||||
expect(members).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from './prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from './prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
|
||||
@@ -30,6 +30,23 @@ function stripComments(source: string): string {
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schneidet den Quelltext einer einzelnen Top-Level-`export function`
|
||||
* heraus (bis zur naechsten `export function` oder zum Dateiende). Seit
|
||||
* 260909-jts (Aufgabe 2) enthaelt diese Datei ZWEI Funktionen mit
|
||||
* unterschiedlichem, jeweils gemessen begruendetem Transaktionsmuster —
|
||||
* die Array-Form-Garantie unten gilt nachweislich nur fuer `forTenant()`
|
||||
* selbst, nicht mehr fuer die gesamte Datei.
|
||||
*/
|
||||
function extractFunctionSource(source: string, functionName: string): string {
|
||||
const startMarker = `export function ${functionName}`;
|
||||
const startIdx = source.indexOf(startMarker);
|
||||
if (startIdx === -1) return '';
|
||||
const rest = source.slice(startIdx);
|
||||
const nextExportIdx = rest.indexOf('\nexport function ', startMarker.length);
|
||||
return nextExportIdx === -1 ? rest : rest.slice(0, nextExportIdx);
|
||||
}
|
||||
|
||||
describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => {
|
||||
const transactionCalls: unknown[] = [];
|
||||
@@ -108,9 +125,87 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
expect(source).not.toContain('$executeRawUnsafe');
|
||||
});
|
||||
|
||||
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback)', () => {
|
||||
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback) — innerhalb von forTenant() selbst', () => {
|
||||
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
|
||||
expect(source).toMatch(/\$transaction\(\s*\[/);
|
||||
expect(source).not.toMatch(/\$transaction\(\s*async/);
|
||||
const forTenantSource = extractFunctionSource(source, 'forTenant');
|
||||
expect(forTenantSource).not.toBe('');
|
||||
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
|
||||
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
|
||||
it('nutzt im tatsaechlichen Code die interaktive Callback-Form auf tx, nicht die Array-Form (gemessen: einzige Form, die die Lastprobe bestand)', () => {
|
||||
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
|
||||
const withTenantTransactionSource = extractFunctionSource(source, 'withTenantTransaction');
|
||||
expect(withTenantTransactionSource).not.toBe('');
|
||||
expect(withTenantTransactionSource).toMatch(/\$transaction\(async/);
|
||||
});
|
||||
|
||||
it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => {
|
||||
const setConfigCalls: unknown[] = [];
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
setConfigCalls.push(values);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
// Der aeussere Client darf NIE direkt fuer set_config oder die
|
||||
// eigentliche Abfrage herangezogen werden — nur $transaction selbst.
|
||||
$executeRaw: vi.fn(() => {
|
||||
throw new Error('set_config darf nicht auf dem aeusseren Client laufen');
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
|
||||
expect(tx).toBe(fakeTx);
|
||||
return 'fn-result';
|
||||
});
|
||||
|
||||
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(setConfigCalls).toEqual([['tenant-a']]);
|
||||
expect(result).toBe('fn-result');
|
||||
});
|
||||
|
||||
it('reicht denselben tx-Parameter an fn weiter, sodass mehrere Schritte auf derselben Verbindung laufen', async () => {
|
||||
const touchedByFn: unknown[] = [];
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn(() => Promise.resolve(1)),
|
||||
group: { create: vi.fn(() => Promise.resolve({ id: 'g1' })) },
|
||||
user: { findMany: vi.fn(() => Promise.resolve([])) },
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
};
|
||||
|
||||
await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
|
||||
touchedByFn.push(await tx.group.create({ data: {} }));
|
||||
touchedByFn.push(await tx.user.findMany({ where: {} }));
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(fakeTx.group.create).toHaveBeenCalledTimes(1);
|
||||
expect(fakeTx.user.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(touchedByFn).toEqual([{ id: 'g1' }, []]);
|
||||
});
|
||||
|
||||
it('setzt den Mandantenkontext ueber ein getaggtes Template, nicht ueber zusammengebauten Text (T-02-05)', async () => {
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
expect(Array.isArray(strings)).toBe(true);
|
||||
expect(values).toContain("tenant-with-quote-' OR 1=1");
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
};
|
||||
|
||||
await withTenantTransaction(fakePrisma, "tenant-with-quote-' OR 1=1", async () => 'ok');
|
||||
|
||||
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,51 @@ import { PrismaClient } from '@prisma/client';
|
||||
* Parametrisiert ueber ein getaggtes `$executeRaw`-Template (kein
|
||||
* `$executeRawUnsafe` mit zusammengebautem Text mehr) — die
|
||||
* Injektionsfestigkeit aus T-02-05 bleibt beim Umbau erhalten.
|
||||
*
|
||||
* ERNEUTE PRUEFUNG FUER ETAPPE 2, BEREICH `groups` (260909-jts, gemessen
|
||||
* 2026-09-09 gegen die echte Datenbank, siehe Aufgabe 1 in
|
||||
* `.planning/quick/260909-jts-.../260909-jts-PLAN.md` und den Abschnitt
|
||||
* "Bereich groups" in `docs/mandantentrennung-etappe2-fehlerrichtung.md`):
|
||||
* `groups.service.ts` ist die einzige Datei im gesamten API-Quelltext mit
|
||||
* einer interaktiven Callback-Transaktion (`ensureDefaultGroup`), dazu zwei
|
||||
* Array-Transaktionen (`update`, `reassignDefaultBeforeDelete`) — genau der
|
||||
* im Absatz oben benannte neue Fall. Ergebnis der Messung, mit
|
||||
* `pg_backend_pid()` und `current_tenant_id()` je Teilschritt:
|
||||
*
|
||||
* Form (i) — Array-Form auf dem gebundenen Client: FAELLT DURCH. Zwei
|
||||
* Teilschritte liefen auf ZWEI verschiedenen Verbindungen
|
||||
* (`step1.pid=276749`, `step2.pid=276750`) — exakt die im
|
||||
* Absatz oben beschriebene Aufspaltung in mehrere
|
||||
* Teiltransaktionen. Jeder Teilschritt sah zwar noch den
|
||||
* richtigen Kontext (kein Datenleck), aber die Atomaritaet
|
||||
* der aeusseren Transaktion ist nicht mehr gegeben.
|
||||
* Form (ii) — interaktive Callback-Form auf dem gebundenen Client
|
||||
* (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`):
|
||||
* bestand die Einzelmessung (gleiche Verbindung, richtiger
|
||||
* Kontext, richtige Zeilenzahl), brach aber unter einer
|
||||
* zusaetzlichen Lastprobe (40 parallele Aufrufe,
|
||||
* alternierend TENANT-A/TENANT-B) mit
|
||||
* `PrismaClientKnownRequestError: Transaction API error:
|
||||
* Unable to start a transaction in the given time.` (P2028)
|
||||
* ab — jeder `tx.$queryRaw`-Aufruf innerhalb der interaktiven
|
||||
* Transaktion loest selbst wieder eine VERSCHACHTELTE
|
||||
* Array-Transaktion aus (weil `$allOperations` bei jedem
|
||||
* Aufruf erneut feuert), und die aeussere plus jede innere
|
||||
* Verschachtelung belegen gleichzeitig eine Verbindung aus
|
||||
* demselben, endlichen Pool.
|
||||
* Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client,
|
||||
* `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf
|
||||
* dem aeusseren Client): bestand sowohl die Einzelmessung als
|
||||
* auch die Lastprobe (0 Verletzungen unter 40 parallelen
|
||||
* Aufrufen) — sie belegt pro Aufruf genau EINE Verbindung,
|
||||
* ohne Verschachtelung.
|
||||
*
|
||||
* Entscheidung: `withTenantTransaction()` unten baut Form (iii) nach und
|
||||
* ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen
|
||||
* Aenderungen dieses Bereichs. Fuer den naechsten Bereich mit einer eigenen
|
||||
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
|
||||
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
|
||||
* ein anderes Ergebnis liefern.
|
||||
*/
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
return prisma.$extends({
|
||||
@@ -70,3 +115,32 @@ export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt `fn` als EINE mehrschrittige, mandantengebundene Transaktion aus
|
||||
* (260909-jts, Befund A/Aufgabe 1). Anders als `forTenant()` bindet diese
|
||||
* Funktion NICHT ueber `$extends`/`$allOperations`, sondern oeffnet direkt
|
||||
* eine interaktive Transaktion auf dem UNGEBUNDENEN Basisclient und setzt
|
||||
* `app.current_tenant` als allererste Anweisung ueber ein getaggtes
|
||||
* Roh-Template DIREKT AUF `tx` — nicht auf `prisma`. Jede weitere Anweisung
|
||||
* innerhalb von `fn` bekommt denselben `tx`-Parameter uebergeben und laeuft
|
||||
* dadurch auf DERSELBEN Verbindung wie das `set_config` davor.
|
||||
*
|
||||
* Parametrisiert wie `forTenant()` (getaggtes Template, kein
|
||||
* zusammengebauter Text — T-02-05 bleibt erhalten).
|
||||
*
|
||||
* Fuer Einzeloperationen bleibt `forTenant()` das richtige Werkzeug; dieses
|
||||
* Hilfsmittel ist ausschliesslich fuer Aufrufstellen gedacht, die mehrere
|
||||
* Schritte als EINE Transaktion brauchen (Array- oder interaktive
|
||||
* Callback-Form).
|
||||
*/
|
||||
export function withTenantTransaction<T>(
|
||||
prisma: PrismaClient,
|
||||
tenantId: string,
|
||||
fn: (tx: any) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return (prisma as any).$transaction(async (tx: any) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
||||
return fn(tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@ import { describe, expect, it } from 'vitest';
|
||||
* der Form `const <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
|
||||
* Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand:
|
||||
* `gebunden`, `ungebunden` oder `gemischt`.
|
||||
*
|
||||
* Erweitert in Aufgabe 2 (260909-jts, Befund B): Modellzugriffe koennen
|
||||
* auch ueber den Rueckgabeparameter einer INTERAKTIVEN Transaktion laufen
|
||||
* (`empfaenger.$transaction(async (tx) => { ... tx.<Modell> ... })`) —
|
||||
* weder `this.prisma.<Modell>` noch `<gebundener Client>.<Modell>` sehen
|
||||
* das, weil der Parametername (z. B. `tx`) weder mit `this.prisma`
|
||||
* uebereinstimmt noch selbst aus einer `forTenant(`-Zuweisung stammt. Die
|
||||
* dritte Erkennung sammelt je Datei die Empfaenger UND Parameternamen
|
||||
* solcher Transaktionen (zwei Formen: direkt `<empfaenger>.$transaction(
|
||||
* async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction(
|
||||
* <empfaenger>, tenantId, async (tx) => ...)` aus
|
||||
* `prisma-tenant.extension.ts`) und sucht danach `<tx>.<Modell>`. Die
|
||||
* Zuordnung richtet sich nach dem Empfaenger: eine bereits als gebunden
|
||||
* erkannte Zuweisung ODER jeder Aufruf von `withTenantTransaction(` zaehlt
|
||||
* als gebunden (das Hilfsmittel bindet den Kontext selbst, direkt auf dem
|
||||
* Transaktionsparameter) — alles andere zaehlt als ungebunden.
|
||||
*/
|
||||
|
||||
const API_SRC_DIR = join(__dirname, '..');
|
||||
@@ -39,6 +55,18 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
|
||||
'apps/api/src/tenant/tenant.guard.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
|
||||
* async (tx) => ...)`) bewusst KEINER der beiden erkannten Empfaengerformen
|
||||
* entspricht. Gemessen zur Planungszeit (260909-jts, Aufgabe 1) gibt es im
|
||||
* gesamten API-Quelltext genau eine interaktive Transaktion, in
|
||||
* `groups.service.ts` — nach deren Umstellung auf `withTenantTransaction(`
|
||||
* (Aufgabe 2) entspricht sie der erkannten Hilfsmittel-Form. Die Liste
|
||||
* startet deshalb leer und bleibt es, bis ein begruendeter Ausnahmefall
|
||||
* auftritt.
|
||||
*/
|
||||
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
|
||||
|
||||
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
||||
type Stand = (typeof STAND_TOKENS)[number];
|
||||
|
||||
@@ -48,6 +76,8 @@ interface FileAnalysis {
|
||||
boundModels: Set<string>;
|
||||
totalForTenantCalls: number;
|
||||
assignmentFormCalls: number;
|
||||
rawInteractiveTransactionCount: number;
|
||||
matchedInteractiveTransactionCount: number;
|
||||
}
|
||||
|
||||
function listTsFiles(dir: string): string[] {
|
||||
@@ -100,12 +130,72 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
||||
// die Definition ist kein Aufruf und braucht keine Zuweisungsform.
|
||||
const totalForTenantCalls = [...source.matchAll(/(?<!function )forTenant\(/g)].length;
|
||||
|
||||
// Dritte Erkennung (260909-jts, Befund B): Modellzugriffe ueber den
|
||||
// Rueckgabeparameter einer interaktiven Transaktion. Rohzahl zuerst
|
||||
// (jedes "<etwas>.$transaction(async" im Quelltext), danach die
|
||||
// strukturierte Erkennung der beiden bekannten Empfaengerformen — die
|
||||
// Differenz ist die offen gehaltene Grenze (siehe
|
||||
// INTERACTIVE_TRANSACTION_EXCEPTIONS oben).
|
||||
//
|
||||
// prisma-tenant.extension.ts definiert `withTenantTransaction()` selbst
|
||||
// und enthaelt deshalb dessen KANONISCHE interaktive `$transaction`-
|
||||
// Anweisung (`(prisma as any).$transaction(async (tx) => ...)`) als
|
||||
// Definition, nicht als Aufrufstelle, die klassifiziert werden muesste —
|
||||
// dieselbe Ausnahme, die `totalForTenantCalls` oben fuer die Definition
|
||||
// von `forTenant()` bereits macht.
|
||||
const isPrismaTenantExtensionFile = relPath.endsWith(
|
||||
'apps/api/src/prisma/prisma-tenant.extension.ts',
|
||||
);
|
||||
const rawInteractiveTransactionCount = isPrismaTenantExtensionFile
|
||||
? 0
|
||||
: [...source.matchAll(/\.\$transaction\(\s*async\b/g)].length;
|
||||
|
||||
// Form 1: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
||||
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
|
||||
const directInteractiveMatches = [
|
||||
...source.matchAll(
|
||||
/([\w.]+)\.\$transaction\(\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
||||
),
|
||||
];
|
||||
for (const m of directInteractiveMatches) {
|
||||
const receiver = m[1];
|
||||
const param = m[2];
|
||||
if (!param) continue;
|
||||
const isBound = boundNames.has(receiver);
|
||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||
for (const mm of source.matchAll(re)) {
|
||||
if (!mm[1]) continue;
|
||||
if (isBound) boundModels.add(mm[1]);
|
||||
else unboundModels.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Form 2: `withTenantTransaction(<empfaenger>, tenantId, async (<param>)
|
||||
// => ...)` aus prisma-tenant.extension.ts — IMMER gebunden, unabhaengig
|
||||
// vom Empfaenger: das Hilfsmittel bindet den Kontext selbst, direkt auf
|
||||
// dem Transaktionsparameter (siehe dessen Kopfkommentar).
|
||||
const withTenantTransactionMatches = [
|
||||
...source.matchAll(
|
||||
/\bwithTenantTransaction\(\s*[\w.]+\s*,[^,]*,\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
||||
),
|
||||
];
|
||||
for (const m of withTenantTransactionMatches) {
|
||||
const param = m[1];
|
||||
if (!param) continue;
|
||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||
for (const mm of source.matchAll(re)) {
|
||||
if (mm[1]) boundModels.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: relPath,
|
||||
unboundModels,
|
||||
boundModels,
|
||||
totalForTenantCalls,
|
||||
assignmentFormCalls: assignmentMatches.length,
|
||||
rawInteractiveTransactionCount,
|
||||
matchedInteractiveTransactionCount: directInteractiveMatches.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,4 +346,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
}
|
||||
expect(violations, violations.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
|
||||
const violations: string[] = [];
|
||||
for (const a of analyses) {
|
||||
const unmatched = a.rawInteractiveTransactionCount - a.matchedInteractiveTransactionCount;
|
||||
if (unmatched > 0 && !INTERACTIVE_TRANSACTION_EXCEPTIONS.has(a.file)) {
|
||||
violations.push(
|
||||
`${a.file}: ${unmatched} interaktive Transaktion(en) ausserhalb der erkannten Empfaengerformen`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(violations, violations.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,10 +176,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | ungebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | ungebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
|
||||
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | ungebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | ungebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | ungebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. |
|
||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. |
|
||||
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb eines Mandanten. |
|
||||
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
|
||||
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
|
||||
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden, einschliesslich des Zugriffs innerhalb des Standardgruppen-Aufbaus (Befund B). |
|
||||
| apps/api/src/groups/groups.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat. Bis 260909-jts vollstaendig unsichtbar (Befund B, 260909-jts-PLAN.md): der einzige Zugriff dieser Datei lief innerhalb der interaktiven Transaktion von `ensureDefaultGroup` ueber den Rueckgabeparameter (`tx.tenantModuleActivation.findMany`) — weder `this.prisma.<Modell>` noch `<gebundener Client>.<Modell>` sahen das, weil `tx` weder `this.prisma` noch aus einer `forTenant(`-Zuweisung stammte. Die um Transaktionsparameter erweiterte Erkennung aus Aufgabe 2 macht dieses Paar erstmals sichtbar; die Stelle ist seit derselben Aufgabe gebunden (ueber `withTenantTransaction()`). |
|
||||
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb eines Mandanten. Seit 260909-jts gebunden; `addUserToDefaultGroup` prueft seither zusaetzlich, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf `GroupMembership` prueft nachweislich nur die Gruppenseite. |
|
||||
| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | ungebunden | Wie groups.service.ts. |
|
||||
| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | ungebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. |
|
||||
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. |
|
||||
|
||||
Reference in New Issue
Block a user