feat(quick-260909-ipc): ldap-config.service.ts an forTenant() binden, Loesch-Fremdzugriff schliessen

Aufgabe 2 der Etappe 2: getConfig/createConfig/updateConfig sowie
addFieldMapping/removeFieldMapping laufen jetzt ueber forTenant(), gebunden
an den aus der Anfrage bekannten Mandanten. removeFieldMapping nimmt den
Mandanten neu als Pflichtparameter entgegen und der Controller holt ihn aus
dem Sitzungsnachweis statt nur die URL-Kennung weiterzureichen (T-IPC-01) --
ein Administrator konnte bisher die Feldzuordnung eines fremden Mandanten
loeschen, wenn er ihre Kennung kannte. getAllActiveConfigs() und die
Start-Nachverschluesselung bleiben bewusst uebergreifend, mit ausgeschriebener
Begruendung im Code (Befund B).

rls-access-inventory.spec.ts erkennt jetzt neben `this.prisma.<Modell>` auch
gebundene `<Name>.<Modell>`-Zugriffe (Befund F/G) und prueft eine neue
Stand-Spalte (gebunden/ungebunden/gemischt) im Klassifikationsdokument gegen
den Quelltext. Das macht zwei bisher unsichtbare, weil schon laenger
gebundene Fundstellen sichtbar (auth.service.ts/passwordResetToken,
ldap.service.ts/groupMembership) und deckt auf, dass
(ldap-config.service.ts, ldapConfig) tatsaechlich "beides" ist, nicht
"muss-mandantengebunden" (Befund B).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 14:01:28 +02:00
parent a0c9ef070f
commit 9a57fa79f5
5 changed files with 447 additions and 103 deletions
+153 -26
View File
@@ -9,15 +9,45 @@ import { describe, expect, it } from 'vitest';
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
* Verschieben einer Zeile.
*
* Erweitert in Aufgabe 2 (260909-ipc, Befund G): eine Umstellung auf
* `forTenant()` laesst `this.prisma.<Modell>` aus dem Quelltext
* verschwinden. Ohne eine zweite Erkennung fuer gebundene Zugriffe wuerde
* diese Pruefung eine Umstellung als "Fundstelle verschwunden" werten und
* zwingen, den Nachweis aus dem Dokument zu LOESCHEN statt ihn
* fortzuschreiben. Die zweite Erkennung sammelt je Datei die Zuweisungen
* der Form `const <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
* Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand:
* `gebunden`, `ungebunden` oder `gemischt`.
*/
const API_SRC_DIR = join(__dirname, '..');
const REPO_ROOT = join(__dirname, '../../../..');
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
interface AccessSite {
/**
* Dateien, in denen ein `forTenant(`-Aufruf bewusst NICHT der erkannten
* `const <Name> = forTenant(`-Zuweisungsform folgt. Beide veroeffentlichen
* den gebundenen Client auf dem Anfrageobjekt (`req.tenantPrisma = ...`)
* statt ihn einer lokalen Konstante zuzuweisen — genau dieser Weg ist die
* offene Architekturfrage aus docs/mandantentrennung-zugriffsklassifikation.md
* ("Was diese Etappe NICHT entscheidet"), hier bewusst offen gehalten statt
* stillschweigend als Erkennungsluecke durchzurutschen.
*/
const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
'apps/api/src/tenant/tenant.middleware.ts',
'apps/api/src/tenant/tenant.guard.ts',
]);
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
type Stand = (typeof STAND_TOKENS)[number];
interface FileAnalysis {
file: string;
model: string;
unboundModels: Set<string>;
boundModels: Set<string>;
totalForTenantCalls: number;
assignmentFormCalls: number;
}
function listTsFiles(dir: string): string[] {
@@ -35,8 +65,8 @@ function listTsFiles(dir: string): string[] {
/**
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
* bevor nach `this.prisma.<model>` gesucht wird — sonst zaehlt eine
* erklaerende Kopfzeile als Fundstelle mit.
* bevor nach `this.prisma.<model>` oder `forTenant(` gesucht wird — sonst
* zaehlt eine erklaerende Kopfzeile als Fundstelle mit.
*/
function stripComments(source: string): string {
return source
@@ -46,26 +76,79 @@ function stripComments(source: string): string {
.join('\n');
}
function findAccessSites(): AccessSite[] {
const files = listTsFiles(API_SRC_DIR);
const sites: AccessSite[] = [];
function analyzeFile(absPath: string, relPath: string): FileAnalysis {
const source = stripComments(readFileSync(absPath, 'utf-8'));
for (const absPath of files) {
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
const source = stripComments(readFileSync(absPath, 'utf-8'));
const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g);
const models = new Set<string>();
for (const m of matches) {
if (m[1]) models.add(m[1]);
}
for (const model of models) {
sites.push({ file: relPath, model });
const unboundModels = new Set<string>();
for (const m of source.matchAll(/this\.prisma\.([a-zA-Z]+)/g)) {
if (m[1]) unboundModels.add(m[1]);
}
const assignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forTenant\(/g)];
const boundNames = new Set(assignmentMatches.map((m) => m[1]).filter(Boolean) as string[]);
const boundModels = new Set<string>();
for (const name of boundNames) {
const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g');
for (const m of source.matchAll(re)) {
if (m[1]) boundModels.add(m[1]);
}
}
// Zaehlt Aufrufstellen von `forTenant(`, aber nicht die Funktionsdefinition
// selbst (`export function forTenant(...)` in prisma-tenant.extension.ts) —
// die Definition ist kein Aufruf und braucht keine Zuweisungsform.
const totalForTenantCalls = [...source.matchAll(/(?<!function )forTenant\(/g)].length;
return {
file: relPath,
unboundModels,
boundModels,
totalForTenantCalls,
assignmentFormCalls: assignmentMatches.length,
};
}
function analyzeAllFiles(): FileAnalysis[] {
const files = listTsFiles(API_SRC_DIR);
return files
.map((absPath) => {
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
return analyzeFile(absPath, relPath);
})
.sort((a, b) => a.file.localeCompare(b.file));
}
interface AccessSite {
file: string;
model: string;
}
function findAccessSites(analyses: FileAnalysis[]): AccessSite[] {
const sites: AccessSite[] = [];
for (const a of analyses) {
const allModels = new Set([...a.unboundModels, ...a.boundModels]);
for (const model of allModels) {
sites.push({ file: a.file, model });
}
}
return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model));
}
function computeStandByKey(analyses: FileAnalysis[]): Map<string, Stand> {
const standByKey = new Map<string, Stand>();
for (const a of analyses) {
const allModels = new Set([...a.unboundModels, ...a.boundModels]);
for (const model of allModels) {
const isBound = a.boundModels.has(model);
const isUnbound = a.unboundModels.has(model);
const stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden';
standByKey.set(`${a.file}::${model}`, stand);
}
}
return standByKey;
}
const CLASS_TOKENS = [
'muss-mandantengebunden',
'bewusst-uebergreifend',
@@ -73,16 +156,25 @@ const CLASS_TOKENS = [
'beides',
];
function parseDocEntries(): { file: string; model: string; klasse: string }[] {
const raw = readFileSync(DOC_PATH, 'utf-8');
const entries: { file: string; model: string; klasse: string }[] = [];
interface DocEntry {
file: string;
model: string;
klasse: string;
stand: string;
}
// Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |`
const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm;
function parseDocEntries(): DocEntry[] {
const raw = readFileSync(DOC_PATH, 'utf-8');
const entries: DocEntry[] = [];
// Nur Zeilen aus der Bestandsaufnahme-Tabelle:
// `| Datei | Modell | Klasse | Stand | Begruendung |`
const rowPattern =
/^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|\s*([a-z-]+)\s*\|/gm;
for (const match of raw.matchAll(rowPattern)) {
const [, file, model, klasse] = match;
entries.push({ file, model, klasse });
const [, file, model, klasse, stand] = match;
entries.push({ file, model, klasse, stand });
}
return entries;
@@ -93,7 +185,9 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
expect(() => statSync(DOC_PATH)).not.toThrow();
});
const sourceSites = findAccessSites();
const analyses = analyzeAllFiles();
const sourceSites = findAccessSites(analyses);
const standByKey = computeStandByKey(analyses);
const docEntries = parseDocEntries();
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`));
@@ -109,7 +203,7 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]);
});
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => {
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext (gebunden oder ungebunden)', () => {
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
});
@@ -119,6 +213,26 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
expect(invalid, JSON.stringify(invalid)).toEqual([]);
});
it('jeder Eintrag traegt einen der drei gueltigen Stand-Werte', () => {
const invalid = docEntries.filter((e) => !STAND_TOKENS.includes(e.stand as Stand));
expect(invalid, JSON.stringify(invalid)).toEqual([]);
});
it('der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein', () => {
const mismatches: string[] = [];
for (const e of docEntries) {
const measured = standByKey.get(`${e.file}::${e.model}`);
if (measured && measured !== e.stand) {
mismatches.push(
`${e.file}::${e.model} — dokumentiert=${e.stand}, gemessen=${measured}`,
);
}
}
expect(mismatches, `Abweichender Stand (Dokument vs. Quelltext):\n${mismatches.join('\n')}`).toEqual(
[],
);
});
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
const seen = new Set<string>();
const duplicates: string[] = [];
@@ -129,4 +243,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
}
expect(duplicates).toEqual([]);
});
it('jedes forTenant(-Vorkommen entspricht der erkannten Zuweisungsform `const X = forTenant(` oder steht in der begruendeten Ausnahmeliste', () => {
const violations: string[] = [];
for (const a of analyses) {
const unmatched = a.totalForTenantCalls - a.assignmentFormCalls;
if (unmatched > 0 && !FORTENANT_ASSIGNMENT_EXCEPTIONS.has(a.file)) {
violations.push(
`${a.file}: ${unmatched} forTenant(-Aufruf(e) ausserhalb der erkannten Zuweisungsform`,
);
}
}
expect(violations, violations.join('\n')).toEqual([]);
});
});