import "server-only"; import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; import { getPool } from "./pool"; import type { Schema } from "./schema"; // Der einzige Weg an die Datenbank. // // ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══ // // Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen // is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase, // sondern `current_setting('app.user_id')` — eine Sitzungsvariable. // // Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und // Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion // gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die // dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person — // quer über Benutzer hinweg, in einer Personaldatenbank. // // Das ist die Art Fehler, die in keinem Test auffällt, den man nicht // absichtlich dafür schreibt (tests/integration/session-context.test.ts tut // genau das). Deshalb: // // 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss // durch withUser() — und das öffnet immer eine Transaktion. // 2. `set_config(..., true)` — das dritte Argument bedeutet // transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme // wirkungslos. // 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool` // ausserhalb dieses Verzeichnisses. // // Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne** // BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts // zurück — nicht alles. // Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely // ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses // Moduls noch keine Zugangsdaten — siehe getPool(). const db = new Kysely({ dialect: new PostgresDialect({ pool: async () => getPool() }), }); export type Tx = Transaction; /** * Führt `fn` im Namen der angegebenen Person aus. * * `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann * greift keine Policy und es kommt nichts zurück, was auch richtig ist. */ export async function withUser(userId: string | null, fn: (tx: Tx) => Promise): Promise { return db.transaction().execute(async (tx) => { // Erste Anweisung der Transaktion, vor allem anderen. await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); return fn(tx); }); } /** * Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für * fällige Änderungen. * * Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle * ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion * in der Datenbank stehen und dort selbst prüfen, was es tut. Ein * Dienstschlüssel, der RLS aushebelt, existiert nicht mehr. */ export async function asSystem(fn: (tx: Tx) => Promise): Promise { return withUser(null, fn); } /** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */ export async function closeDb(): Promise { await db.destroy(); } export { sql };