diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 5045d33..725bbbb 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -35,11 +35,27 @@ import { describe, expect, it } from 'vitest'; * 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. + * + * Erweitert in 260911-mkj (WINDOWS #27): keine der drei Formen oben sieht + * einen Zugriff, der ueber `include:`/`select:`/`_count:` aus einem + * erkannten Modellaufruf HERAUS in eine ZWEITE Tabelle reicht — Prisma + * rendert das als Unterabfrage/Join auf die zweite Tabelle unter DEREN + * Regel, aber weder `this.prisma.` noch `.` noch `.` enthalten das Zielmodell als + * eigenen Text. Die vierte Erkennung loest Relationsfelder ueber + * `schema.prisma` (`parseSchemaRelations`, `SCHEMA_RELATIONS`) auf ihr + * Zielmodell auf und traegt das Zielmodell als eigene Fundstelle derselben + * Datei ein — gebunden, wenn der Empfaenger des Ankeraufrufs gebunden ist, + * sonst ungebunden. Ein Waechter (Rohzahl `include|select|_count` ueber den + * ganzen kommentarfreien Quelltext gegen die innerhalb erkannter Aufrufe + * gezaehlte Zahl) haelt die Grenze der Erkennung laut, nicht still — siehe + * `RELATION_SPEC_EXCEPTIONS` unten. */ const API_SRC_DIR = join(__dirname, '..'); const REPO_ROOT = join(__dirname, '../../../..'); const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md'); +const SCHEMA_PATH = join(__dirname, '../../prisma/schema.prisma'); /** * Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten @@ -77,6 +93,23 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([]); */ const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set([]); +/** + * Dateien, in denen eine `include:`/`select:`/`_count:`-Angabe ausserhalb + * jedes von der vierten Erkennung erfassten Modellaufrufs liegt (260911-mkj, + * WINDOWS #27). Gemessen zur Planungszeit: `backfill-tender-source.ts` ist + * ein eigenstaendiges Skript mit eigenem `new PrismaClient()` — sein + * `select:` (Zeile 34) liegt auf der plattformglobalen Tabelle `Tender` + * (Migration 20260909140000, Gruppe b, kein Zeilenschutz); der Empfaenger + * `prisma` dieser Datei ist fuer KEINE der vier Erkennungsformen erreichbar + * (weder `this.prisma` noch eine `forTenant(`-Zuweisung noch ein + * Transaktionsparameter). Zusammen mit `tenders.seed.ts` + * (Funktionsparameter `prisma: PrismaService`, keine Relationsangabe, + * deshalb hier nicht gelistet) als eigener Ledger-Eintrag gefuehrt: + * WINDOWS #TBD-MKJ (Aufgabe 2 ersetzt den Platzhalter durch die vergebene + * Nummer). + */ +const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill-tender-source.ts']); + const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const; type Stand = (typeof STAND_TOKENS)[number]; @@ -88,6 +121,9 @@ interface FileAnalysis { assignmentFormCalls: number; rawInteractiveTransactionCount: number; matchedInteractiveTransactionCount: number; + rawRelationSpecCount: number; + matchedRelationSpecCount: number; + unresolvedRelationSpecValues: string[]; } function listTsFiles(dir: string): string[] { @@ -116,8 +152,216 @@ function stripComments(source: string): string { .join('\n'); } -function analyzeFile(absPath: string, relPath: string): FileAnalysis { - const source = stripComments(readFileSync(absPath, 'utf-8')); +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Sucht ab `openIndex` (der Position von `openChar`) die passende + * schliessende Klammer per Klammertiefe. Verwendet fuer sowohl `(`/`)` + * (Argumentbereich eines Ankeraufrufs) als auch `{`/`}` (Objektliteral + * einer aufgeloesten Konstante) — 260911-mkj, WINDOWS #27. + */ +function findMatchingBracket(text: string, openIndex: number, openChar: string, closeChar: string): number { + let depth = 0; + for (let i = openIndex; i < text.length; i++) { + const ch = text[i]; + if (ch === openChar) depth++; + else if (ch === closeChar) { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +/** + * Ersetzt den INHALT jedes Zeichenkettenliterals (einfach, doppelt, + * Backtick) durch nichts, die Anfuehrungszeichen bleiben — NUR fuer die + * vierte Erkennung (260911-mkj, WINDOWS #27): eine Zeichenkette wie + * `contains: '{('` darf die Klammertiefenzaehlung des Argumentbereichs + * nicht zerreissen. Die Formen 1-3 arbeiten weiter auf dem unveraenderten + * kommentarfreien Quelltext (`source`), damit eine Vorlagen-Interpolation + * wie `${tx.user.count()}` fuer sie sichtbar bleibt. + */ +function blankStringLiterals(text: string): string { + return text.replace( + /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g, + (literal) => literal[0] + literal[literal.length - 1], + ); +} + +function lowerFirst(name: string): string { + return name.length > 0 ? name.charAt(0).toLowerCase() + name.slice(1) : name; +} + +/** + * Liest `schema.prisma` zur TESTZEIT (dieselbe Idiomatik wie das Lesen der + * Migrationen in `rls-coverage.spec.ts`) und bildet je `model`-Block eine + * Map Feld -> Zielmodell, aber NUR fuer Felder, deren Typ selbst ein + * Modellname ist (260911-mkj, WINDOWS #27). + * + * Der Lookahead `(?=\s|$)` ist zwingend: eine Feldzeile wie + * `users User[]` in `Tenant` steht am ZEILENENDE. Ohne den Lookahead + * verliert das Muster jede Listenrelation, deren Typname direkt vor dem + * Zeilenende steht — der erste Fehler des Planungs-Prototyps, `Tenant` + * hatte damit scheinbar keine Relation. Nicht wiederholen. + */ +function parseSchemaRelations(schemaSource: string): Map> { + const modelBlockPattern = /^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm; + const blocks: Array<{ name: string; body: string }> = []; + for (const m of schemaSource.matchAll(modelBlockPattern)) { + if (m[1] !== undefined && m[2] !== undefined) { + blocks.push({ name: m[1], body: m[2] }); + } + } + const modelNames = new Set(blocks.map((b) => b.name)); + + const fieldPattern = /^\s*(\w+)\s+(\w+)(?:\[\]|\?)?(?=\s|$)/gm; + const relations = new Map>(); + for (const { name, body } of blocks) { + const fieldMap = new Map(); + for (const fm of body.matchAll(fieldPattern)) { + const fieldName = fm[1]; + const fieldType = fm[2]; + if (fieldName && fieldType && modelNames.has(fieldType)) { + fieldMap.set(fieldName, fieldType); + } + } + relations.set(name, fieldMap); + } + return relations; +} + +const SCHEMA_RELATIONS = parseSchemaRelations(readFileSync(SCHEMA_PATH, 'utf-8')); + +/** + * Kleiner Anfangsbuchstabe -> Modellname (`Tenant` -> `tenant`, + * `LdapFieldMapping` -> `ldapFieldMapping`) — derselbe Schluessel wie in + * der Spalte `Modell` der Bestandsaufnahme und in `this.prisma.` + * (260911-mkj, WINDOWS #27). + */ +const CLIENT_NAME_TO_MODEL = new Map( + [...SCHEMA_RELATIONS.keys()].map((modelName) => [lowerFirst(modelName), modelName]), +); + +const RELATION_ANCHOR_OPERATIONS = [ + 'findMany', + 'findFirst', + 'findUnique', + 'findFirstOrThrow', + 'findUniqueOrThrow', + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'updateManyAndReturn', + 'upsert', + 'delete', + 'deleteMany', + 'count', + 'aggregate', + 'groupBy', +]; +const RELATION_ANCHOR_OPERATIONS_PATTERN = RELATION_ANCHOR_OPERATIONS.join('|'); + +/** + * Loest `select: NAME`/`include: NAME` gegen eine im selben Quelltext + * definierte Objektliteral-Konstante `const NAME = { ... }` auf + * (260911-mkj, WINDOWS #27, Waechter (b)). Findet sich keine, ist der + * Aufrufer dafuer zustaendig, die Kennung als unaufloesbar zu vermerken. + */ +function resolveConstantObjectLiteral(blank: string, name: string): string | null { + const declPattern = new RegExp(`\\bconst\\s+${name}\\b[^=]*=\\s*\\{`); + const m = declPattern.exec(blank); + if (!m) return null; + const braceStart = m.index + m[0].length - 1; + const braceEnd = findMatchingBracket(blank, braceStart, '{', '}'); + if (braceEnd === -1) return null; + return blank.slice(braceStart, braceEnd + 1); +} + +interface RelationScanFrame { + context: string; + enteringKey: string | null; +} + +/** + * Laeuft mit einem Kontextstapel ueber den Argumentbereich eines erkannten + * Modellaufrufs (260911-mkj, WINDOWS #27, PLAN.md Aufgabe 1 Schritt 3(f)). + * Start-Kontext ist das Modell des Ankers. Ein Schluessel, der ein + * Relationsfeld des AKTUELLEN Kontextmodells ist, traegt das Zielmodell als + * eigene Fundstelle ein (gebunden/ungebunden nach dem Empfaenger des + * Ankers) und wird zum Kontext des naechsten `{`; `_count: true` direkt + * unter `include`/`select` traegt ALLE Relationen des aktuellen + * Kontextmodells ein. Jeder andere Schluessel laesst den Kontext + * unveraendert (`where`, `data`, `some`, `every`, `none`, `is`, `isNot`, + * `connect`, `create`, Operatoren wie `contains`, Skalare) — `{` schiebt + * den vorgemerkten (sonst den aktuellen) Kontext, `}` nimmt ihn zurueck, + * `,` loescht die Vormerkung. + */ +function scanRelationKeys( + region: string, + initialContext: string, + isBound: boolean, + unboundModels: Set, + boundModels: Set, +): void { + const stack: RelationScanFrame[] = [{ context: initialContext, enteringKey: null }]; + let pendingContext: string | null = null; + let pendingKey: string | null = null; + + const tokenPattern = /\{|\}|,|\b([A-Za-z_]\w*)\s*:/g; + let match: RegExpExecArray | null = tokenPattern.exec(region); + while (match !== null) { + const token = match[0]; + if (token === '{') { + const currentContext = stack[stack.length - 1].context; + stack.push({ context: pendingContext ?? currentContext, enteringKey: pendingKey }); + pendingContext = null; + pendingKey = null; + } else if (token === '}') { + if (stack.length > 1) stack.pop(); + pendingContext = null; + pendingKey = null; + } else if (token === ',') { + pendingContext = null; + pendingKey = null; + } else { + const keyName = match[1] ?? null; + const currentContext = stack[stack.length - 1].context; + const relTarget = keyName ? SCHEMA_RELATIONS.get(currentContext)?.get(keyName) : undefined; + if (keyName && relTarget) { + const clientName = lowerFirst(relTarget); + (isBound ? boundModels : unboundModels).add(clientName); + pendingContext = relTarget; + pendingKey = keyName; + } else if (keyName === '_count') { + const parentKey = stack[stack.length - 1].enteringKey; + const afterColon = region.slice(match.index + match[0].length); + const isLiteralTrue = /^\s*true\b/.test(afterColon); + if (isLiteralTrue && (parentKey === 'include' || parentKey === 'select')) { + const relations = SCHEMA_RELATIONS.get(currentContext); + if (relations) { + for (const target of relations.values()) { + (isBound ? boundModels : unboundModels).add(lowerFirst(target)); + } + } + } + pendingContext = currentContext; + pendingKey = keyName; + } else { + pendingContext = currentContext; + pendingKey = keyName; + } + } + match = tokenPattern.exec(region); + } +} + +function analyzeSource(rawSource: string, relPath: string): FileAnalysis { + const source = stripComments(rawSource); const unboundModels = new Set(); for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) { @@ -160,6 +404,13 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis { ? 0 : [...source.matchAll(/\.\$transaction\(\s*async\b/g)].length; + // Sammelt je Datei die Parameternamen BEIDER interaktiver Transaktionsformen + // mit ihrer Bindung — 260911-mkj nutzt diese Mengen als zusaetzliche + // erkannte Empfaengerformen der vierten Erkennung (bislang wurden sie nur + // lokal verbraucht). + const txBoundParams: string[] = []; + const txUnboundParams: string[] = []; + // Form 1: `.$transaction(async () => ...)`. // ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben). const directInteractiveMatches = [ @@ -172,6 +423,8 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis { const param = m[2]; if (!param) continue; const isBound = boundNames.has(receiver); + if (isBound) txBoundParams.push(param); + else txUnboundParams.push(param); const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g'); for (const mm of source.matchAll(re)) { if (!mm[1]) continue; @@ -192,12 +445,73 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis { for (const m of withTenantTransactionMatches) { const param = m[1]; if (!param) continue; + txBoundParams.push(param); const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g'); for (const mm of source.matchAll(re)) { if (mm[1]) boundModels.add(mm[1]); } } + // Vierte Erkennung (260911-mkj, WINDOWS #27): Relationszugriffe. `blank` + // ist NUR fuer diese Form — Zeichenkettenliteral-Inhalte sind entfernt, + // damit eine Zeichenkette wie `contains: '{('` die Klammertiefenzaehlung + // nicht zerreisst. + const blank = blankStringLiterals(source); + + const allReceiverNames = new Set([ + 'this.prisma', + ...boundNames, + ...txBoundParams, + ...txUnboundParams, + ]); + const boundReceiverNames = new Set([...boundNames, ...txBoundParams]); + const receiverAlternation = [...allReceiverNames].map(escapeRegExp).join('|'); + const anchorPattern = new RegExp( + `\\b(${receiverAlternation})\\.([a-zA-Z]+)\\.(?:${RELATION_ANCHOR_OPERATIONS_PATTERN})\\(`, + 'g', + ); + + const unresolvedRelationSpecValues: string[] = []; + let matchedRelationSpecCount = 0; + + for (const m of blank.matchAll(anchorPattern)) { + const receiver = m[1]; + const modelClientName = m[2]; + if (!receiver || !modelClientName || m.index === undefined) continue; + + const isBound = boundReceiverNames.has(receiver); + const openIndex = m.index + m[0].length - 1; + const closeIndex = findMatchingBracket(blank, openIndex, '(', ')'); + if (closeIndex === -1) continue; + + let region = blank.slice(openIndex, closeIndex + 1); + matchedRelationSpecCount += (region.match(/\b(?:include|select|_count)\s*:/g) || []).length; + + // Wertform (Waechter (b)): eine Kennung als include:/select:-Wert wird + // gegen eine gleichnamige, in derselben Datei definierte + // Objektliteral-Konstante aufgeloest, sonst als unaufloesbar vermerkt. + region = region.replace( + /\b(include|select)\s*:\s*([A-Za-z_]\w*)\b(?!\s*[.(])/g, + (full: string, keyword: string, ident: string) => { + if (ident === 'true' || ident === 'false') return full; + const resolved = resolveConstantObjectLiteral(blank, ident); + if (resolved) return `${keyword}: ${resolved}`; + unresolvedRelationSpecValues.push(`${relPath}: ${keyword}: ${ident}`); + return full; + }, + ); + + const initialContext = CLIENT_NAME_TO_MODEL.get(modelClientName); + if (initialContext) { + scanRelationKeys(region, initialContext, isBound, unboundModels, boundModels); + } + } + + // Rohzahl ueber den GANZEN kommentarfreien Quelltext (nicht `blank`) — eine + // Angabe in einer Vorlagen-Interpolation soll raw zaehlen und damit laut + // werden, nicht still verschwinden (Waechter (a)). + const rawRelationSpecCount = (source.match(/\b(?:include|select|_count)\s*:/g) || []).length; + return { file: relPath, unboundModels, @@ -206,9 +520,17 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis { assignmentFormCalls: assignmentMatches.length, rawInteractiveTransactionCount, matchedInteractiveTransactionCount: directInteractiveMatches.length, + rawRelationSpecCount, + matchedRelationSpecCount, + unresolvedRelationSpecValues, }; } +function analyzeFile(absPath: string, relPath: string): FileAnalysis { + const rawSource = readFileSync(absPath, 'utf-8'); + return analyzeSource(rawSource, relPath); +} + function analyzeAllFiles(): FileAnalysis[] { const files = listTsFiles(API_SRC_DIR); return files @@ -391,4 +713,203 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst } expect(violations, violations.join('\n')).toEqual([]); }); + + it('jede include:/select:/_count:-Angabe liegt innerhalb eines erkannten Modellaufrufs oder die Datei steht in der begruendeten Ausnahmeliste (260911-mkj, WINDOWS #27)', () => { + const violations: string[] = []; + for (const a of analyses) { + const unmatched = a.rawRelationSpecCount - a.matchedRelationSpecCount; + if (unmatched > 0 && !RELATION_SPEC_EXCEPTIONS.has(a.file)) { + violations.push( + `${a.file}: ${unmatched} include:/select:/_count:-Angabe(n) ausserhalb eines erkannten Modellaufrufs`, + ); + } + } + expect(violations, violations.join('\n')).toEqual([]); + }); + + it('keine veraltete RELATION_SPEC_EXCEPTIONS-Liste: jede Datei existiert und traegt tatsaechlich einen Ueberschuss include:/select:/_count: ausserhalb eines erkannten Modellaufrufs (260911-mkj)', () => { + const staleEntries: string[] = []; + const analysesByFile = new Map(analyses.map((a) => [a.file, a])); + for (const file of [...RELATION_SPEC_EXCEPTIONS]) { + if (!existsSync(join(REPO_ROOT, file))) { + staleEntries.push(`${file}: Datei existiert nicht mehr`); + continue; + } + const analysis = analysesByFile.get(file); + const unmatched = analysis ? analysis.rawRelationSpecCount - analysis.matchedRelationSpecCount : 0; + if (unmatched <= 0) { + staleEntries.push( + `${file}: enthaelt keinen Ueberschuss include:/select:/_count: mehr ausserhalb eines erkannten Modellaufrufs — die Ausnahme ist ueberholt und gehoert entfernt`, + ); + } + } + expect(staleEntries, staleEntries.join('\n')).toEqual([]); + }); + + it('unresolvedRelationSpecValues ist ueberall leer: jeder include:/select:-Wert ist ein Objektliteral, `true` oder eine in derselben Datei definierte Konstante (260911-mkj)', () => { + const unresolved = analyses.flatMap((a) => a.unresolvedRelationSpecValues); + expect(unresolved, unresolved.join('\n')).toEqual([]); + }); +}); + +describe('vierte Erkennung: Relationszugriffe (WINDOWS #27, 260911-mkj)', () => { + it('SCHEMA_RELATIONS pinnt die drei gemessenen Kern-Relationen', () => { + expect(SCHEMA_RELATIONS.get('Tenant')?.get('users')).toBe('User'); + expect(SCHEMA_RELATIONS.get('Group')?.get('memberships')).toBe('GroupMembership'); + expect(SCHEMA_RELATIONS.get('LdapConfig')?.get('fieldMappings')).toBe('LdapFieldMapping'); + }); + + it('jedes Relationsziel in SCHEMA_RELATIONS ist ein Modellname, und SCHEMA_RELATIONS deckt alle `model`-Bloecke des Schemas ab', () => { + const modelNames = new Set(SCHEMA_RELATIONS.keys()); + for (const [, fields] of SCHEMA_RELATIONS) { + for (const target of fields.values()) { + expect(modelNames.has(target)).toBe(true); + } + } + const schemaSource = readFileSync(SCHEMA_PATH, 'utf-8'); + const modelLineCount = (schemaSource.match(/^model\s+\w+\s*\{/gm) || []).length; + expect(SCHEMA_RELATIONS.size).toBe(modelLineCount); + }); + + it('Probe A (WINDOWS #27, ungebunden): `include: { _count: { select: { users: true } } }` auf this.prisma.tenant liefert unboundModels mit tenant UND user, user NICHT in boundModels', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return this.prisma.tenant.findMany({ + include: { _count: { select: { users: true } } }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-a.service.ts'); + expect([...result.unboundModels]).toEqual(expect.arrayContaining(['tenant', 'user'])); + expect(result.boundModels.has('user')).toBe(false); + }); + + it('Probe B (WINDOWS #27, gebunden): derselbe Aufruf auf einem forTenant(-Klienten liefert boundModels mit tenant UND user, user/tenant NICHT in unboundModels', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run(tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.tenant.findMany({ + include: { _count: { select: { users: true } } }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-b.service.ts'); + expect([...result.boundModels]).toEqual(expect.arrayContaining(['tenant', 'user'])); + expect(result.unboundModels.has('user')).toBe(false); + expect(result.unboundModels.has('tenant')).toBe(false); + }); + + it('reale ldap-Form: `include: { tenant: true, fieldMappings: true }` auf this.prisma.ldapConfig.findMany liefert unboundModels mit ldapConfig, tenant UND ldapFieldMapping', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async getAllActiveConfigs() { + return this.prisma.ldapConfig.findMany({ + where: { isActive: true }, + include: { tenant: true, fieldMappings: true }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-ldap.service.ts'); + expect([...result.unboundModels]).toEqual( + expect.arrayContaining(['ldapConfig', 'tenant', 'ldapFieldMapping']), + ); + }); + + it('verschachtelte where-Kette auf einem forTenant(-Klienten liefert boundModels mit moduleGrant, group UND groupMembership', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run(tenantId: string, userId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.moduleGrant.findMany({ + where: { tenantId, group: { memberships: { some: { userId } } } }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-chain.service.ts'); + expect([...result.boundModels]).toEqual( + expect.arrayContaining(['moduleGrant', 'group', 'groupMembership']), + ); + }); + + it('Negativprobe (skalarer select + Zeichenkette mit Klammern): liefert unboundModels GENAU {group} — kein Relationsmodell, die Klammern in der Zeichenkette zerreissen den Argumentbereich nicht', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return this.prisma.group.findMany({ + select: { id: true, name: true }, + where: { name: { contains: '{(' } }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-negative.service.ts'); + expect([...result.unboundModels].sort()).toEqual(['group']); + }); + + it('`_count: true` liefert ALLE Relationen von Tenant (user, ldapConfig, group, moduleGrant)', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return this.prisma.tenant.findMany({ include: { _count: true } }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-count-true.service.ts'); + expect([...result.unboundModels]).toEqual( + expect.arrayContaining(['user', 'ldapConfig', 'group', 'moduleGrant']), + ); + }); + + it('unbekannter Empfaenger (Funktionsparameter) faellt in Waechter (a): rawRelationSpecCount 1, matchedRelationSpecCount 0, kein user in beiden Mengen', () => { + const probe = ` +class ProbeService { + async run(client: any) { + return client.tenant.findMany({ include: { users: true } }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-unknown.service.ts'); + expect(result.rawRelationSpecCount).toBe(1); + expect(result.matchedRelationSpecCount).toBe(0); + expect(result.unboundModels.has('user')).toBe(false); + expect(result.boundModels.has('user')).toBe(false); + }); + + it('Konstante als select-Wert wird aufgeloest; eine nicht definierte Kennung landet in unresolvedRelationSpecValues', () => { + const resolvedProbe = ` +const SAFE = { id: true, users: true }; +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return this.prisma.tenant.findMany({ select: SAFE }); + } +} +`; + const resolvedResult = analyzeSource(resolvedProbe, 'apps/api/src/probe/probe-constant.service.ts'); + expect(resolvedResult.unboundModels.has('user')).toBe(true); + + const unresolvedProbe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return this.prisma.tenant.findMany({ select: IMPORTED_SELECT }); + } +} +`; + const unresolvedResult = analyzeSource(unresolvedProbe, 'apps/api/src/probe/probe-unresolved.service.ts'); + expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1); + expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT'); + }); }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 8a5e46e..f922ee3 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -473,16 +473,45 @@ 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 +**Erkennungslücke GESCHLOSSEN (260911-mkj, WINDOWS #27):** bis 260911-e2s sah +die Bestandsaufnahme 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). +Relationseinbindung (`include:`, `select:`, Relationszähler `_count`) in +eine ZWEITE Tabelle erzeugte kein eigenes Paar und war für das Werkzeug +strukturell unsichtbar, obwohl Prisma sie als Unterabfrage/Join auf die +zweite Tabelle unter DEREN Regel rendert. Eine vierte Erkennungsform in +`rls-access-inventory.spec.ts` (`analyzeSource`, `SCHEMA_RELATIONS`) löst +Relationsfelder über `schema.prisma` auf ihr Zielmodell auf und trägt das +Zielmodell seither als eigene Fundstelle derselben Datei — gebunden, wenn +der Empfänger des erkannten Modellaufrufs gebunden ist, sonst ungebunden. +Das erfasst `include:`, `select:`, `_count: { select: ... }`, `_count: +true`, Relationsfilter in `where:`, `orderBy:` über Relationen und +verschachtelte Schreibzugriffe in `data:`. + +Bewusst NICHT gesehen, und wie das begrenzt ist: ein Empfänger außerhalb der +vier Erkennungsformen (`this.prisma`, eine `const X = forTenant(`-Zuweisung, +ein Transaktionsparameter, `withTenantTransaction(`) — begrenzt durch den +Waechter Rohzahl (`include|select|_count` über den gesamten kommentarfreien +Quelltext) gegen die innerhalb erkannter Aufrufe gezählte Zahl, mit +begründeter Ausnahmeliste `RELATION_SPEC_EXCEPTIONS`; ein `include:`/ +`select:`-Wert aus einer fremden Datei oder ohne aufzulösende Konstante — +begrenzt durch den Wertform-Waechter (`unresolvedRelationSpecValues`); die +Kurzschreibweise `include: { x }` — gemessen null Vorkommen im gesamten +API-Quelltext, kein Prisma-Idiom für diese drei Schlüssel; eine +Vorlagen-Interpolation (`${tx.user.count()}`) — zählt in der Rohzahl und +fällt damit laut, statt still zu verschwinden. + +Gemessen (260911-mkj, Ausgabe von `rls-access-inventory.spec.ts`): SIEBEN +neue Paare, DREI fortgeschriebene Stände, EINE Ausnahmedatei +(`RELATION_SPEC_EXCEPTIONS`). Die einzige zur Planungszeit bereits bekannte +gefährliche Ausprägung (ungebundener äußerer Aufruf auf einer +UNGESCHÜTZTEN Tabelle, Einbindung in eine GESCHÜTZTE Tabelle) war +`tenant.controller.ts` — bereits in 260911-e2s behoben (Fan-out ersetzt den +Relationszähler), von der vierten Erkennung erneut bestätigt: kein +Relationszugriff mehr dort. Seit 260911-mkj führt die Spalte `Modell` auch +RELATIONSZIELE, die in der Datei selbst nie als `.` stehen, +sondern nur über den Schlüsselpfad eines erkannten Modellaufrufs sichtbar +werden. | Datei | Modell | Klasse | Stand | Begründung | |---|---|---|---|---| @@ -505,19 +534,23 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | 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). Bis 20260910120000_rls_widen_membership_grant_and_platform_read pruefte die Regel auf `GroupMembership` nachweislich nur die Gruppenseite — seit 260910-jab prueft sie beide Seiten, die Anwendungspruefung bleibt trotzdem bestehen (Schalter weiterhin aus, #18). | | apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | gebunden | Wie groups.service.ts. Seit 260909-jts (Aufgabe 3) gebunden — `assertTargetBelongsToTenant` erzeugt seinen eigenen Kontext, `getMatrix` teilt sich einen Kontext mit den beiden anderen parallelen Teilabfragen. | | apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. Seit 260909-jts gebunden; der `where`-Filter über die Beziehung zur Gruppe bleibt zusätzlich stehen, weil die Regel auf dieser Tabelle nachweislich nur die Gruppenseite prüft (Aufgabe 1). | +| apps/api/src/groups/module-grants.service.ts | module | keine-mandantengebundene-tabelle | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): nur ueber `include: { module: true }` auf `tenantPrisma.tenantModuleActivation.findMany` erreicht (`getMatrix`, Zeilen 196 und 253). `Module` traegt plattformweit keinen Zeilenschutz (Migration 20260909140000, Gruppe b) — die Bindung des Elternaufrufs ist fuer die Unterabfrage wirkungslos, nicht schaedlich, dieselbe Einordnung wie `module-registry.service.ts`/`module` unten. | | apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung vor jedem Erteilen (`assertTargetBelongsToTenant`) bleibt zusätzlich bestehen. Bis 20260910120000_rls_widen_membership_grant_and_platform_read prüfte die Regel auf dieser Tabelle nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe/den referenzierten Benutzer (Befund F, T-JTS-03, Aufgabe 1) — seit 260910-jab prüft sie beide Ziele zusätzlich, die Anwendungsprüfung bleibt trotzdem der erste Schutz (Schalter weiterhin aus, #18). | | apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden. | | apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | gebunden | Zielbenutzer eines Grants innerhalb des Mandanten. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung bleibt bestehen. | | 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-config.service.ts | ldapFieldMapping | beides | gemischt | Klassenkorrektur (260911-mkj, WINDOWS #27): wechselt von `muss-mandantengebunden` auf `beides`. Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten 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. Die vierte Erkennung (260911-mkj) macht sichtbar, dass der bewusst uebergreifende Planer-Lesepfad `getAllActiveConfigs()` ueber `include: { fieldMappings: true }` in `LdapFieldMapping` hineinreicht — dieselbe Unterabfrage-Form wie die drei `tenant`-Zaehler aus WINDOWS #27, hier aber KEIN neuer Befund: der Elternpfad ist als erster Fall der Hintergrunddienst-Falle bereits an Etappe 3 uebergeben, die Feldzuordnungen teilen nach dem Scharfschalten sein Schicksal (Regel auf `LdapFieldMapping` ueber Join auf `LdapConfig`, Migration 20260618112133). Die Klasse folgt der Elternzeile `ldapConfig` (`beides`). | +| apps/api/src/ldap/ldap-config.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getAllActiveConfigs()` (Zeile 311) `include: { tenant: true, fieldMappings: true }` auf `this.prisma.ldapConfig.findMany`; `Tenant` traegt keinen Zeilenschutz (260911-e2s Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). | | apps/api/src/ldap/ldap.service.ts | group | beides | gebunden | AD-Abgleich: `listGroups` (die "bereits importiert"-Markierung) und `importGroupsByDn` (die Idempotenzpruefung ueber `ldapObjectGuid`) sind mit Aufgabe 3 (260909-ipc) auf `forTenant()` umgestellt — zusammen mit den bereits vorher gebundenen Stellen (Anlage, Mitgliedschafts- und Gruppenabgleich) ist damit jeder `group`-Zugriff dieser Datei gebunden. Die Klasse bleibt `beides`, weil ein zukuenftiger uebergreifender Lesezugriff (z. B. ein neuer Planer-Pfad) hier ebenso legitim waere wie bei `ldapConfig` unten — nicht, weil heute noch ein ungebundener Zugriff bestuende. | | 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 | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. | | apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. | +| apps/api/src/module-registry/module-access.service.ts | group | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): Relationsfilter `where: { tenantId, group: { memberships: { some: { userId } } } }` auf `tenantPrisma.moduleGrant.findMany` (Zeile 73, `getAccessibleModuleIds`, Gruppenweg). Prisma rendert das als Unterabfrage auf `Group` unter DEREN Regel; der Klient ist gebunden, `Group` gehoert demselben Mandanten. | +| apps/api/src/module-registry/module-access.service.ts | groupMembership | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): derselbe Relationsfilter wie bei `group` oben, zwei Ebenen tief (`group > memberships`) auf `tenantPrisma.moduleGrant.findMany` (Zeile 73). Prisma rendert das als Unterabfrage auf `GroupMembership` unter DEREN Regel; gebunden, derselbe Mandant. | | apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. 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. | | apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260910-exd (Aufgabe 2) laufen Direktweg und Gruppenweg von `getAccessibleModuleIds` ueber `forTenant()`, EIN Klient je Methode. | | apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. | -| 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 | module | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Modulkatalog ist plattformweit. Der ungebundene Anteil bleibt bewusst so (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. Die neu sichtbare gebundene Haelfte stammt ausschliesslich aus `include: { module: true }` auf `tenantPrisma.tenantModuleActivation` (Zeilen 56, 97, 148) — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. | | 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 | gemischt | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — keine uebersehene Fundstelle, dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. | | 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. | @@ -527,14 +560,16 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | 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 | tender | keine-mandantengebundene-tabelle | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { tender: true, savedSearch: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `Tender` ist der plattformglobale Katalog (D-03) — die Bindung des Elternaufrufs ist fuer die Unterabfrage wirkungslos, nicht schaedlich. | | apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | gemischt | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) bleibt die Kandidatenabfrage (`findMany` mit `distinct`) bewusst ungebunden, die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile laufen über `forTenant()`. | | apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | gebunden | Präferenzen werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten der jeweiligen Zeile. | +| apps/api/src/tenders/tender-digest.scheduler.ts | tenderSavedSearch | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { savedSearch: true }` plus `orderBy` ueber `savedSearch` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `TenderSavedSearch` ist mandantengebunden und ueber denselben gebundenen Klienten erreicht. | | apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | gebunden | E-Mail-Adressen für den Versand werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`. | | apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | gebunden | 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. Seit 260909-laa (Aufgabe 2) laufen `getConfigForApi` (beide Lesezugriffe), `saveConfig` und `testConnection` vollständig über `forTenant()`. | | 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 | tender | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. Die neu sichtbare gebundene Haelfte stammt aus `include: { tender: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 139) im Sofortmeldungs-Dispatch — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. | | apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | gebunden | Sofortmeldung — siehe Abschnitt "Der Hintergrunddienst als Falle". Seit 260909-laa (Aufgabe 3) laufen sowohl die Treffer-Anlage (`upsert`) als auch der Instant-Dispatch (`findMany`/`updateMany`) vollständig über `forTenant()`, EIN gebundener Client je Profil. | | apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend, Etappe-3-Übergabe (260909-laa, Aufgabe 3, unverändert). | | apps/api/src/tenders/tender-matching.service.ts | user | beides | gebunden | E-Mail-Adressen für die Sofortmeldung. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten des jeweiligen Profils. | @@ -544,6 +579,7 @@ Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler). | 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 | gebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260909-laa (Aufgabe 2) laufen `setTriage`/`listForUser`/`favoriteIds` vollständig über `forTenant()`. | | 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 | tenderSource | keine-mandantengebundene-tabelle | ungebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getTender` (Zeile 612) `include: { sources: { select: ... } }` auf `this.prisma.tender.findUnique`; `TenderSource` plattformweit ohne Zeilenschutz (dieselbe Einordnung wie `tender-dedup.service.ts`/`tenderSource`). | | 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 und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). |