feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst null Zeilen liefern und die Anmeldung waere unmoeglich. Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp, fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts: - auth_lookup_user_by_username (validateUser) - auth_lookup_user_by_email (requestPasswordReset) - auth_lookup_reset_token (resetPassword) Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(), gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/ adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2. rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen. Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Prueft die auth_lookup_*-Migration (Aufgabe 2, WINDOWS #18/#20, T-EOR-01/
|
||||
* T-EOR-02) rein textuell gegen die generierte migration.sql — keine
|
||||
* Datenbank noetig. Baut die Hilfsfunktion aus rls-app-role.spec.ts bewusst
|
||||
* nach, statt sie zu importieren (dieselbe Begruendung wie dort: die
|
||||
* vorhandene ist in ihrer Datei privat).
|
||||
*
|
||||
* Zaehlbedingungen ueber den Migrationstext filtern Kommentarzeilen vorher
|
||||
* heraus, sonst zaehlt die Begruendung im Kopf der Datei als Treffer mit.
|
||||
*/
|
||||
|
||||
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
|
||||
const FUNCTION_NAMES = [
|
||||
'auth_lookup_user_by_username',
|
||||
'auth_lookup_user_by_email',
|
||||
'auth_lookup_reset_token',
|
||||
];
|
||||
|
||||
function readMigrationSql(suffix: string): string {
|
||||
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
||||
.map((entry) => entry.name);
|
||||
|
||||
if (dirs.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||
}
|
||||
|
||||
function stripSqlComments(sql: string): string {
|
||||
// Migrations verwenden ausschliesslich `--`-Zeilenkommentare, keine
|
||||
// Blockkommentare — eine einfache Zeilenfilterung reicht.
|
||||
return sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('auth_lookup_functions migration.sql (Aufgabe 2, WINDOWS #18/#20)', () => {
|
||||
const rawSql = readMigrationSql('_auth_lookup_functions');
|
||||
const sql = stripSqlComments(rawSql);
|
||||
|
||||
it('legt alle drei Funktionen an', () => {
|
||||
for (const name of FUNCTION_NAMES) {
|
||||
expect(sql).toContain(`CREATE OR REPLACE FUNCTION ${name}(`);
|
||||
}
|
||||
});
|
||||
|
||||
it('jede Funktion traegt SECURITY DEFINER', () => {
|
||||
const occurrences = sql.match(/SECURITY DEFINER/g) ?? [];
|
||||
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||
});
|
||||
|
||||
it('jede Funktion traegt STABLE', () => {
|
||||
const occurrences = sql.match(/\bSTABLE\b/g) ?? [];
|
||||
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||
});
|
||||
|
||||
it('jede Funktion heftet den Suchpfad fest auf public, pg_temp — der eigentliche Schutz bei SECURITY DEFINER', () => {
|
||||
const occurrences = sql.match(/SET search_path = public, pg_temp/g) ?? [];
|
||||
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||
});
|
||||
|
||||
it('jede Funktion begrenzt das Ergebnis auf LIMIT 1', () => {
|
||||
const occurrences = sql.match(/LIMIT 1;/g) ?? [];
|
||||
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||
});
|
||||
|
||||
it('entzieht fuer jede Funktion zuerst alle Rechte von PUBLIC, bevor tessera_app das Ausfuehrungsrecht bekommt', () => {
|
||||
for (const name of FUNCTION_NAMES) {
|
||||
const revokeIdx = sql.indexOf(`REVOKE ALL ON FUNCTION ${name}(`);
|
||||
const grantIdx = sql.indexOf(`GRANT EXECUTE ON FUNCTION ${name}(`);
|
||||
expect(revokeIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(grantIdx).toBeGreaterThan(revokeIdx);
|
||||
expect(sql.slice(revokeIdx, revokeIdx + 200)).toContain('FROM PUBLIC');
|
||||
}
|
||||
});
|
||||
|
||||
it('erteilt das Ausfuehrungsrecht ausschliesslich an tessera_app — keine andere Rolle taucht in einem GRANT EXECUTE auf', () => {
|
||||
const grantLines = sql
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('GRANT EXECUTE ON FUNCTION'));
|
||||
expect(grantLines.length).toBe(FUNCTION_NAMES.length);
|
||||
for (const line of grantLines) {
|
||||
expect(line).toContain('TO tessera_app');
|
||||
}
|
||||
});
|
||||
|
||||
it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => {
|
||||
expect(sql).not.toMatch(/PASSWORD\s*'/);
|
||||
});
|
||||
|
||||
it('keine der drei Funktionen schreibt — kein INSERT/UPDATE/DELETE/DROP im Funktionskoerper', () => {
|
||||
for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP']) {
|
||||
expect(sql).not.toContain(`${verb} `);
|
||||
}
|
||||
});
|
||||
|
||||
it('prueft vorab, dass tessera_app existiert, statt still zu ueberspringen (wiederholbares Muster wie 20260909130000)', () => {
|
||||
expect(sql).toContain("pg_roles WHERE rolname = 'tessera_app'");
|
||||
expect(sql).toContain('RAISE EXCEPTION');
|
||||
});
|
||||
|
||||
it('auth_lookup_user_by_username liefert keinen der drei Textblock-Kandidaten fuer Kennwortfelder ausserhalb passwordHash', () => {
|
||||
// Feste Regressionspruefung: der Spaltensatz ist genau der im Plan
|
||||
// benannte, kein SELECT *.
|
||||
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"User"/);
|
||||
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"PasswordResetToken"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user