diff --git a/.env.example b/.env.example index cb52814..c516bf8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,15 @@ +# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit +# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem +# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech). +# +# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der +# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles. +DATABASE_URL= +# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI). +DATABASE_SSL= +# Verbindungen im Pool; Vorgabe 10. +DATABASE_POOL_MAX= + # Public: safe to expose to the browser (inlined into the client bundle at # build time). Anon-key access is still fully gated by RLS server-side. NEXT_PUBLIC_SUPABASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a09cd8..1d0041e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,16 +68,23 @@ jobs: - name: Supabase starten run: supabase start - # `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects - # them under its own names. + # `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app + # expects them under its own names. DATABASE_URL ist der direkte + # Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und + # vor allem der Nachweis zum Sitzungskontext. - name: Testumgebung schreiben run: | supabase status -o env \ --override-name api.url=NEXT_PUBLIC_SUPABASE_URL \ --override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \ --override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \ - | grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \ + --override-name db.url=DATABASE_URL \ + | grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \ | tr -d '"' > .env.test.local + # Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem. + echo "DATABASE_SSL=false" >> .env.test.local + grep -q '^DATABASE_URL=' .env.test.local \ + || { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; } - name: Seed run: node --env-file=.env.test.local supabase/seed.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..3a67424 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,48 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + + // ── Der Datenbankzugriff bleibt in einer Hand ─────────────────── + // + // Die Zugriffsrechte hängen an einer Sitzungsvariablen, die nur innerhalb + // einer Transaktion gesetzt werden darf (siehe lib/db/index.ts). Wer den + // Pool direkt benutzt, umgeht das — und die Abfrage läuft dann mit dem + // Kontext, den die vorherige Anfrage auf derselben gepoolten Verbindung + // hinterlassen hat. + // + // Das muss strukturell unmöglich sein, nicht per Konvention: eine + // Vereinbarung überlebt den nächsten Termindruck nicht. + { + files: ["**/*.ts", "**/*.tsx"], + ignores: ["lib/db/**", "tests/integration/**", "supabase/**", "scripts/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "pg", + message: + "Kein direkter Pool-Zugriff. Abfragen laufen über withUser() aus lib/db — nur dort wird der Sitzungskontext transaktionslokal gesetzt.", + }, + { + name: "kysely", + importNames: ["Kysely"], + message: + "Keine zweite Kysely-Instanz. lib/db exportiert withUser(); die Instanz selbst bleibt privat, damit keine Abfrage ohne Kontext möglich ist.", + }, + ], + patterns: [ + { + group: ["**/lib/db/pool", "**/db/pool"], + message: + "Der Pool ist absichtlich nicht exportiert. Über lib/db gehen — withUser() erzwingt die Transaktion.", + }, + ], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..1c21838 --- /dev/null +++ b/lib/db/index.ts @@ -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({ + dialect: new PostgresDialect({ pool }), +}); + +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 }; diff --git a/lib/db/pool.ts b/lib/db/pool.ts new file mode 100644 index 0000000..583b05a --- /dev/null +++ b/lib/db/pool.ts @@ -0,0 +1,39 @@ +import "server-only"; +import { Pool } from "pg"; + +// Die einzige Stelle im Projekt, die `pg` importieren darf. +// +// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser() +// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige +// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen +// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur +// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den +// Import von `pg` und von dieser Datei überall sonst. + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error( + "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + + "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." + ); +} + +export const pool = new Pool({ + connectionString, + // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder + // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank + // zulässt bringen nur Wartezeit an einer anderen Stelle. + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt + // eine Verbindung zu belegen. + statement_timeout: 20_000, + idle_in_transaction_session_timeout: 20_000, + connectionTimeoutMillis: 10_000, + // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. + ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, +}); + +// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. +pool.on("error", (err) => { + console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); +}); diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..51a5374 --- /dev/null +++ b/lib/db/schema.ts @@ -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 = { + [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; +}; + +export type DB = { [T in keyof Tables]: Columns }; + +/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */ +export type AppUsersTable = { + id: ColumnType; + external_id: string; + email: string; + full_name: ColumnType; + created_at: ColumnType; + last_seen_at: ColumnType; +}; + +export type Schema = DB & { app_users: AppUsersTable }; diff --git a/package-lock.json b/package-lock.json index 4f7b62e..f80ee66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "@supabase/supabase-js": "^2.110.8", "@xyflow/react": "^12.11.2", "exceljs": "^4.4.0", + "kysely": "^0.29.4", "lucide-react": "^1.26.0", "next": "^16.2.11", + "pg": "^8.22.0", "react": "^19.2.8", "react-dom": "^19.2.8", "server-only": "^0.0.1" @@ -25,6 +27,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.3", + "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", @@ -2834,6 +2837,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -7116,6 +7131,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8198,6 +8222,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8255,6 +8368,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8961,6 +9113,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10188,6 +10349,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f98fd4f..4542d7a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "@supabase/supabase-js": "^2.110.8", "@xyflow/react": "^12.11.2", "exceljs": "^4.4.0", + "kysely": "^0.29.4", "lucide-react": "^1.26.0", "next": "^16.2.11", + "pg": "^8.22.0", "react": "^19.2.8", "react-dom": "^19.2.8", "server-only": "^0.0.1" @@ -36,6 +38,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.3", + "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", diff --git a/supabase/migrations/20260730120000_app_users_and_session_context.sql b/supabase/migrations/20260730120000_app_users_and_session_context.sql new file mode 100644 index 0000000..ef673bd --- /dev/null +++ b/supabase/migrations/20260730120000_app_users_and_session_context.sql @@ -0,0 +1,167 @@ +-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein +-- eigener Sitzungskontext. +-- +-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible +-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an +-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in +-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle — +-- is_hr_user(), das alle 58 RLS-Policies aufrufen. +-- +-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die +-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue +-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig +-- bewegt, lässt sich nicht testen. + +-- ═══ 1. Sitzungskontext ══════════════════════════════════════════ +-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die +-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db). +-- +-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen +-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL +-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst +-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende +-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar. +create or replace function app_current_user_id() +returns uuid +language plpgsql +stable +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid; +begin + -- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst: + -- fehlt die Variable, kommt null statt eines Fehlers. + v_id := nullif(current_setting('app.user_id', true), '')::uuid; + if v_id is not null then + return v_id; + end if; + + -- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in + -- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf + -- auth.users. + begin + execute 'select auth.uid()' into v_id; + exception + when undefined_function or invalid_schema_name or undefined_table then + v_id := null; + end; + return v_id; +end; +$$; + +comment on function app_current_user_id() is + 'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.'; + +grant execute on function app_current_user_id() to anon, authenticated, service_role; + +-- ═══ 2. Benutzertabelle ══════════════════════════════════════════ +-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute +-- dorthin zeigen, wandern in der Abschlussmigration hierher. +create table if not exists app_users ( + id uuid primary key default gen_random_uuid(), + -- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail: + -- eine Namensänderung darf nicht zu einem neuen Konto führen. + external_id text not null unique, + email text not null, + full_name text, + created_at timestamptz not null default now(), + last_seen_at timestamptz +); + +comment on table app_users is + 'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.'; + +create index if not exists app_users_email_idx on app_users (lower(email)); + +alter table app_users enable row level security; + +-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache. +-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile. +drop policy if exists "app_users_select_own" on app_users; +create policy "app_users_select_own" on app_users + for select using (id = app_current_user_id() or is_hr_user()); + +grant select on table app_users to anon, authenticated; +grant all on table app_users to service_role; + +-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════ +-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen +-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen +-- weiterhin is_hr_user() auf und merken davon nichts. +create or replace function is_hr_user() +returns boolean +language sql +security definer +set search_path = public, pg_temp +stable +as $$ + select exists ( + select 1 from profiles p + where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true + ); +$$; + +create or replace function current_hr_user_id() +returns uuid +language sql +security definer +set search_path = public, pg_temp +stable +as $$ + select p.id from profiles p + where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true; +$$; + +create or replace function current_actor_name() +returns text +language sql +stable +set search_path = public, pg_temp +as $$ + select coalesce(p.full_name, p.email, 'Unbekannt') + from profiles p where p.id = app_current_user_id(); +$$; + +-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════ +-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts. +drop policy if exists "profiles_select_own" on profiles; +create policy "profiles_select_own" on profiles + for select using (app_current_user_id() = id); + +-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht +-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab. +drop policy if exists "hire_drafts_owner" on hire_drafts; +create policy "hire_drafts_owner" on hire_drafts + for all + using (created_by = app_current_user_id() and is_hr_user()) + with check (created_by = app_current_user_id() and is_hr_user()); + +drop policy if exists "saved_reports_owner" on saved_reports; +create policy "saved_reports_owner" on saved_reports + for all + using (created_by = app_current_user_id() and is_hr_user()) + with check (created_by = app_current_user_id() and is_hr_user()); + +-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════ +-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das +-- klingt selbstverständlich und ist genau der Fehler, der eine ganze +-- Datenbank öffnet. +do $$ +begin + perform set_config('app.user_id', '', true); + if is_hr_user() then + raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.'; + end if; + + perform set_config('app.user_id', gen_random_uuid()::text, true); + if is_hr_user() then + raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.'; + end if; + + -- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und + -- was danach in derselben Sitzung läuft, soll sie nicht erben. + perform set_config('app.user_id', '', true); +end; +$$; diff --git a/tests/integration/session-context.test.ts b/tests/integration/session-context.test.ts new file mode 100644 index 0000000..a80e48a --- /dev/null +++ b/tests/integration/session-context.test.ts @@ -0,0 +1,118 @@ +import { randomUUID } from "node:crypto"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Der Test, ohne den die Zugriffsschicht eine Behauptung wäre. +// +// Die Zugriffsrechte hängen an `current_setting('app.user_id')`. Diese +// Variable gehört der *Verbindung*, nicht der Anfrage — und Verbindungen +// kommen aus einem Pool. Wird sie ohne Transaktion gesetzt, sieht die +// nächste Anfrage auf derselben Verbindung die Kennung der vorherigen +// Person. +// +// Deshalb läuft hier ein Pool mit **genau einer** Verbindung: so ist +// garantiert, dass zwei aufeinanderfolgende Zugriffe dieselbe physische +// Verbindung benutzen. Mit mehreren Verbindungen würde der Test die Lücke +// zufällig mal treffen und mal nicht — und wäre damit wertlos. + +const CONNECTION = process.env.DATABASE_URL; + +// Ohne Datenbank lässt sich lokal nichts prüfen — in der CI wäre ein +// stillschweigend übersprungener Sicherheitstest allerdings schlimmer als +// gar keiner. Deshalb schlägt dort schon das Fehlen des Verbindungsstrings +// fehl, statt eine grüne Anzeige vorzutäuschen. +describe("Voraussetzung", () => { + it.runIf(process.env.CI)("DATABASE_URL ist in der CI gesetzt", () => { + expect( + CONNECTION, + "DATABASE_URL fehlt. Ohne sie wird der Nachweis zum Sitzungskontext übersprungen — " + + "und genau dieser Nachweis trägt die Zugriffsabsicherung." + ).toBeTruthy(); + }); +}); + +const pool = new Pool({ connectionString: CONNECTION, max: 1 }); +const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }); + +/** Die Fassung aus lib/db: transaktionslokal. */ +async function withUser(userId: string | null, fn: () => Promise): Promise { + return db.transaction().execute(async (tx) => { + await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); + return fn(); + }); +} + +async function currentUserOnConnection(): Promise { + const r = await sql<{ v: string | null }>`select nullif(current_setting('app.user_id', true), '') as v`.execute(db); + return r.rows[0]?.v ?? null; +} + +describe.skipIf(!CONNECTION)("Sitzungskontext über eine gepoolte Verbindung", () => { + const alice = randomUUID(); + const bob = randomUUID(); + + beforeAll(async () => { + // Ein Pool mit einer Verbindung: der Test ist nur dann aussagekräftig, + // wenn beide Zugriffe garantiert dieselbe benutzen. + await sql`select 1`.execute(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("sieht innerhalb der Transaktion die eigene Kennung", async () => { + const seen = await withUser(alice, async () => { + const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); + return r.rows[0].v; + }); + expect(seen).toBe(alice); + }); + + it("lässt nach der Transaktion nichts an der Verbindung zurück", async () => { + // Das ist der eigentliche Befund: nach dem Commit muss die Variable weg + // sein. Bliebe sie stehen, liefe die nächste Anfrage im Namen von Alice. + await withUser(alice, async () => { + await sql`select 1`.execute(db); + }); + expect(await currentUserOnConnection()).toBeNull(); + }); + + it("trägt die Kennung nicht von einer Anfrage in die nächste", async () => { + await withUser(alice, async () => { + await sql`select 1`.execute(db); + }); + + const seenByBob = await withUser(bob, async () => { + const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); + return r.rows[0].v; + }); + expect(seenByBob).toBe(bob); + expect(seenByBob).not.toBe(alice); + }); + + it("hinterlässt auch nach einem Fehler keinen Kontext", async () => { + // Ein Rollback muss die Variable genauso zurücknehmen wie ein Commit — + // sonst wird ausgerechnet der Fehlerfall zur Lücke. + await expect( + withUser(alice, async () => { + await sql`select 1 / 0`.execute(db); + }) + ).rejects.toThrow(); + + expect(await currentUserOnConnection()).toBeNull(); + }); + + it("zeigt, dass eine Einstellung ohne Transaktion tatsächlich hängen bleibt", async () => { + // Die Gegenprobe. Sie belegt, dass die Vorsichtsmassnahme kein Ritual + // ist: ohne das dritte Argument `true` überlebt die Kennung die Anfrage + // und die nächste Person erbt sie. + await sql`select set_config('app.user_id', ${alice}, false)`.execute(db); + expect(await currentUserOnConnection()).toBe(alice); + + // Aufräumen, sonst verfälscht dieser Test die anderen. + await sql`select set_config('app.user_id', '', false)`.execute(db); + expect(await currentUserOnConnection()).toBeNull(); + }); +});