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:
12
.env.example
12
.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=
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 };
|
||||
39
lib/db/pool.ts
Normal file
39
lib/db/pool.ts
Normal file
@@ -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);
|
||||
});
|
||||
49
lib/db/schema.ts
Normal file
49
lib/db/schema.ts
Normal 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 };
|
||||
170
package-lock.json
generated
170
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
118
tests/integration/session-context.test.ts
Normal file
118
tests/integration/session-context.test.ts
Normal file
@@ -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<Record<string, never>>({ dialect: new PostgresDialect({ pool }) });
|
||||
|
||||
/** Die Fassung aus lib/db: transaktionslokal. */
|
||||
async function withUser<T>(userId: string | null, fn: () => Promise<T>): Promise<T> {
|
||||
return db.transaction().execute(async (tx) => {
|
||||
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
|
||||
return fn();
|
||||
});
|
||||
}
|
||||
|
||||
async function currentUserOnConnection(): Promise<string | null> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user