docs(quick-260909-eor): alle 227 Datenbankzugriffe klassifiziert und maschinell abgesichert

WINDOWS #18/#20, Aufgabe 3: docs/mandantentrennung-zugriffsklassifikation.md
haelt fuer jede der 227 this.prisma.*-Fundstellen (32 Dateien, zusammengefasst
zu 59 Datei-Modell-Paaren) eine Klasse fest — muss-mandantengebunden (31),
keine-mandantengebundene-tabelle (16), bewusst-uebergreifend (3, mit
ausgeschriebenem Grund) oder beides (9, der Hintergrunddienst-Sonderfall:
uebergreifend lesen, je Zeile mandantengebunden schreiben — betrifft
ldap.service.ts, tender-digest.scheduler.ts, tender-matching.service.ts).

rls-access-inventory.spec.ts ermittelt die Fundstellen bei jedem Testlauf neu
aus dem Quelltext und vergleicht sie gegen die Tabelle im Dokument — Datei und
Modellname als Schluessel, keine Zeilennummer. Scheitert nachweislich, sobald
eine Fundstelle fehlt oder ein Eintrag verwaist (per Testlauf geprueft, danach
zurueckgesetzt).

Zwei belegte Befunde im Dokument festgehalten: req.tenantPrisma wird gesetzt,
aber nirgends gelesen; WINDOWS #19 (nullbares tenantId bei SearchProvider/
TenderRssFeedSource) bleibt benannter Blocker fuer Etappe 3.

docs/mandantentrennung-datenbankrolle.md verweist jetzt auf das neue
Dokument und korrigiert die ueberholte Zahl 182 auf den nachgemessenen Stand
(227/59). WINDOWS.md #18/#19 um Nachtrag auf diesen Plan ergaenzt; #20 (der
in Aufgabe 1 gemessene und behobene forTenant()-Verbindungsdefekt) als
"fixed" markiert.

Deviation (Rule 3, blockierend fuer die Bestandsaufnahme-Pruefung):
auth.service.ts-Kommentar umformuliert, der zuvor woertlich
"this.prisma.user.findUnique" als erklaerenden Text enthielt und dadurch
einen Eigentreffer der grep-basierten Inventur-Pruefung erzeugte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 11:04:29 +02:00
parent de50297467
commit 5f3a39c2c3
5 changed files with 391 additions and 34 deletions
+3 -2
View File
@@ -65,8 +65,9 @@ export class AuthService {
* Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also
* geht die Suche ueber auth_lookup_user_by_username() (SECURITY DEFINER,
* 20260909160000_auth_lookup_functions) statt eines gewoehnlichen
* this.prisma.user.findUnique — unter der kuenftigen Rolle ohne BYPASSRLS
* (tessera_app) liefert ein ungebundener SELECT auf "User" null Zeilen.
* "this dot prisma dot user dot findUnique" — unter der kuenftigen Rolle
* ohne BYPASSRLS (tessera_app) liefert ein ungebundener SELECT auf "User"
* null Zeilen.
* Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle
* Schreibzugriffe ueber forTenant(), gebunden an genau diesen Mandanten
* (WINDOWS #20, Aufgabe 1).
@@ -0,0 +1,132 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Ermittelt die Fundstellen erneut aus dem Quelltext und vergleicht sie
* gegen die im Dokument gefuehrten Eintraege (Aufgabe 3, WINDOWS #18/#20,
* T-EOR-05). Scheitert, sobald eine Fundstelle ohne Eintrag existiert oder
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
* Verschieben einer Zeile.
*/
const API_SRC_DIR = join(__dirname, '..');
const REPO_ROOT = join(__dirname, '../../../..');
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
interface AccessSite {
file: string;
model: string;
}
function listTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...listTsFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) {
out.push(full);
}
}
return out;
}
/**
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
* bevor nach `this.prisma.<model>` gesucht wird — sonst zaehlt eine
* erklaerende Kopfzeile als Fundstelle mit.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n');
}
function findAccessSites(): AccessSite[] {
const files = listTsFiles(API_SRC_DIR);
const sites: AccessSite[] = [];
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 });
}
}
return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model));
}
const CLASS_TOKENS = [
'muss-mandantengebunden',
'bewusst-uebergreifend',
'keine-mandantengebundene-tabelle',
'beides',
];
function parseDocEntries(): { file: string; model: string; klasse: string }[] {
const raw = readFileSync(DOC_PATH, 'utf-8');
const entries: { file: string; model: string; klasse: 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;
for (const match of raw.matchAll(rowPattern)) {
const [, file, model, klasse] = match;
entries.push({ file, model, klasse });
}
return entries;
}
describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollstaendig ab (Aufgabe 3, T-EOR-05)', () => {
it('das Dokument existiert', () => {
expect(() => statSync(DOC_PATH)).not.toThrow();
});
const sourceSites = findAccessSites();
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}`));
it('die Bestandsaufnahme-Tabelle enthaelt mindestens einen Eintrag', () => {
expect(docEntries.length).toBeGreaterThan(0);
});
it('jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen', () => {
const missing = sourceSites
.map((s) => `${s.file}::${s.model}`)
.filter((key) => !docKeys.has(key));
expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]);
});
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => {
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
});
it('jeder Eintrag traegt eine der vier gueltigen Klassen', () => {
const invalid = docEntries.filter((e) => !CLASS_TOKENS.includes(e.klasse));
expect(invalid, JSON.stringify(invalid)).toEqual([]);
});
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
const seen = new Set<string>();
const duplicates: string[] = [];
for (const e of docEntries) {
const key = `${e.file}::${e.model}`;
if (seen.has(key)) duplicates.push(key);
seen.add(key);
}
expect(duplicates).toEqual([]);
});
});