Files
tessera-ctl/apps/api/src/prisma/auth-lookup-functions.spec.ts
T
schalli de50297467 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
2026-09-09 10:56:41 +02:00

118 lines
4.6 KiB
TypeScript

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"/);
});
});