test(quick-260911-mkj): vierte Erkennungsform fuer Relationszugriffe, WINDOWS #27
- rls-access-inventory.spec.ts: analyzeFile in analyzeSource(source, relPath) herausgeloest, parseSchemaRelations() liest schema.prisma zur Testzeit, vierte Erkennung loest include:/select:/_count:/Relationsfilter ueber SCHEMA_RELATIONS auf das Zielmodell auf und traegt es als eigene Fundstelle (gebunden/ungebunden nach Empfaenger) ein - drei Waechter (Rohzahl vs. erkannte Modellaufrufe, Stale-Check fuer RELATION_SPEC_EXCEPTIONS, unresolvedRelationSpecValues leer), zwei Schema-Tests, acht gepinnte Proben (WINDOWS #27 ungebunden/gebunden, reale ldap-Form, verschachtelte where-Kette, Negativprobe, _count: true, unbekannter Empfaenger, Konstantenaufloesung) - Bestandsaufnahme (docs/mandantentrennung-zugriffsklassifikation.md): sieben neue Paare, drei fortgeschriebene Staende (davon ldapFieldMapping mit Klassenwechsel auf beides), Kopfabsatz "Erkennungsluecke GESCHLOSSEN" ersetzt den alten "seit 260911-e2s vermessen"-Absatz, 65 -> 72 Paare Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -35,11 +35,27 @@ import { describe, expect, it } from 'vitest';
|
|||||||
* erkannte Zuweisung ODER jeder Aufruf von `withTenantTransaction(` zaehlt
|
* erkannte Zuweisung ODER jeder Aufruf von `withTenantTransaction(` zaehlt
|
||||||
* als gebunden (das Hilfsmittel bindet den Kontext selbst, direkt auf dem
|
* als gebunden (das Hilfsmittel bindet den Kontext selbst, direkt auf dem
|
||||||
* Transaktionsparameter) — alles andere zaehlt als ungebunden.
|
* 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.<Modell>` noch `<gebundener
|
||||||
|
* Client>.<Modell>` noch `<tx>.<Modell>` 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 API_SRC_DIR = join(__dirname, '..');
|
||||||
const REPO_ROOT = join(__dirname, '../../../..');
|
const REPO_ROOT = join(__dirname, '../../../..');
|
||||||
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
|
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
|
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
|
||||||
@@ -77,6 +93,23 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set<string>([]);
|
|||||||
*/
|
*/
|
||||||
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
|
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>(['apps/api/src/tenders/backfill-tender-source.ts']);
|
||||||
|
|
||||||
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
||||||
type Stand = (typeof STAND_TOKENS)[number];
|
type Stand = (typeof STAND_TOKENS)[number];
|
||||||
|
|
||||||
@@ -88,6 +121,9 @@ interface FileAnalysis {
|
|||||||
assignmentFormCalls: number;
|
assignmentFormCalls: number;
|
||||||
rawInteractiveTransactionCount: number;
|
rawInteractiveTransactionCount: number;
|
||||||
matchedInteractiveTransactionCount: number;
|
matchedInteractiveTransactionCount: number;
|
||||||
|
rawRelationSpecCount: number;
|
||||||
|
matchedRelationSpecCount: number;
|
||||||
|
unresolvedRelationSpecValues: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTsFiles(dir: string): string[] {
|
function listTsFiles(dir: string): string[] {
|
||||||
@@ -116,8 +152,216 @@ function stripComments(source: string): string {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
function escapeRegExp(value: string): string {
|
||||||
const source = stripComments(readFileSync(absPath, 'utf-8'));
|
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<string, Map<string, string>> {
|
||||||
|
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<string, Map<string, string>>();
|
||||||
|
for (const { name, body } of blocks) {
|
||||||
|
const fieldMap = new Map<string, string>();
|
||||||
|
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.<Modell>`
|
||||||
|
* (260911-mkj, WINDOWS #27).
|
||||||
|
*/
|
||||||
|
const CLIENT_NAME_TO_MODEL = new Map<string, string>(
|
||||||
|
[...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<string>,
|
||||||
|
boundModels: Set<string>,
|
||||||
|
): 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<string>();
|
const unboundModels = new Set<string>();
|
||||||
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
|
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
|
||||||
@@ -160,6 +404,13 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
|||||||
? 0
|
? 0
|
||||||
: [...source.matchAll(/\.\$transaction\(\s*async\b/g)].length;
|
: [...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: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
// Form 1: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
||||||
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
|
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
|
||||||
const directInteractiveMatches = [
|
const directInteractiveMatches = [
|
||||||
@@ -172,6 +423,8 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
|||||||
const param = m[2];
|
const param = m[2];
|
||||||
if (!param) continue;
|
if (!param) continue;
|
||||||
const isBound = boundNames.has(receiver);
|
const isBound = boundNames.has(receiver);
|
||||||
|
if (isBound) txBoundParams.push(param);
|
||||||
|
else txUnboundParams.push(param);
|
||||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||||
for (const mm of source.matchAll(re)) {
|
for (const mm of source.matchAll(re)) {
|
||||||
if (!mm[1]) continue;
|
if (!mm[1]) continue;
|
||||||
@@ -192,12 +445,73 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
|||||||
for (const m of withTenantTransactionMatches) {
|
for (const m of withTenantTransactionMatches) {
|
||||||
const param = m[1];
|
const param = m[1];
|
||||||
if (!param) continue;
|
if (!param) continue;
|
||||||
|
txBoundParams.push(param);
|
||||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||||
for (const mm of source.matchAll(re)) {
|
for (const mm of source.matchAll(re)) {
|
||||||
if (mm[1]) boundModels.add(mm[1]);
|
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<string>([
|
||||||
|
'this.prisma',
|
||||||
|
...boundNames,
|
||||||
|
...txBoundParams,
|
||||||
|
...txUnboundParams,
|
||||||
|
]);
|
||||||
|
const boundReceiverNames = new Set<string>([...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 {
|
return {
|
||||||
file: relPath,
|
file: relPath,
|
||||||
unboundModels,
|
unboundModels,
|
||||||
@@ -206,9 +520,17 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
|||||||
assignmentFormCalls: assignmentMatches.length,
|
assignmentFormCalls: assignmentMatches.length,
|
||||||
rawInteractiveTransactionCount,
|
rawInteractiveTransactionCount,
|
||||||
matchedInteractiveTransactionCount: directInteractiveMatches.length,
|
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[] {
|
function analyzeAllFiles(): FileAnalysis[] {
|
||||||
const files = listTsFiles(API_SRC_DIR);
|
const files = listTsFiles(API_SRC_DIR);
|
||||||
return files
|
return files
|
||||||
@@ -391,4 +713,203 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
|||||||
}
|
}
|
||||||
expect(violations, violations.join('\n')).toEqual([]);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -473,16 +473,45 @@ oder — seit 260909-ipc, Befund G — in `<gebundener Client>.<Modell>`
|
|||||||
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||||
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
||||||
|
|
||||||
**Erkennungslücke, seit 260911-e2s vermessen (Aufgabe 1, (n4)(b)):** die
|
**Erkennungslücke GESCHLOSSEN (260911-mkj, WINDOWS #27):** bis 260911-e2s sah
|
||||||
Bestandsaufnahme sieht ausschließlich (Datei, Modell)-Paare über
|
die Bestandsaufnahme ausschließlich (Datei, Modell)-Paare über
|
||||||
`this.prisma.<Modell>` bzw. `<gebundener Client>.<Modell>` — eine
|
`this.prisma.<Modell>` bzw. `<gebundener Client>.<Modell>` — eine
|
||||||
Relationseinbindung (`include:`, Relationszähler `_count`) in eine ZWEITE
|
Relationseinbindung (`include:`, `select:`, Relationszähler `_count`) in
|
||||||
Tabelle erzeugt kein eigenes Paar und ist für das Werkzeug strukturell
|
eine ZWEITE Tabelle erzeugte kein eigenes Paar und war für das Werkzeug
|
||||||
unsichtbar. Alle 19 `include:`-Stellen und alle `_count`-Stellen des
|
strukturell unsichtbar, obwohl Prisma sie als Unterabfrage/Join auf die
|
||||||
API-Quelltexts wurden einzeln nachgesehen; die einzige gefährliche
|
zweite Tabelle unter DEREN Regel rendert. Eine vierte Erkennungsform in
|
||||||
Ausprägung (ungebundener äußerer Aufruf auf einer UNGESCHÜTZTEN Tabelle,
|
`rls-access-inventory.spec.ts` (`analyzeSource`, `SCHEMA_RELATIONS`) löst
|
||||||
Einbindung in eine GESCHÜTZTE Tabelle) war `tenant.controller.ts` — hier in
|
Relationsfelder über `schema.prisma` auf ihr Zielmodell auf und trägt das
|
||||||
Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
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 `<Klient>.<Modell>` stehen,
|
||||||
|
sondern nur über den Schlüsselpfad eines erkannten Modellaufrufs sichtbar
|
||||||
|
werden.
|
||||||
|
|
||||||
| Datei | Modell | Klasse | Stand | Begründung |
|
| 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/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 | 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 | 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 | 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 | 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/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 | 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 | 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 | 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 | 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/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 | 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 | 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-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/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/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. |
|
| 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/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 | 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-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 | 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 | 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-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-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-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 | 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-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 | 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 | 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. |
|
| 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-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/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 | 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.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/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). |
|
| 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). |
|
||||||
|
|||||||
Reference in New Issue
Block a user