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:
2026-07-30 19:01:39 +02:00
parent 730521ee79
commit a66263a96e
10 changed files with 684 additions and 3 deletions

49
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { ColumnType } from "kysely";
import type { Database } from "@/lib/supabase/types";
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
// Schemabeschreibung — nicht daneben gestellt.
//
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
// Diese Ableitung erbt diese Absicherung.
//
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
type Tables = Database["public"]["Tables"];
/**
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
*
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
* was beim Aktualisieren erlaubt ist.
*/
type Columns<T extends keyof Tables> = {
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
? ColumnType<
Tables[T]["Row"][K],
Tables[T]["Insert"][K],
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
>
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
ColumnType<Tables[T]["Row"][K], never, never>;
};
export type DB = { [T in keyof Tables]: Columns<T> };
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
export type AppUsersTable = {
id: ColumnType<string, string | undefined, never>;
external_id: string;
email: string;
full_name: ColumnType<string | null, string | null | undefined, string | null>;
created_at: ColumnType<string, string | undefined, never>;
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
};
export type Schema = DB & { app_users: AppUsersTable };