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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user