From 44a90e5bfd06f3bc1c283520088464eab4169834 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 10:03:25 +0200 Subject: [PATCH] feat(quick-260909-dgj): Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung (Task 3/4) - apps/api/scripts/rls-preflight.mjs misst fuenf benannte Eigenschaften (rollenrechte, kontext-setzbar, ohne-kontext-leer, mit-kontext-sichtbar, schreibrechte) jeweils in einer eigenen Transaktion gegen eine per TESSERA_PREFLIGHT_DATABASE_URL angegebene Verbindung; --print-plan verbindet nicht, das Werkzeug schreibt in keiner Betriebsart - 5 Tests in rls-preflight.spec.ts (rot vor dem Werkzeug, jetzt gruen) - docs/mandantentrennung-datenbankrolle.md: Befund, Sperrgrund (182 unskalierte Zugriffe, Anmeldeweg), Handgriffe des Betreibers samt Kennwortsetzung, Freigabebedingung und Rueckweg; docs/README.md verweist darauf Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU --- apps/api/scripts/rls-preflight.mjs | 192 ++++++++++++++++++++++ apps/api/src/prisma/rls-preflight.spec.ts | 62 +++++++ docs/README.md | 4 + docs/mandantentrennung-datenbankrolle.md | 173 +++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100755 apps/api/scripts/rls-preflight.mjs create mode 100644 apps/api/src/prisma/rls-preflight.spec.ts create mode 100644 docs/mandantentrennung-datenbankrolle.md diff --git a/apps/api/scripts/rls-preflight.mjs b/apps/api/scripts/rls-preflight.mjs new file mode 100755 index 0000000..ed9c71f --- /dev/null +++ b/apps/api/scripts/rls-preflight.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// WINDOWS #18 — misst, statt zu behaupten, ob die Mandantentrennung unter +// einer angegebenen Datenbankrolle tatsaechlich greift (Task 3). +// +// Verbindet mit der ueber TESSERA_PREFLIGHT_DATABASE_URL angegebenen Rolle +// (new PrismaClient({ datasourceUrl: url })), damit gegen eine andere Rolle +// gemessen werden kann als die, mit der die Anwendung laeuft. Kein neues +// Paket noetig — Prisma ist bereits Abhaengigkeit der API. +// +// Jede Pruefung laeuft in einer eigenen interaktiven Transaktion +// (prisma.$transaction), und jedes Setzen des Mandantenkontexts geschieht +// transaktionslokal (set_config(..., true)) — genau wie +// apps/api/src/prisma/prisma-tenant.extension.ts es tut. Ausserhalb einer +// Transaktion kann Prismas Verbindungspool die Folgeabfrage auf eine andere +// physische Verbindung legen, auf der die Einstellung nie gesetzt wurde; +// die Messung waere dann wertlos. +// +// Das Werkzeug schreibt nichts. Es liest ausschliesslich. + +import { PrismaClient } from '@prisma/client'; + +const CHECKS = [ + ['rollenrechte', 'current_user traegt weder rolsuper noch rolbypassrls (WINDOWS #18, der eigentliche Kern dieser Pruefung)'], + ['kontext-setzbar', 'set_config(app.current_tenant, ...) laesst sich ohne besonderes Recht setzen und lesen'], + ['ohne-kontext-leer', 'ohne gesetzten Mandantenkontext liefert jede Tabelle mit Policy null Zeilen'], + ['mit-kontext-sichtbar', 'mit gesetztem Mandantenkontext ist mindestens eine Tabelle wieder sichtbar'], + ['schreibrechte', 'current_user hat auf jede Tabelle im Schema public alle vier Zugriffsarten'], +]; + +const ENV_VAR = 'TESSERA_PREFLIGHT_DATABASE_URL'; + +function printPlan() { + console.log('Pruefplan (verbindet nicht):'); + for (const [kennung, beschreibung] of CHECKS) { + console.log(`- ${kennung}: ${beschreibung}`); + } + console.log(`Verbindung wird aus der Umgebungsvariablen ${ENV_VAR} gelesen.`); +} + +async function checkRollenrechte(tx) { + const rows = await tx.$queryRaw`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`; + const row = rows[0]; + const passed = Boolean(row) && row.rolsuper === false && row.rolbypassrls === false; + return { + kennung: 'rollenrechte', + passed, + detail: row + ? `Rolle ${row.rolname}: rolsuper=${row.rolsuper}, rolbypassrls=${row.rolbypassrls}` + : 'current_user nicht in pg_roles gefunden', + }; +} + +async function checkKontextSetzbar(tx) { + await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, 'probe'); + const rows = await tx.$queryRaw`SELECT current_tenant_id() AS tenant`; + const value = rows[0]?.tenant; + return { + kennung: 'kontext-setzbar', + passed: value === 'probe', + detail: `current_tenant_id() lieferte: ${JSON.stringify(value)}`, + }; +} + +async function getPolicyTables(prisma) { + const rows = await prisma.$queryRaw`SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename`; + return rows.map((r) => r.tablename); +} + +async function countRows(tx, table) { + const rows = await tx.$queryRawUnsafe(`SELECT count(*)::int AS count FROM "${table}"`); + return rows[0]?.count ?? 0; +} + +async function checkOhneKontextLeer(tx, policyTables) { + const nonZero = []; + for (const table of policyTables) { + const count = await countRows(tx, table); + if (count !== 0) nonZero.push(`${table}=${count}`); + } + return { + kennung: 'ohne-kontext-leer', + passed: nonZero.length === 0, + detail: + nonZero.length === 0 + ? `alle ${policyTables.length} Tabellen mit Policy liefern 0 Zeilen` + : `sichtbar ohne Kontext (Beweis fuer wirkungslose Trennung): ${nonZero.join(', ')}`, + }; +} + +async function checkMitKontextSichtbar(prisma, tx, policyTables) { + const tenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" LIMIT 1`; + const tenantId = tenantRows[0]?.id; + if (!tenantId) { + return { + kennung: 'mit-kontext-sichtbar', + passed: null, + detail: 'nicht durchfuehrbar — kein Mandant in der Tabelle Tenant gefunden', + }; + } + + await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId); + const visible = []; + for (const table of policyTables) { + const count = await countRows(tx, table); + if (count > 0) visible.push(`${table}=${count}`); + } + return { + kennung: 'mit-kontext-sichtbar', + passed: visible.length > 0, + detail: + visible.length > 0 + ? `mit Mandant ${tenantId} sichtbar: ${visible.join(', ')}` + : `mit Mandant ${tenantId} liefert weiterhin keine Tabelle Zeilen`, + }; +} + +async function checkSchreibrechte(tx) { + const tableRows = await tx.$queryRaw`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`; + const privileges = ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; + const gaps = []; + + for (const { table_name: table } of tableRows) { + const missing = []; + for (const priv of privileges) { + const rows = await tx.$queryRawUnsafe( + `SELECT has_table_privilege(current_user, $1, $2) AS allowed`, + table, + priv, + ); + if (!rows[0]?.allowed) missing.push(priv); + } + if (missing.length > 0) gaps.push(`${table} fehlt ${missing.join('/')}`); + } + + return { + kennung: 'schreibrechte', + passed: gaps.length === 0, + detail: gaps.length === 0 ? `alle ${tableRows.length} Tabellen vollstaendig` : gaps.join('; '), + }; +} + +async function runChecks(url) { + const prisma = new PrismaClient({ datasourceUrl: url }); + const results = []; + + try { + results.push(await prisma.$transaction((tx) => checkRollenrechte(tx))); + results.push(await prisma.$transaction((tx) => checkKontextSetzbar(tx))); + + const policyTables = await getPolicyTables(prisma); + + results.push(await prisma.$transaction((tx) => checkOhneKontextLeer(tx, policyTables))); + results.push(await prisma.$transaction((tx) => checkMitKontextSichtbar(prisma, tx, policyTables))); + results.push(await prisma.$transaction((tx) => checkSchreibrechte(tx))); + } finally { + await prisma.$disconnect(); + } + + return results; +} + +async function main() { + const args = process.argv.slice(2); + + if (args[0] === '--print-plan') { + printPlan(); + process.exit(0); + } + + const url = process.env[ENV_VAR]; + if (!url) { + console.error(`FEHLER: ${ENV_VAR} ist nicht gesetzt.`); + process.exit(1); + } + + const results = await runChecks(url); + + console.log('Ergebnis der Mandantentrennungs-Pruefung:'); + let allPassed = true; + for (const { kennung, passed, detail } of results) { + const status = passed === true ? 'bestanden' : passed === false ? 'FEHLGESCHLAGEN' : 'nicht durchfuehrbar'; + if (passed === false) allPassed = false; + console.log(`${kennung}: ${status} — ${detail}`); + } + + process.exit(allPassed ? 0 : 1); +} + +main().catch((err) => { + console.error('FEHLER beim Ausfuehren der Pruefung:', err.message); + process.exit(1); +}); diff --git a/apps/api/src/prisma/rls-preflight.spec.ts b/apps/api/src/prisma/rls-preflight.spec.ts new file mode 100644 index 0000000..2f0db00 --- /dev/null +++ b/apps/api/src/prisma/rls-preflight.spec.ts @@ -0,0 +1,62 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prueft apps/api/scripts/rls-preflight.mjs ausschliesslich in der + * Pruefplan-Betriebsart (--print-plan) — es wird keine Datenbankverbindung + * aufgebaut (Task 3, WINDOWS #18). + */ + +const SCRIPT_PATH = join(__dirname, '../../scripts/rls-preflight.mjs'); + +describe('rls-preflight.mjs --print-plan', () => { + it('Test 1: endet mit Ende-Code 0, obwohl keine Verbindungsangabe in der Umgebung steht', () => { + const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], { + env: { PATH: process.env.PATH ?? '' }, + encoding: 'utf-8', + }); + expect(stdout).toBeTruthy(); + }); + + it('Test 2: nennt alle fuenf Pruefkennungen', () => { + const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], { + env: { PATH: process.env.PATH ?? '' }, + encoding: 'utf-8', + }); + + for (const kennung of [ + 'rollenrechte', + 'kontext-setzbar', + 'ohne-kontext-leer', + 'mit-kontext-sichtbar', + 'schreibrechte', + ]) { + expect(stdout).toContain(kennung); + } + }); + + it('Test 3: nennt TESSERA_PREFLIGHT_DATABASE_URL als Quelle der zu pruefenden Verbindung', () => { + const stdout = execFileSync(process.execPath, [SCRIPT_PATH, '--print-plan'], { + env: { PATH: process.env.PATH ?? '' }, + encoding: 'utf-8', + }); + expect(stdout).toContain('TESSERA_PREFLIGHT_DATABASE_URL'); + }); + + it('Test 4: jede Pruefung laeuft innerhalb einer Transaktion ($transaction) — ausserhalb kann der Verbindungspool die Folgeabfrage auf eine andere Verbindung legen als die, auf der set_config gesetzt wurde, und die Messung waere wertlos', () => { + const source = readFileSync(SCRIPT_PATH, 'utf-8'); + expect(source).toContain('$transaction'); + }); + + it('Test 5: das Werkzeug schreibt nicht — keines der Schluesselwoerter INSERT, UPDATE, DELETE, DROP, ALTER in einer SQL-Zeichenkette', () => { + const source = readFileSync(SCRIPT_PATH, 'utf-8'); + const sqlStrings = source.match(/`[^`]*`/g) ?? []; + for (const str of sqlStrings) { + for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER']) { + expect(str.toUpperCase()).not.toContain(verb); + } + } + }); +}); diff --git a/docs/README.md b/docs/README.md index cbda21e..08b9944 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,10 @@ Daneben liegt das [CI/CD-Runbook](ci-cd-setup.md), das die Einrichtung der Bau-Pipeline in Gitea beschreibt. Es richtet sich an dieselben Leute wie die Betriebsanleitung, deckt aber nur den Weg vom Quelltext zum fertigen Abbild ab. +Ebenfalls dabei: [Mandantentrennung auf Datenbankebene](mandantentrennung-datenbankrolle.md), +das sich an dieselben Leute wie die Betriebsanleitung richtet und ausschliesslich +die Datenbankrolle behandelt, mit der Tessera verbindet (WINDOWS #18). + --- ## Die drei Dinge, die am häufigsten Zeit kosten diff --git a/docs/mandantentrennung-datenbankrolle.md b/docs/mandantentrennung-datenbankrolle.md new file mode 100644 index 0000000..7f83de1 --- /dev/null +++ b/docs/mandantentrennung-datenbankrolle.md @@ -0,0 +1,173 @@ + +# Tessera – Mandantentrennung auf Datenbankebene (WINDOWS #18) + +Diese Anleitung richtet sich an dieselben Kolleg:innen wie das +[Betriebshandbuch](./anleitung-betrieb.md) und behandelt nur einen einzigen +Punkt: die Datenbankrolle, mit der Tessera verbindet, und warum die +bestehenden Sicherheitsregeln (Row-Level Security) heute keine Wirkung +haben. + +## Inhaltsverzeichnis + +1. [Der Befund](#1-der-befund) +2. [Was bereits vorbereitet ist](#2-was-bereits-vorbereitet-ist) +3. [Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist](#3-der-sperrgrund--warum-die-umstellung-noch-nicht-erfolgt-ist) +4. [Die Umstellung, Schritt fuer Schritt](#4-die-umstellung-schritt-fuer-schritt) +5. [Die Vorher-Pruefung](#5-die-vorher-pruefung) +6. [Der Rueckweg](#6-der-rueckweg) + +--- + +## 1. Der Befund + +Tessera trennt die Daten mehrerer Mandanten auf zwei Ebenen: im Anwendungscode +(`where: { tenantId }`) und zusaetzlich auf Datenbankebene ueber PostgreSQL +Row-Level Security (RLS) — ein „zweites Sicherheitsnetz" fuer den Fall, dass +im Code einmal ein `tenantId`-Filter vergessen wird. + +Dieses zweite Netz existiert seit Juni 2026 auf sieben Tabellen und wurde am +2026-09-09 mit einer wichtigen Einschraenkung nachgemessen: **es greift +derzeit nicht.** Die API verbindet laut `docker-compose.yml` als Rolle +`tessera`. Diese Rolle entsteht aus der Umgebungsvariable `POSTGRES_USER` und +ist damit automatisch Superuser des PostgreSQL-Clusters. PostgreSQL wendet +Row-Level Security auf Superuser-Rollen grundsaetzlich nicht an — auch nicht +auf Rollen mit dem Recht `BYPASSRLS`, das Superuser automatisch mitbringen. +Der Zusatz `FORCE ROW LEVEL SECURITY` in den bestehenden Migrationen aendert +daran nichts: er erzwingt Policies nur gegenueber dem *Tabelleneigentuemer*, +nicht gegenueber Rollen mit Umgehungsrecht — und die Rolle `tessera` ist +beides zugleich. + +Praktisch nachgemessen am 2026-09-09: ein `SELECT count(*) FROM "Group"` ohne +gesetzten Mandantenkontext liefert zwei Zeilen, obwohl die vorhandene Policy +`USING ("tenantId" = current_tenant_id())` bei fehlendem Kontext null Zeilen +liefern muesste. Die Isolation zwischen Mandanten haengt heute vollstaendig +am `where`-Filter im Anwendungscode — nicht an der Datenbank. + +Dieser Befund ist als [WINDOWS #18](../.planning/WINDOWS.md) im +Fehler-Ledger festgehalten. Kein akutes Risiko, solange Tessera nur intern +und einmandantig laeuft — aber zwingend zu beheben, bevor Tessera an einen +zweiten, externen Mandanten geht. + +## 2. Was bereits vorbereitet ist + +Diese Aenderung liefert die vier Bausteine, die fuer eine wirksame Trennung +noetig sind: + +- **Eine eigene Datenbankrolle** `tessera_app` ohne Superuser- und ohne + BYPASSRLS-Recht (Migration `20260909130000_rls_app_role`). +- **Getrennte Verbindungen fuer Migration und Laufzeit.** `prisma migrate + deploy` braucht die Rechte des Tabelleneigentuemers, die Anwendung soll + sie nicht haben — `apps/api/scripts/migrate-and-start.sh` trennt beide + Schritte ueber die neue Variable `TESSERA_MIGRATE_DATABASE_URL`. +- **Ein Pruefwerkzeug**, das den Nachweis fuehrt statt ihn zu behaupten: + `apps/api/scripts/rls-preflight.mjs` misst gegen eine angegebene + Verbindung Rollenrechte, Setzbarkeit des Mandantenkontexts, sichtbare + Zeilen mit und ohne Kontext sowie vorhandene Zugriffsrechte. +- **Vollstaendige Policy-Abdeckung.** Migration + `20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang + fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine + Regel. + +## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist + +**Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht +umgestellt werden.** + +Am 2026-09-09 wurde im Quelltext von `apps/api/src` gezaehlt, wie oft die +Anwendung ueber den unskalierten Prisma-Client zugreift (also ohne den +Mandantenkontext zu setzen) gegenueber der Anzahl der Verwendungen des +mandantengebundenen `tenantPrisma`: **182 unskalierte Zugriffe** — 84 auf die +sieben bereits mit Policies versehenen Tabellen, 98 auf die 16 neu +hinzugekommenen — gegenueber lediglich **19 Verwendungen** von `tenantPrisma` +im gesamten API-Quelltext. + +Darunter ist der Anmeldeweg selbst, und der kann strukturell nicht anders +funktionieren: `apps/api/src/auth/auth.service.ts` sucht den Benutzer anhand +des Benutzernamens, **bevor** der Mandant bekannt ist — der Mandant wird ja +erst aus dem gefundenen Benutzer bestimmt. Unter der Rolle `tessera_app` +liefert genau diese Suche null Zeilen. **Niemand koennte sich mehr +anmelden.** + +Ebenso betroffen sind Hintergrunddienste, die von Natur aus ohne +Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die +Modulzugriffspruefung, die Treffersuche im Ausschreibungs-Radar und die +Erstanlage des Administrators beim ersten Start. + +Diese 182 Stellen brauchen einen ausdruecklichen, benannten Systemkontext +(oder eine gezielte Umstellung auf `tenantPrisma`/`forTenant()`), bevor der +Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser +Aenderung.** Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt +ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese +182 Zugriffe aus. + +## 4. Die Umstellung, Schritt fuer Schritt + +Erst durchfuehren, wenn Abschnitt 3 abgearbeitet ist. Reihenfolge einhalten +— die Migration muss weiterhin als Tabelleneigentuemer laufen. + +1. **Kennwort der Rolle einmalig setzen**, als Datenbank-Superuser: + + ```sql + ALTER ROLE tessera_app WITH PASSWORD ''; + ``` + + Dieser Befehl gehoert in keine Datei und in kein Protokoll — er wird + direkt an `psql` uebergeben und danach aus der Shell-Historie entfernt. + +2. **In der `.env` des Servers** `TESSERA_MIGRATE_DATABASE_URL` auf die + **bisherige** Verbindung (Rolle `tessera`, Tabelleneigentuemer) setzen und + `DATABASE_URL` auf die **neue** Rolle `tessera_app` umbiegen — in dieser + Reihenfolge, denn `prisma migrate deploy` muss weiterhin mit + Eigentuemerrechten laufen, waehrend die Anwendung selbst die eingeschraenkte + Rolle bekommt. + +3. Vor dem Neustart die Vorher-Pruefung aus Abschnitt 5 gegen die neue Rolle + ausfuehren. + +4. API-Dienst neu erstellen (`docker compose up -d --build --force-recreate + api`, siehe Betriebshandbuch Abschnitt 4). + +## 5. Die Vorher-Pruefung + +```bash +TESSERA_PREFLIGHT_DATABASE_URL="postgresql://tessera_app:@:5432/tessera" \ + node apps/api/scripts/rls-preflight.mjs +``` + +Das Werkzeug meldet fuenf Pruefungen: Rollenrechte, Setzbarkeit des +Mandantenkontexts, keine sichtbaren Zeilen ohne Kontext, sichtbare Zeilen mit +Kontext, vollstaendige Zugriffsrechte. Es veraendert nichts — es liest +ausschliesslich. + +**Ein gruener Bericht ist die Freigabebedingung fuer Schritt 4 aus Abschnitt +4 — aber er ist keine Freigabe fuer die Umstellung insgesamt.** Er bestaetigt +nur, dass die Datenbankseite stimmt. Die 182 unskalierten Zugriffe aus +Abschnitt 3 misst er nicht und kann sie nicht messen — dafuer muesste er den +Anwendungscode lesen, nicht die Datenbank. + +## 6. Der Rueckweg + +**Wenn die API nach einer Umstellung nicht mehr hochkommt:** + +Das sichtbare Bild: der `api`-Container bleibt ungesund (Healthcheck +schlaegt fehl), und das Protokoll (`docker compose logs api`) zeigt einen +Authentifizierungs- oder Rechtefehler von PostgreSQL — etwa eine +fehlgeschlagene Anmeldung oder eine verweigerte Berechtigung. + +Weg zurueck, zwei Schritte: + +1. `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung (Rolle + `tessera`) setzen. +2. `docker compose up -d --build --force-recreate api` ausfuehren. + +Die Rolle `tessera_app` darf dabei unangetastet bestehen bleiben — sie +schadet nicht, solange niemand mit ihr verbindet. + +**Wichtiger Hinweis fuer den Server:** `/opt/tessera/docker-compose.yml` ist +keine Arbeitskopie dieses Repositorys. Aenderungen an `docker-compose.yml` +oder `docker-compose.prod.yml` in diesem Repository erreichen den Server +nicht automatisch. Wer `TESSERA_MIGRATE_DATABASE_URL` dort verfuegbar haben +will, traegt die Zeile selbst in `/opt/tessera/docker-compose.yml` ein +(vorher sichern) — genau wie es bereits fuer andere nachtraegliche +Variablen dokumentiert ist (Betriebshandbuch, Abschnitt zur +Server-Konfiguration).