diff --git a/apps/api/src/ldap/ldap-config.service.spec.ts b/apps/api/src/ldap/ldap-config.service.spec.ts index 379f0f7..5ccb60a 100644 --- a/apps/api/src/ldap/ldap-config.service.spec.ts +++ b/apps/api/src/ldap/ldap-config.service.spec.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LdapConfigService } from './ldap-config.service'; +// forTenant gibt in den Bestandstests denselben Client zurueck (tenant +// scoping ist dort nicht unter Test) — ohne diesen Mock bricht die Datei am +// blanken Prisma-Ersatz beim ersten `$extends`-Aufruf (Befund F, +// 260909-ipc-PLAN.md). Der eigene Bindungs-Testblock unten biegt die +// Implementierung auf ein zweites, unterscheidbares Client-Objekt um. +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; + /** * Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden * kann — Tessera muss sich damit am Domain Controller anmelden, braucht es @@ -176,3 +187,125 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => { await expect(service.onApplicationBootstrap()).resolves.toBeUndefined(); }); }); + +/** + * Aufgabe 2 (260909-ipc, WINDOWS #20 Etappe 2, T-IPC-01/T-IPC-02): belegt, + * dass die drei pro-Mandant-Methoden gebunden laufen, die beiden + * uebergreifenden Methoden es NICHT tun, und dass das Loeschen einer + * Feldzuordnung ohne Mandant nicht mehr moeglich ist. + */ +describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => { + let prisma: any; + let service: LdapConfigService; + + beforeEach(() => { + vi.clearAllMocks(); + prisma = { + ldapConfig: { + findUnique: vi.fn().mockResolvedValue(CONFIG_ROW), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })), + update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })), + }, + ldapFieldMapping: { + create: vi.fn((args: any) => Promise.resolve({ id: 'map1', isDefault: false, ...args.data })), + findUnique: vi.fn(), + delete: vi.fn((args: any) => Promise.resolve({ id: args.where.id })), + }, + }; + service = new LdapConfigService(prisma, crypto as any); + }); + + it('getConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { + await service.getConfig('t1'); + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.ldapConfig.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tenantId: 't1' } }), + ); + }); + + it('createConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { + await service.createConfig('t1', { + serverUrl: 'ldap://example', + baseDn: 'dc=example,dc=com', + } as any); + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.ldapConfig.create).toHaveBeenCalled(); + }); + + it('updateConfig() bindet ueber forTenant() an den uebergebenen Mandanten', async () => { + await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any); + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.ldapConfig.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { tenantId: 't1' } }), + ); + }); + + it('addFieldMapping() nimmt den Mandanten entgegen und schreibt gebunden', async () => { + await service.addFieldMapping('t1', 'cfg1', { + ldapField: 'department', + tesseraField: 'department', + } as any); + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.ldapFieldMapping.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ ldapConfigId: 'cfg1' }), + }), + ); + }); + + it('removeFieldMapping() nimmt den Mandanten entgegen, liest gebunden und loescht gebunden', async () => { + prisma.ldapFieldMapping.findUnique.mockResolvedValue({ + id: 'map1', + ldapConfigId: 'cfg1', + isDefault: false, + }); + + const result = await service.removeFieldMapping('t1', 'map1'); + + expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); + expect(prisma.ldapFieldMapping.findUnique).toHaveBeenCalledWith({ + where: { id: 'map1' }, + }); + expect(prisma.ldapFieldMapping.delete).toHaveBeenCalledWith({ + where: { id: 'map1' }, + }); + expect(result).toEqual({ id: 'map1' }); + }); + + it('removeFieldMapping() liefert null, wenn die Zuordnung unter diesem Mandanten nicht sichtbar ist (T-IPC-01)', async () => { + // Simuliert die RLS-Wirkung: unter dem Mandantenkontext von t1 ist eine + // fremde Feldzuordnung (Mandant t2) unsichtbar — findUnique liefert null, + // genau wie es die echte Policy nach dem Scharfschalten taete. + prisma.ldapFieldMapping.findUnique.mockResolvedValue(null); + + const result = await service.removeFieldMapping('t1', 'map-fremd'); + + expect(result).toBeNull(); + expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled(); + }); + + it('removeFieldMapping() schuetzt Vorgabe-Zuordnungen weiterhin, auch gebunden', async () => { + prisma.ldapFieldMapping.findUnique.mockResolvedValue({ + id: 'map1', + ldapConfigId: 'cfg1', + isDefault: true, + }); + + await expect(service.removeFieldMapping('t1', 'map1')).rejects.toThrow( + 'Cannot delete default field mappings', + ); + expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled(); + }); + + it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => { + await service.getAllActiveConfigs(); + expect(forTenant).not.toHaveBeenCalled(); + }); + + it('onApplicationBootstrap() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => { + prisma.ldapConfig.findMany.mockResolvedValue([]); + await service.onApplicationBootstrap(); + expect(forTenant).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts index a17b864..0284de7 100644 --- a/apps/api/src/ldap/ldap-config.service.ts +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; import { CreateFieldMappingDto, CreateLdapConfigDto, @@ -51,6 +52,14 @@ export class LdapConfigService implements OnApplicationBootstrap { * is logged and swallowed: a tenant whose bind password could not be * re-encrypted still authenticates, because the read path below tolerates a * legacy plaintext value. + * + * BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B): + * dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen, + * bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert + * strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser + * Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum + * Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3 + * (Systemkontext), diese Umstellung entscheidet sie nicht. */ async onApplicationBootstrap(): Promise { try { @@ -120,9 +129,15 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Get LDAP config for a tenant, including field mappings. + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): der Mandant ist + * hier bereits aus der Anfrage bekannt (Parameter), also ueber + * `forTenant()` gebunden — anders als `getAllActiveConfigs()` unten, die + * bewusst ueber alle Mandanten liest. */ async getConfig(tenantId: string) { - const config = await this.prisma.ldapConfig.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const config = await tenantPrisma.ldapConfig.findUnique({ where: { tenantId }, include: { fieldMappings: true }, }); @@ -132,9 +147,17 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Create LDAP config for a tenant with default field mappings (D-16). * Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc). Das verschachtelte + * Anlegen der drei Vorgabe-Zuordnungen bleibt eine einzige Prisma-Operation + * und laeuft damit in derselben `forTenant()`-Transaktion wie das Setzen + * des Kontexts — dass diese Schreibweise unter der Policy traegt, ist in + * Aufgabe 1 (260909-ipc-PLAN.md) gegen die echte, ausgelieferte Policy + * gemessen. */ async createConfig(tenantId: string, dto: CreateLdapConfigDto) { - const created = await this.prisma.ldapConfig.create({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const created = await tenantPrisma.ldapConfig.create({ data: { tenantId, serverUrl: dto.serverUrl, @@ -172,9 +195,12 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Update LDAP config for a tenant. + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc). */ async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) { - const updated = await this.prisma.ldapConfig.update({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const updated = await tenantPrisma.ldapConfig.update({ where: { tenantId }, data: { ...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }), @@ -210,9 +236,19 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Add a custom field mapping to an LDAP config (D-17). + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc): `LdapFieldMapping` + * hat keine eigene `tenantId`-Spalte, ihre RLS-Sichtbarkeit kommt ueber + * den Join auf `LdapConfig`. Der Mandant ist typseitig Pflicht (erster + * Parameter) — ein Aufruf ohne Mandant ist damit nicht mehr moeglich. */ - async addFieldMapping(configId: string, dto: CreateFieldMappingDto) { - return this.prisma.ldapFieldMapping.create({ + async addFieldMapping( + tenantId: string, + configId: string, + dto: CreateFieldMappingDto, + ) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.ldapFieldMapping.create({ data: { ldapConfigId: configId, ldapField: dto.ldapField, @@ -225,9 +261,20 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Remove a field mapping. Only non-default mappings can be deleted. * System-provided defaults (isDefault=true) are protected. + * + * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc, T-IPC-01): schliesst + * die bisherige Fremdzugriffsluecke — `DELETE /ldap/config/mappings/:id` + * nahm bislang ausschliesslich die Kennung entgegen, ein Administrator des + * Mandanten A konnte damit die Feldzuordnung des Mandanten B loeschen, + * wenn er deren Kennung kannte. Sowohl das Lesen als auch das Loeschen + * laufen jetzt ueber `forTenant()`; eine Zuordnung, die unter diesem + * Mandanten nicht sichtbar ist (RLS-Join auf `LdapConfig`), liefert + * `findUnique` null zurueck — die Steuerung macht daraus 404 statt einer + * Loeschung. */ - async removeFieldMapping(mappingId: string) { - const mapping = await this.prisma.ldapFieldMapping.findUnique({ + async removeFieldMapping(tenantId: string, mappingId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const mapping = await tenantPrisma.ldapFieldMapping.findUnique({ where: { id: mappingId }, }); @@ -239,7 +286,7 @@ export class LdapConfigService implements OnApplicationBootstrap { throw new Error('Cannot delete default field mappings'); } - return this.prisma.ldapFieldMapping.delete({ + return tenantPrisma.ldapFieldMapping.delete({ where: { id: mappingId }, }); } @@ -247,6 +294,16 @@ export class LdapConfigService implements OnApplicationBootstrap { /** * Get all active LDAP configs. Used by the scheduler to determine which * tenants need auto-sync. + * + * BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B): + * der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER + * Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist + * die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf. + * Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der + * LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne + * Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E, + * docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung + * (Systemkontext) gehoert nach Etappe 3. */ async getAllActiveConfigs() { const configs = await this.prisma.ldapConfig.findMany({ diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 558961b..6caba17 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -337,17 +337,31 @@ export class LdapController { throw new NotFoundException('No LDAP config found for this tenant'); } - return this.ldapConfigService.addFieldMapping(config.id, dto); + return this.ldapConfigService.addFieldMapping(tenantId, config.id, dto); } /** * DELETE /ldap/config/mappings/:id - Remove non-default field mapping. + * + * Der Mandant kommt aus dem Sitzungsnachweis, NICHT aus der URL (T-IPC-01, + * WINDOWS #20 Etappe 2, 260909-ipc): vorher nahm diese Route + * ausschliesslich die Kennung entgegen und reichte sie ungebunden an den + * Dienst weiter — ein Administrator des Mandanten A konnte damit die + * Feldzuordnung des Mandanten B loeschen, wenn er deren Kennung kannte. */ @Delete('config/mappings/:id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async removeFieldMapping(@Param('id') id: string) { + async removeFieldMapping(@Req() req: any, @Param('id') id: string) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + try { - const result = await this.ldapConfigService.removeFieldMapping(id); + const result = await this.ldapConfigService.removeFieldMapping( + tenantId, + id, + ); if (!result) { throw new NotFoundException('Field mapping not found'); } diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index f9a0f08..4ba286b 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -9,15 +9,45 @@ import { describe, expect, it } from 'vitest'; * ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND * Modellname — eine Zeilennummer traegt nicht, das ueberlebt das * Verschieben einer Zeile. + * + * Erweitert in Aufgabe 2 (260909-ipc, Befund G): eine Umstellung auf + * `forTenant()` laesst `this.prisma.` aus dem Quelltext + * verschwinden. Ohne eine zweite Erkennung fuer gebundene Zugriffe wuerde + * diese Pruefung eine Umstellung als "Fundstelle verschwunden" werten und + * zwingen, den Nachweis aus dem Dokument zu LOESCHEN statt ihn + * fortzuschreiben. Die zweite Erkennung sammelt je Datei die Zuweisungen + * der Form `const = forTenant(` und sucht danach `.`. + * Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand: + * `gebunden`, `ungebunden` oder `gemischt`. */ const API_SRC_DIR = join(__dirname, '..'); const REPO_ROOT = join(__dirname, '../../../..'); const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md'); -interface AccessSite { +/** + * Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten + * `const = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen + * den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`) + * statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die + * offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md + * ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt + * stillschweigend als Erkennungsluecke durchzurutschen. + */ +const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([ + 'apps/api/src/tenant/tenant.middleware.ts', + 'apps/api/src/tenant/tenant.guard.ts', +]); + +const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const; +type Stand = (typeof STAND_TOKENS)[number]; + +interface FileAnalysis { file: string; - model: string; + unboundModels: Set; + boundModels: Set; + totalForTenantCalls: number; + assignmentFormCalls: number; } function listTsFiles(dir: string): string[] { @@ -35,8 +65,8 @@ function listTsFiles(dir: string): string[] { /** * Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus, - * bevor nach `this.prisma.` gesucht wird — sonst zaehlt eine - * erklaerende Kopfzeile als Fundstelle mit. + * bevor nach `this.prisma.` oder `forTenant(` gesucht wird — sonst + * zaehlt eine erklaerende Kopfzeile als Fundstelle mit. */ function stripComments(source: string): string { return source @@ -46,26 +76,79 @@ function stripComments(source: string): string { .join('\n'); } -function findAccessSites(): AccessSite[] { - const files = listTsFiles(API_SRC_DIR); - const sites: AccessSite[] = []; +function analyzeFile(absPath: string, relPath: string): FileAnalysis { + const source = stripComments(readFileSync(absPath, 'utf-8')); - for (const absPath of files) { - const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); - const source = stripComments(readFileSync(absPath, 'utf-8')); - const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g); - const models = new Set(); - for (const m of matches) { - if (m[1]) models.add(m[1]); - } - for (const model of models) { - sites.push({ file: relPath, model }); + const unboundModels = new Set(); + for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) { + if (m[1]) unboundModels.add(m[1]); + } + + const assignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forTenant\(/g)]; + const boundNames = new Set(assignmentMatches.map((m) => m[1]).filter(Boolean) as string[]); + + const boundModels = new Set(); + for (const name of boundNames) { + const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g'); + for (const m of source.matchAll(re)) { + if (m[1]) boundModels.add(m[1]); } } + // Zaehlt Aufrufstellen von `forTenant(`, aber nicht die Funktionsdefinition + // selbst (`export function forTenant(...)` in prisma-tenant.extension.ts) — + // die Definition ist kein Aufruf und braucht keine Zuweisungsform. + const totalForTenantCalls = [...source.matchAll(/(? { + const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); + return analyzeFile(absPath, relPath); + }) + .sort((a, b) => a.file.localeCompare(b.file)); +} + +interface AccessSite { + file: string; + model: string; +} + +function findAccessSites(analyses: FileAnalysis[]): AccessSite[] { + const sites: AccessSite[] = []; + for (const a of analyses) { + const allModels = new Set([...a.unboundModels, ...a.boundModels]); + for (const model of allModels) { + sites.push({ file: a.file, model }); + } + } return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model)); } +function computeStandByKey(analyses: FileAnalysis[]): Map { + const standByKey = new Map(); + for (const a of analyses) { + const allModels = new Set([...a.unboundModels, ...a.boundModels]); + for (const model of allModels) { + const isBound = a.boundModels.has(model); + const isUnbound = a.unboundModels.has(model); + const stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden'; + standByKey.set(`${a.file}::${model}`, stand); + } + } + return standByKey; +} + const CLASS_TOKENS = [ 'muss-mandantengebunden', 'bewusst-uebergreifend', @@ -73,16 +156,25 @@ const CLASS_TOKENS = [ 'beides', ]; -function parseDocEntries(): { file: string; model: string; klasse: string }[] { - const raw = readFileSync(DOC_PATH, 'utf-8'); - const entries: { file: string; model: string; klasse: string }[] = []; +interface DocEntry { + file: string; + model: string; + klasse: string; + stand: string; +} - // Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |` - const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm; +function parseDocEntries(): DocEntry[] { + const raw = readFileSync(DOC_PATH, 'utf-8'); + const entries: DocEntry[] = []; + + // Nur Zeilen aus der Bestandsaufnahme-Tabelle: + // `| Datei | Modell | Klasse | Stand | Begruendung |` + const rowPattern = + /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|\s*([a-z-]+)\s*\|/gm; for (const match of raw.matchAll(rowPattern)) { - const [, file, model, klasse] = match; - entries.push({ file, model, klasse }); + const [, file, model, klasse, stand] = match; + entries.push({ file, model, klasse, stand }); } return entries; @@ -93,7 +185,9 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst expect(() => statSync(DOC_PATH)).not.toThrow(); }); - const sourceSites = findAccessSites(); + const analyses = analyzeAllFiles(); + const sourceSites = findAccessSites(analyses); + const standByKey = computeStandByKey(analyses); const docEntries = parseDocEntries(); const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`)); const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`)); @@ -109,7 +203,7 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]); }); - it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => { + it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext (gebunden oder ungebunden)', () => { const stale = [...docKeys].filter((key) => !sourceKeys.has(key)); expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]); }); @@ -119,6 +213,26 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst expect(invalid, JSON.stringify(invalid)).toEqual([]); }); + it('jeder Eintrag traegt einen der drei gueltigen Stand-Werte', () => { + const invalid = docEntries.filter((e) => !STAND_TOKENS.includes(e.stand as Stand)); + expect(invalid, JSON.stringify(invalid)).toEqual([]); + }); + + it('der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein', () => { + const mismatches: string[] = []; + for (const e of docEntries) { + const measured = standByKey.get(`${e.file}::${e.model}`); + if (measured && measured !== e.stand) { + mismatches.push( + `${e.file}::${e.model} — dokumentiert=${e.stand}, gemessen=${measured}`, + ); + } + } + expect(mismatches, `Abweichender Stand (Dokument vs. Quelltext):\n${mismatches.join('\n')}`).toEqual( + [], + ); + }); + it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => { const seen = new Set(); const duplicates: string[] = []; @@ -129,4 +243,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst } expect(duplicates).toEqual([]); }); + + it('jedes forTenant(-Vorkommen entspricht der erkannten Zuweisungsform `const X = forTenant(` oder steht in der begruendeten Ausnahmeliste', () => { + const violations: string[] = []; + for (const a of analyses) { + const unmatched = a.totalForTenantCalls - a.assignmentFormCalls; + if (unmatched > 0 && !FORTENANT_ASSIGNMENT_EXCEPTIONS.has(a.file)) { + violations.push( + `${a.file}: ${unmatched} forTenant(-Aufruf(e) ausserhalb der erkannten Zuweisungsform`, + ); + } + } + expect(violations, violations.join('\n')).toEqual([]); + }); }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 82d09b7..5c94192 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -84,15 +84,24 @@ am 2026-09-09, **nach** den Änderungen aus Aufgabe 1/2 dieses Plans: | settings | 4 | unverändert | | **Summe** | **227** | war 232 in measured_baseline, Delta = die 5 in Aufgabe 2 verschwundenen `auth`-Treffer minus ein bereits vorher fehlerhaft mitgezähltes Kommentarvorkommen in der neuen Kopfzeile von `validateUser()`, das bewusst umformuliert wurde, um einen Eigentreffer der Bestandsaufnahme-Prüfung zu vermeiden | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 59 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare) + +Stand 260909-ipc (Aufgabe 2): 59 Paare aus der urspruenglichen Zaehlung plus +zwei bisher unentdeckte, weil bereits gebundene Fundstellen +(`auth.service.ts`/`passwordResetToken`, `ldap.service.ts`/`groupMembership`), +die erst die um gebundene Zugriffe erweiterte Erkennung (Befund G) sichtbar +macht — sie waren nie Teil der 227 `this.prisma.*`-Rohtrefferzahl, weil sie +schon vor diesem Plan über `forTenant()` liefen. Dazu die Korrektur von +(`ldap-config.service.ts`, `ldapConfig`) von `muss-mandantengebunden` auf +`beides` (Befund B). | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 31 | +| muss-mandantengebunden | 32 | | keine-mandantengebundene-tabelle | 16 | -| beides | 9 | +| beides | 10 | | bewusst-uebergreifend | 3 | -| **Summe** | **59** | +| **Summe** | **61** | ## Der Hintergrunddienst als Falle — drei `beides`-Fälle @@ -124,69 +133,73 @@ betroffen: Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.` -verwendet), Klasse, Begründung. +oder — seit 260909-ipc, Befund G — in `.` +verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit +260909-ipc maschinell gegen den Quelltext geprüft), Begründung. -| Datei | Modell | Klasse | Begründung | -|---|---|---|---| -| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | -| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). | -| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. | -| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. | -| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. | -| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | 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 | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb eines Mandanten. | -| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | Wie groups.service.ts. | -| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. | -| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. | -| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. | -| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | Zielbenutzer eines Grants innerhalb des Mandanten. | -| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | muss-mandantengebunden | LDAP-Konfiguration je Mandant, `tenantId`-Spalte (unique) vorhanden. | -| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `LdapConfig`. | -| apps/api/src/ldap/ldap.service.ts | group | beides | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). | -| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. | -| apps/api/src/ldap/ldap.service.ts | user | beides | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. | -| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId`. | -| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. | -| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit. | -| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant. | -| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. | -| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | `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 | Dieselbe Begründung. | -| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | `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 | 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 | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) | -| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | Dieselbe Begründung. | -| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. | -| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. | -| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. | -| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. | -| apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). | -| apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". | -| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). | -| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. | -| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". | -| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. | -| apps/api/src/tenders/tender-matching.service.ts | user | beides | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. | -| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. | -| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. | -| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. | -| apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. | -| apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | Lesezugriff auf den plattformweiten Katalog (D-03). | -| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. | -| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. | -| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. | -| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. | -| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. | -| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | Dieselbe Begründung. | +| 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. | +| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | +| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). | +| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. | +| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | ungebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. | +| 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/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. | +| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. | +| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | ungebunden | Zielbenutzer eines Grants innerhalb des Mandanten. | +| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | gemischt | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` bleiben bewusst uebergreifend: beide laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md). Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. | +| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten jetzt als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. | +| apps/api/src/ldap/ldap.service.ts | group | beides | gemischt | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). | +| apps/api/src/ldap/ldap.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `Group`. `syncGroupMembershipsForTenant` laeuft vollstaendig ueber `forTenant()` (Plan 16-03/16-05) — 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. | +| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | ungebunden | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. | +| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. | +| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. | +| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. | +| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. | +| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. | +| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. | +| 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/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) | +| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. | +| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | ungebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. | +| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | ungebunden | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. | +| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | ungebunden | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. | +| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. | +| apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). | +| apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". | +| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). | +| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. | +| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | ungebunden | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". | +| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. | +| apps/api/src/tenders/tender-matching.service.ts | user | beides | ungebunden | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. | +| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. | +| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. | +| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | ungebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. | +| apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. | +| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | ungebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. | +| apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Lesezugriff auf den plattformweiten Katalog (D-03). | +| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. | +| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. | +| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. | +| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | ungebunden | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. | +| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | ungebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. | +| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | ungebunden | Dieselbe Begründung. | ## Was diese Etappe NICHT entscheidet