From c8de72e762ad942069bde54941623fca6a6642e2 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 10:58:27 +0200 Subject: [PATCH] feat(260911-e2s): Benutzerzaehler im TenantController binden (Fan-out je Mandant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findAll/findOne/remove zaehlen Benutzer je Mandant jetzt ueber drei gebundene Aufrufstellen (tenantPrisma.user.count mit where: { tenantId }, in remove zusaetzlich isActive: true) statt ueber den Relationszaehler, der nach dem Scharfschalten unbemerkt unter der Regel von User gelaufen waere (260911-e2s, Aufgabe 1, Pruefungen 5-7). Fan-out-Muster aus UserService.findAllForPlatformAdmin uebernommen; die vier tenant-Zugriffe bleiben ungebunden (Tenant ohne Regel). Antwortform, Meldungen und Statuscodes unveraendert. tenant.controller.spec.ts legt die Testlage aus dem Nichts an (20 Faelle): Zwei-Klienten-Nachweis ueber __makeBoundClient, Rollen- Metadaten-Test (Klasse SUPER_ADMIN, kein Handler ueberschreibt), Wachhund gegen mehrfache Klientenerzeugung. Falsifizierungsnachweis durchgefuehrt: der probeweise ungebundene Zaehler in findOne macht 2 Faelle rot mit "Cannot read properties of undefined (reading 'count')" — die dkv-Form der Falsifizierung, nicht nur eine falsche Zahl —, danach zurueckgenommen. Klassifikation und Entwicklungsanleitung nachgezogen: 64 Paare (ein neues, tenant.controller.ts/user), Uebersichtszeile 8/3, Klassen- Verteilung 32 muss-mandantengebunden, Erkennungsluecke fuer Relationseinbindungen im Kopf der Bestandsaufnahme benannt, "Zwei belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster Punkt aufgeloest). Beide Dokument-Falsifizierungsnachweise durchgefuehrt (falsche Klasse macht rls-access-inventory.spec.ts rot, falsche Uebersichtszahl macht das herleitende Gate rot), zurueckgenommen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/src/tenant/tenant.controller.spec.ts | 346 ++++++++++++++++++ apps/api/src/tenant/tenant.controller.ts | 81 ++-- docs/anleitung-entwicklung.md | 37 +- ...andantentrennung-zugriffsklassifikation.md | 76 +++- 4 files changed, 486 insertions(+), 54 deletions(-) create mode 100644 apps/api/src/tenant/tenant.controller.spec.ts diff --git a/apps/api/src/tenant/tenant.controller.spec.ts b/apps/api/src/tenant/tenant.controller.spec.ts new file mode 100644 index 0000000..b428a8c --- /dev/null +++ b/apps/api/src/tenant/tenant.controller.spec.ts @@ -0,0 +1,346 @@ +import 'reflect-metadata'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; +import { TenantController } from './tenant.controller'; + +/** + * TenantController.spec — legt die Testlage fuer diesen Bereich aus dem + * Nichts an (260911-e2s, Aufgabe 3, Befund I: vorher gab es nur + * `tenant.service.spec.ts` mit zwei Faellen zu `create`). + * + * Zwei-Klienten-Nachweis (Muster aus `../dkv/dkv.service.spec.ts`): + * `__makeBoundClient(tenantId)` bietet ein `user.count`-Modell, das + * zusaetzlich nach der Mandantenkennung filtert und jeden Aufruf in ein + * Bindungsprotokoll schreibt. Der UNGEBUNDENE Nachbau (`prisma.tenant.*`) + * hat absichtlich KEIN `user`-Modell — ein versehentlich ungebundener + * Zaehler scheitert dadurch mit "Cannot read properties of undefined" + * (die `dkv`-Form der Falsifizierung), nicht mit einer nur falschen Zahl. + */ + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + +interface FakeTenantRow { + id: string; + name: string; + slug: string; + isActive: boolean; + createdAt: Date; +} + +interface FakeUserRow { + id: string; + tenantId: string; + isActive: boolean; +} + +function makeFakePrisma(tenantRows: FakeTenantRow[], userRows: FakeUserRow[]) { + const tenants = new Map(tenantRows.map((t) => [t.id, { ...t }])); + const users = [...userRows]; + const boundCallLog: { tenantId: string; model: string; method: string; where: any }[] = []; + + const tenantModel = { + findMany: vi.fn(async ({ orderBy }: { orderBy?: { name?: 'asc' | 'desc' } } = {}) => { + const rows = [...tenants.values()]; + if (orderBy?.name === 'asc') rows.sort((a, b) => a.name.localeCompare(b.name)); + return rows; + }), + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => tenants.get(where.id) ?? null), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + const row = tenants.get(where.id) ?? null; + tenants.delete(where.id); + return row; + }), + }; + + const fake: any = { + tenant: tenantModel, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + return { + user: { + count: async ({ + where, + }: { + where: { tenantId: string; isActive?: boolean }; + }) => { + boundCallLog.push({ tenantId, model: 'user', method: 'count', where }); + return users.filter( + (u) => + u.tenantId === where.tenantId && + (where.isActive === undefined || u.isActive === where.isActive), + ).length; + }, + }, + }; + }, + }; + + return fake; +} + +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); +} + +function makeFakeTenantService() { + return { + create: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + } as any; +} + +const TENANT_A: FakeTenantRow = { + id: 'TENANT-A', + name: 'A GmbH', + slug: 'tenant-a', + isActive: true, + createdAt: new Date('2026-01-01'), +}; +const TENANT_B: FakeTenantRow = { + id: 'TENANT-B', + name: 'B GmbH', + slug: 'tenant-b', + isActive: true, + createdAt: new Date('2026-01-02'), +}; +const TENANT_C: FakeTenantRow = { + id: 'TENANT-C', + name: 'C GmbH', + slug: 'tenant-c', + isActive: true, + createdAt: new Date('2026-01-03'), +}; + +describe('TenantController.findAll', () => { + it('drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner): liefert drei Eintraege mit userCount 2/1/0, genau drei gebundene Zaehlaufrufe je Mandantenkennung', async () => { + const prisma = makeFakePrisma( + [TENANT_A, TENANT_B, TENANT_C], + [ + { id: 'u-a1', tenantId: 'TENANT-A', isActive: true }, + { id: 'u-a2', tenantId: 'TENANT-A', isActive: true }, + { id: 'u-b1', tenantId: 'TENANT-B', isActive: true }, + ], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const result = await controller.findAll(); + + expect(result).toEqual([ + expect.objectContaining({ id: 'TENANT-A', userCount: 2 }), + expect.objectContaining({ id: 'TENANT-B', userCount: 1 }), + expect.objectContaining({ id: 'TENANT-C', userCount: 0 }), + ]); + expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); + expect(prisma.__boundCallLog).toHaveLength(3); + expectBoundCall(prisma, 'TENANT-A', 'user', 'count'); + expectBoundCall(prisma, 'TENANT-B', 'user', 'count'); + expectBoundCall(prisma, 'TENANT-C', 'user', 'count'); + for (const call of prisma.__boundCallLog) { + expect(call.where.tenantId).toBe(call.tenantId); + } + }); + + it('ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt', async () => { + const prisma = makeFakePrisma([], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + expect(prisma.__boundCallLog).toHaveLength(0); + }); + + it('die Antwort traegt genau die Felder id, name, slug, isActive, createdAt, userCount', async () => { + const prisma = makeFakePrisma( + [TENANT_A], + [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const [entry] = await controller.findAll(); + + expect(Object.keys(entry).sort()).toEqual( + ['createdAt', 'id', 'isActive', 'name', 'slug', 'userCount'].sort(), + ); + }); +}); + +describe('TenantController.findOne', () => { + it('unbekannte Kennung: NotFoundException, KEIN gebundener Klient', async () => { + const prisma = makeFakePrisma([TENANT_A], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await expect(controller.findOne('unknown')).rejects.toThrow( + new NotFoundException('Tenant not found'), + ); + expect(prisma.__boundCallLog).toHaveLength(0); + }); + + it('bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung', async () => { + const prisma = makeFakePrisma( + [TENANT_A, TENANT_B], + [ + { id: 'u-a1', tenantId: 'TENANT-A', isActive: true }, + { id: 'u-b1', tenantId: 'TENANT-B', isActive: true }, + { id: 'u-b2', tenantId: 'TENANT-B', isActive: true }, + ], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const result = await controller.findOne('TENANT-B'); + + expect(result).toEqual(expect.objectContaining({ id: 'TENANT-B', userCount: 2 })); + expectBoundCall(prisma, 'TENANT-B', 'user', 'count'); + expect(prisma.__boundCallLog).toHaveLength(1); + expect(prisma.__boundCallLog[0].where.tenantId).toBe('TENANT-B'); + }); +}); + +describe('TenantController.remove', () => { + it('unbekannte Kennung: NotFoundException, kein gebundener Klient, tenant.delete nicht aufgerufen', async () => { + const prisma = makeFakePrisma([TENANT_A], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await expect(controller.remove('unknown')).rejects.toThrow( + new NotFoundException('Tenant not found'), + ); + expect(prisma.__boundCallLog).toHaveLength(0); + expect(prisma.tenant.delete).not.toHaveBeenCalled(); + }); + + it('mit aktiven Benutzern: BadRequestException mit der heutigen Meldung, tenant.delete NICHT aufgerufen, der gebundene Zaehlaufruf traegt isActive=true', async () => { + const prisma = makeFakePrisma( + [TENANT_A], + [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await expect(controller.remove('TENANT-A')).rejects.toThrow( + new BadRequestException( + 'Cannot delete tenant with active users. Deactivate or reassign users first.', + ), + ); + expect(prisma.tenant.delete).not.toHaveBeenCalled(); + expectBoundCall(prisma, 'TENANT-A', 'user', 'count'); + expect(prisma.__boundCallLog[0].where).toEqual({ tenantId: 'TENANT-A', isActive: true }); + }); + + it('mit ausschliesslich inaktiven Benutzern: der Riegel laesst durch, tenant.delete wird ungebunden mit { where: { id } } aufgerufen, Antwort { message: "Tenant deleted" } (heutiges Verhalten — der Fremdschluessel, der das in der echten Datenbank abfaengt, existiert im Nachbau nicht)', async () => { + const prisma = makeFakePrisma( + [TENANT_A], + [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: false }], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const result = await controller.remove('TENANT-A'); + + expect(result).toEqual({ message: 'Tenant deleted' }); + expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-A' } }); + }); + + it('ohne Benutzer: geloescht, Antwort wie oben', async () => { + const prisma = makeFakePrisma([TENANT_C], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + const result = await controller.remove('TENANT-C'); + + expect(result).toEqual({ message: 'Tenant deleted' }); + expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-C' } }); + }); +}); + +describe('TenantController.create / update — delegieren an die Dienst-Attrappe', () => { + it('create: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus, delegiert an den Dienst', async () => { + const prisma = makeFakePrisma([], []); + const tenantService = makeFakeTenantService(); + tenantService.create.mockResolvedValue(TENANT_A); + const controller = new TenantController(tenantService, prisma as any); + + const result = await controller.create({ name: 'A GmbH', slug: 'tenant-a' } as any); + + expect(result).toBe(TENANT_A); + expect(tenantService.create).toHaveBeenCalledWith({ name: 'A GmbH', slug: 'tenant-a' }); + expect(prisma.tenant.findMany).not.toHaveBeenCalled(); + expect(prisma.tenant.findUnique).not.toHaveBeenCalled(); + expect(prisma.__boundCallLog).toHaveLength(0); + }); + + it('update: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus (ausser der Existenzpruefung ueber den Dienst), delegiert an den Dienst', async () => { + const prisma = makeFakePrisma([], []); + const tenantService = makeFakeTenantService(); + tenantService.findById.mockResolvedValue(TENANT_A); + tenantService.update.mockResolvedValue({ ...TENANT_A, name: 'Neuer Name' }); + const controller = new TenantController(tenantService, prisma as any); + + const result = await controller.update('TENANT-A', { name: 'Neuer Name' }); + + expect(result).toEqual(expect.objectContaining({ name: 'Neuer Name' })); + expect(tenantService.update).toHaveBeenCalledWith('TENANT-A', { + name: 'Neuer Name', + isActive: undefined, + }); + expect(prisma.tenant.findMany).not.toHaveBeenCalled(); + expect(prisma.__boundCallLog).toHaveLength(0); + }); +}); + +describe('TenantController — Rollen-Metadaten (Befund E, T-E2S-01)', () => { + it('klassenweit ist genau [Role.SUPER_ADMIN] gesetzt', () => { + const roles = Reflect.getMetadata(ROLES_KEY, TenantController); + expect(roles).toEqual([Role.SUPER_ADMIN]); + }); + + it.each(['findAll', 'findOne', 'create', 'update', 'remove'] as const)( + 'Handler %s traegt KEINE eigene Rollenmetadaten — eine schwaechere Handler-Rolle wuerde die Klassenrolle via getAllAndOverride ueberschreiben', + (handlerName) => { + const handlerRoles = Reflect.getMetadata( + ROLES_KEY, + (TenantController.prototype as any)[handlerName], + ); + expect(handlerRoles).toBeUndefined(); + }, + ); +}); + +describe('TenantController — Wachhund: hoechstens ein gebundener Klient je Aufruf und Mandant', () => { + it('findOne erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { + const prisma = makeFakePrisma( + [TENANT_A], + [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], + ); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await controller.findOne('TENANT-A'); + + expect(prisma.__boundCallLog).toHaveLength(1); + }); + + it('remove erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { + const prisma = makeFakePrisma([TENANT_A], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await controller.remove('TENANT-A'); + + expect(prisma.__boundCallLog).toHaveLength(1); + }); + + it('findAll erzeugt genau so viele gebundene Klienten wie Mandanten vorhanden sind', async () => { + const prisma = makeFakePrisma([TENANT_A, TENANT_B, TENANT_C], []); + const controller = new TenantController(makeFakeTenantService(), prisma as any); + + await controller.findAll(); + + expect(prisma.__boundCallLog).toHaveLength(3); + }); +}); diff --git a/apps/api/src/tenant/tenant.controller.ts b/apps/api/src/tenant/tenant.controller.ts index 262c016..e00b176 100644 --- a/apps/api/src/tenant/tenant.controller.ts +++ b/apps/api/src/tenant/tenant.controller.ts @@ -13,6 +13,7 @@ import { import { Role } from '@prisma/client'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { TenantService } from './tenant.service'; @@ -21,6 +22,30 @@ import { TenantService } from './tenant.service'; * Tenant CRUD controller. * D-10: Only Super-Admin can manage tenants. * T-02-09: Tenant deletion blocked if active users exist. + * + * User counts (260911-e2s, Aufgabe 3): `findAll`/`findOne`/`remove` used to + * read the user count through a relation include on the four unbound + * tenant reads below. Prisma renders that as a single statement with a + * LEFT JOIN into the protected `User` table — which carries a row-level + * security rule. After the switch flips, an unbound relation count reads + * zero for every tenant (measured, 260911-e2s Aufgabe 1, Pruefungen 5-7), + * which would make the platform-admin tenant list show zero users + * everywhere and let the delete gate below pass through with active users + * still present. Fixed by the fan-out pattern `UserService + * .findAllForPlatformAdmin` already uses: read tenants unbound, then bind + * ONE user count per tenant via the tenant-binding helper. The explicit + * `where: { tenantId }` on each bound count is TODAY (role runs with + * BYPASSRLS, WINDOWS #18) the only filter actually in effect. + * + * The four tenant reads/writes below stay unbound on purpose — `Tenant` + * carries no row-level security rule in any shipped migration (measured, + * 260911-e2s Aufgabe 1, Pruefungen 1/2); nothing on `Tenant` itself needs + * binding. + * + * This controller keeps talking to Prisma directly rather than going + * through a service method (same pattern as `user.controller.ts`) — a + * deliberate choice, not an oversight; see + * docs/mandantentrennung-zugriffsklassifikation.md, "(n5)". */ @Controller('tenants') @UseGuards(RolesGuard) @@ -38,22 +63,26 @@ export class TenantController { @Get() async findAll() { const tenants = await this.prisma.tenant.findMany({ - include: { - _count: { - select: { users: true }, - }, - }, orderBy: { name: 'asc' }, }); - return tenants.map((t: any) => ({ - id: t.id, - name: t.name, - slug: t.slug, - isActive: t.isActive, - createdAt: t.createdAt, - userCount: t._count.users, - })); + const results: any[] = []; + for (const tenant of tenants) { + const tenantPrisma = forTenant(this.prisma, tenant.id) as any; + const userCount = await tenantPrisma.user.count({ + where: { tenantId: tenant.id }, + }); + results.push({ + id: tenant.id, + name: tenant.name, + slug: tenant.slug, + isActive: tenant.isActive, + createdAt: tenant.createdAt, + userCount, + }); + } + + return results; } /** @@ -64,24 +93,24 @@ export class TenantController { async findOne(@Param('id') id: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id }, - include: { - _count: { - select: { users: true }, - }, - }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } + const tenantPrisma = forTenant(this.prisma, id) as any; + const userCount = await tenantPrisma.user.count({ + where: { tenantId: id }, + }); + return { id: tenant.id, name: tenant.name, slug: tenant.slug, isActive: tenant.isActive, createdAt: tenant.createdAt, - userCount: tenant._count.users, + userCount, }; } @@ -125,20 +154,18 @@ export class TenantController { async remove(@Param('id') id: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id }, - include: { - _count: { - select: { - users: { where: { isActive: true } }, - }, - }, - }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } - if (tenant._count.users > 0) { + const tenantPrisma = forTenant(this.prisma, id) as any; + const activeUserCount = await tenantPrisma.user.count({ + where: { tenantId: id, isActive: true }, + }); + + if (activeUserCount > 0) { throw new BadRequestException( 'Cannot delete tenant with active users. Deactivate or reassign users first.', ); diff --git a/docs/anleitung-entwicklung.md b/docs/anleitung-entwicklung.md index 5943349..8338d12 100644 --- a/docs/anleitung-entwicklung.md +++ b/docs/anleitung-entwicklung.md @@ -145,13 +145,13 @@ Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem). zeigt intern auf `http://api:3001`) auf. 2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser - Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`/`req.tenantPrisma` + Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId` aus dem JWT) → `RolesGuard` (prüft `@Roles()`). 3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard` (`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`. 4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService` - (`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über den - tenant-gescopten Client aus `req.tenantPrisma` auf Postgres zugreift. + (`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen + dienst-intern per `forTenant()` gebundenen Client auf Postgres zugreift. 5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder Client-Komponente. @@ -296,16 +296,17 @@ Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden. Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als `APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt -SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend `req.tenantId` sowie -`req.tenantPrisma` — einen über `forTenant()` -(`apps/api/src/prisma/prisma-tenant.extension.ts`) erzeugten Prisma-Client, der vor **jeder** Query -in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt. +SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH +`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je +Service-Methode neu, über das Bindungshilfsmittel `forTenant()` +(`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion +`SELECT set_config('app.current_tenant', $1, true)` ausführt — der Guard selbst erzeugt keinen +Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt. -> Im Code existiert daneben eine gleichnamige `TenantMiddleware` -> (`apps/api/src/tenant/tenant.middleware.ts`) mit identischer Logik. Sie ist in `app.module.ts` -> nirgends über `.apply(...).forRoutes(...)` eingebunden — der tatsächlich aktive Mechanismus ist -> ausschließlich `TenantGuard`. Vereinzelte Code-Kommentare verweisen noch auf „TenantMiddleware“; -> gemeint ist in jedem Fall der Guard. +> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem +> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten +> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine +> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand. `app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber **nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`, @@ -318,12 +319,12 @@ RLS-Policy. **Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss `tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das ist im Code auch der gelebte Stil: `DkvService.loadConfig()` -(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain `PrismaService` (nicht -`req.tenantPrisma`) und filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle -das `tenantId`-Filter vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das -auffängt. Bei den sieben RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, -vorausgesetzt die Query läuft tatsächlich über den `tenantPrisma`-Client aus `req.tenantPrisma` -und nicht über den globalen `PrismaService`. +(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und +filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter +vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben +RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query +läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über +den globalen, ungebundenen `PrismaService`. ## Berechtigungen diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 3d0c040..92e65e9 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -47,6 +47,23 @@ entscheiden, ob die Controller künftig darüber gehen (dann bräuchte es keinen zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest. +**Entschieden (260911-e2s, Aufgabe 2):** ersatzloser Entfall, fuer ALLE +Bereiche der Etappe 2, nicht nur fuer `tenant`. Gemessen: außerhalb von +`tenant.middleware.ts` und `tenant.guard.ts` gab es KEINEN Leser (Suchumfang +oben bestaetigt, ebenso erneut gemessen in 260911-e2s Aufgabe 1, Befund B); +`tenant.middleware.ts` war zudem NIRGENDS verdrahtet (kein +`MiddlewareConsumer`, kein `configure(` in ganz `apps/api`, gemessen) +und hatte — anders als der urspruengliche Befund oben suggerierte — auch +KEINE eigenen Tests, ebenso wenig wie der Guard (`ls apps/api/src/tenant/` +vor 260911-e2s: einzige Testdatei war `tenant.service.spec.ts`). Entscheidung: +`tenant.middleware.ts` ist GELOESCHT, `tenant.guard.ts` setzt nur noch +`req.tenantId` und hat keine Prisma-Abhaengigkeit mehr. Grund: alle neun vor +diesem Bereich umgestellten Bereiche binden ausnahmslos dienst-intern (ein +Klient je Methode) — die Konvention ist durch Praxis entschieden, und tote +Verdrahtung, die wie ein Sicherheitsmechanismus aussieht, ist schlimmer als +keine. Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt +"## Bereich tenant", (n4)(a), fuer Messung und Begruendung im Volltext. + **WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und `TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle tragen ein nullbares `tenantId` (`SearchProvider` für admin-gepflegte @@ -125,12 +142,12 @@ autoritative Quelle. | dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | | auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand | | calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | -| tenant | 8 | 0 | unverändert | +| tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | favorites | 7 | 0 | unverändert | | settings | 4 | 0 | unverändert | -| **Summe** | **83** | **159** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), jetzt 159 nach 260911-cwh (zusätzlich 12 in `calendar`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **83** | **162** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0) und unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern — nur die Gebunden-Spalte änderte sich). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), jetzt 162 nach 260911-e2s (zusätzlich 3 in `tenant`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -188,13 +205,22 @@ Vermerk waere von einer vergessenen Nachziehung nicht zu unterscheiden — deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt stillschweigend uebersprungen zu werden. +**Stand 260911-e2s (Aufgabe 3):** 64 Paare — 63 aus dem vorherigen +Durchlauf plus EIN neues Paar (`tenant.controller.ts`/`user`, Klasse +`muss-mandantengebunden`, Stand `gebunden`): die drei gebundenen +Benutzerzähler des Fan-outs (Befund F/G aus 260911-e2s Aufgabe 1). Keine +bestehende Klasse verschiebt sich — die beiden `tenant`-Paare +(`tenant.controller.ts`/`tenant`, `tenant.service.ts`/`tenant`) bleiben +`keine-mandantengebundene-tabelle`/`ungebunden`, nur ihre Begründung wird +fortgeschrieben (siehe Fundstellentabelle unten). + | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 31 | +| muss-mandantengebunden | 32 | | keine-mandantengebundene-tabelle | 17 | | beides | 13 | | bewusst-uebergreifend | 2 | -| **Summe** | **63** | +| **Summe** | **64** | ## Der Hintergrunddienst als Falle — fünf Fälle @@ -333,6 +359,18 @@ Mandantenkennung der Anfrage, die sie ausgelöst hat, und kann strukturell keine andere haben. Kein Kandidat für diese Liste; dieser Absatz hält die Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht. +Auch der Bereich `tenant` fügt diesem Abschnitt keinen sechsten Fall hinzu, +gemessen statt angenommen (260911-e2s, Aufgabe 1, Befund J). Anweisung: +`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|Scheduler" apps/api/src/tenant --include=*.ts` +und `grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/tenant --include=*.ts` +liefern je null Treffer — kein Hintergrunddienst, kein Roh-SQL, keine +Transaktion in diesem Bereich. `TenantService.create` ruft nach dem Anlegen +eines Mandanten `groupsService.ensureDefaultGroup(tenant.id)` auf; dieser +Aufruf läuft seit 260909-jts bereits vollständig über den gebundenen Weg des +Bereichs `groups` (`forTenant()`/`withTenantTransaction()`, siehe +Fundstellentabelle unten, `groups.service.ts`/`group`, Stand `gebunden`) — +nichts an dieser Übergabe musste in 260911-e2s umgestellt werden. + ## Bestandsaufnahme Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit @@ -341,6 +379,17 @@ oder — seit 260909-ipc, Befund G — in `.` verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit 260909-ipc maschinell gegen den Quelltext geprüft), Begründung. +**Erkennungslücke, seit 260911-e2s vermessen (Aufgabe 1, (n4)(b)):** die +Bestandsaufnahme sieht ausschließlich (Datei, Modell)-Paare über +`this.prisma.` bzw. `.` — eine +Relationseinbindung (`include:`, Relationszähler `_count`) in eine ZWEITE +Tabelle erzeugt kein eigenes Paar und ist für das Werkzeug strukturell +unsichtbar. Alle 19 `include:`-Stellen und alle `_count`-Stellen des +API-Quelltexts wurden einzeln nachgesehen; die einzige gefährliche +Ausprägung (ungebundener äußerer Aufruf auf einer UNGESCHÜTZTEN Tabelle, +Einbindung in eine GESCHÜTZTE Tabelle) war `tenant.controller.ts` — hier in +Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). + | Datei | Modell | Klasse | Stand | Begründung | |---|---|---|---|---| | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | @@ -376,8 +425,9 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. | | apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. | | apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). | -| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. | +| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. | +| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. | +| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. | | apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. | | apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. | | apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) | @@ -409,7 +459,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit ## Was diese Etappe NICHT entscheidet -- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten +- ~~Ob Controller künftig über `req.tenantPrisma` statt eines erneuten `forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich `ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden — `forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu @@ -425,7 +475,15 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit `calendar` (260911-cwh) hat sich für denselben dienst-internen Weg entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts` erzeugt ihren eigenen `forTenant()`-Aufruf, wie alle acht Bereiche vor ihm. - Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen. + Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen.~~ + **Aufgelöst (260911-e2s):** die Frage ist für ALLE Bereiche entschieden, + nicht nur für `tenant` — dienst-intern, ein Klient je Methode, ist die + Konvention. Die Anfrageobjekt-Eigenschaft existiert nicht mehr: + `tenant.guard.ts` setzt nur noch `req.tenantId`, `tenant.middleware.ts` + (der nie verdrahtete Zwilling mit identischer Logik) ist gelöscht. Siehe + Abschnitt "Zwei belegte Befunde" oben und + `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich tenant", + (n4)(a). - ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource` am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach