From 7f08b27eea272247d66c2a8de76f6d97233a9ddb Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 15:01:24 +0200 Subject: [PATCH] feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/src/groups/groups.service.spec.ts | 237 +++++++++++++++++- apps/api/src/groups/groups.service.ts | 152 +++++++---- .../prisma/prisma-tenant.extension.spec.ts | 103 +++++++- .../api/src/prisma/prisma-tenant.extension.ts | 74 ++++++ .../src/prisma/rls-access-inventory.spec.ts | 103 ++++++++ ...andantentrennung-zugriffsklassifikation.md | 9 +- 6 files changed, 624 insertions(+), 54 deletions(-) diff --git a/apps/api/src/groups/groups.service.spec.ts b/apps/api/src/groups/groups.service.spec.ts index a77580e..068ee26 100644 --- a/apps/api/src/groups/groups.service.spec.ts +++ b/apps/api/src/groups/groups.service.spec.ts @@ -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(); @@ -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 `..` + * 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([]); + }); +}); diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index 253d259..df0192b 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -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 { - 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, }); diff --git a/apps/api/src/prisma/prisma-tenant.extension.spec.ts b/apps/api/src/prisma/prisma-tenant.extension.spec.ts index b761e1b..aaffc79 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.spec.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.spec.ts @@ -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); }); }); diff --git a/apps/api/src/prisma/prisma-tenant.extension.ts b/apps/api/src/prisma/prisma-tenant.extension.ts index 9f8230b..64155ee 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.ts @@ -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( + prisma: PrismaClient, + tenantId: string, + fn: (tx: any) => Promise, +): Promise { + return (prisma as any).$transaction(async (tx: any) => { + await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; + return fn(tx); + }); +} diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 4ba286b..86792ca 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -19,6 +19,22 @@ import { describe, expect, it } from 'vitest'; * der Form `const = forTenant(` und sucht danach `.`. * 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. ... })`) — + * weder `this.prisma.` noch `.` 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 `.$transaction( + * async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction( + * , tenantId, async (tx) => ...)` aus + * `prisma-tenant.extension.ts`) und sucht danach `.`. 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([]); + const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const; type Stand = (typeof STAND_TOKENS)[number]; @@ -48,6 +76,8 @@ interface FileAnalysis { boundModels: Set; 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(/(?.$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: `.$transaction(async () => ...)`. + // 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(, tenantId, async () + // => ...)` 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([]); + }); }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 1e5ccd5..185d103 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -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.` noch `.` 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. |