Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".
Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.
Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.
Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.
Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:
- Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
withUser() — und das öffnet immer eine Transaktion.
- set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
- Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
erzeugt zwei Fehler.
- Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.
tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.
Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.
Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
This commit is contained in:
74
lib/db/index.ts
Normal file
74
lib/db/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import "server-only";
|
||||
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
||||
import { pool } 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.
|
||||
|
||||
const db = new Kysely<Schema>({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
});
|
||||
|
||||
export type Tx = Transaction<Schema>;
|
||||
|
||||
/**
|
||||
* 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<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
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<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
return withUser(null, fn);
|
||||
}
|
||||
|
||||
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
|
||||
export async function closeDb(): Promise<void> {
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
export { sql };
|
||||
Reference in New Issue
Block a user