#!/usr/bin/env node // WINDOWS #20 (260909-eor) — richtet sich eine eigene Wegwerf-Datenbank ein // und misst dort live, ob das reparierte forTenant()-Muster (Aufgabe 1) // tatsaechlich das tut, was es behauptet. Aufgabe 2 erweitert dieses // Werkzeug um einen zweiten Abschnitt fuer die auth_lookup_*-Funktionen. // // Ruehrt die Datenbank "tessera" NICHT an (T-EOR-07): der Name der // Wegwerf-Datenbank ist fest im Werkzeug verdrahtet, nicht ueber eine // Umgebungsvariable steuerbar, damit ein Tippfehler nicht in der echten // Datenbank landet. Verbindungsangaben kommen ausschliesslich ueber // TESSERA_SCRATCH_ADMIN_URL (Verbindung zu einer Wartungsdatenbank wie // "postgres" mit Rechten, um eine neue Datenbank/Rolle anzulegen und wieder // abzuraeumen). Ohne diese Variable bricht das Werkzeug mit einer Anleitung // ab, statt eine Vorgabe zu raten. // // Nutzt ausschliesslich @prisma/client (bereits Abhaengigkeit der API) — // kein neues Paket. Fuer DDL (CREATE DATABASE/ROLE mit festen, im Werkzeug // hartkodierten Namen) ist Interpolation unvermeidlich, da PostgreSQL // Identifier nicht parametrisieren kann; es fliesst dabei nirgends // Nutzereingabe ein. // // Dupliziert bewusst das forTenant()-Verbindungsmuster statt die // TypeScript-Quelle unter apps/api/src zu importieren — dasselbe Vorgehen // wie im bestehenden apps/api/scripts/rls-preflight.mjs, weil ein reines // Node-Skript ohne Build-Schritt kein .ts importieren kann. // // Meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald // eine Pruefung scheitert. Gibt kein Kennwort und keine vollstaendige // Verbindungszeichenkette aus. import { PrismaClient } from '@prisma/client'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ADMIN_ENV_VAR = 'TESSERA_SCRATCH_ADMIN_URL'; const SCRATCH_DB_NAME = 'tessera_rls_scratch'; const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role'; const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused'; const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations'); const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma'); /** * Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$ * -Bloecke) als EIN Kommando aus. `prisma.$executeRawUnsafe` nutzt das * erweiterte Protokoll und erlaubt pro Aufruf nur eine einzelne Anweisung — * `prisma db execute --file` sendet das gesamte Skript dagegen als ein * Kommando (einfaches Protokoll) und ist genau dafuer vorgesehen, ganze * Migrationsdateien auszufuehren. */ function executeSqlScript(databaseUrl, sql) { const dir = mkdtempSync(join(tmpdir(), 'rls-scratch-check-')); const file = join(dir, 'script.sql'); writeFileSync(file, sql, 'utf-8'); try { execFileSync(PRISMA_BIN, ['db', 'execute', '--file', file, '--url', databaseUrl], { stdio: 'pipe', }); } finally { rmSync(dir, { recursive: true, force: true }); } } function fail(message) { console.error(`FEHLER: ${message}`); process.exit(1); } function parseAdminUrl() { const raw = process.env[ADMIN_ENV_VAR]; if (!raw) { fail( `${ADMIN_ENV_VAR} ist nicht gesetzt. Beispiel: ` + `${ADMIN_ENV_VAR}="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" ` + `node apps/api/scripts/rls-scratch-check.mjs`, ); } return raw; } function urlForDatabase(adminUrl, dbName) { const url = new URL(adminUrl); url.pathname = `/${dbName}`; return url; } async function withAdminPrisma(adminUrl, fn) { const prisma = new PrismaClient({ datasourceUrl: adminUrl }); try { return await fn(prisma); } finally { await prisma.$disconnect(); } } async function setupScratchDatabase(adminUrl) { await withAdminPrisma(adminUrl, async (admin) => { await admin.$executeRawUnsafe( `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`, ); await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`); await admin.$executeRawUnsafe(`CREATE DATABASE ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe( `CREATE ROLE ${SCRATCH_ROLE_NAME} WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE PASSWORD '${SCRATCH_ROLE_PASSWORD}'`, ); }); const scratchAdminUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(); await withAdminPrisma(scratchAdminUrl, async (db) => { await db.$executeRawUnsafe(` CREATE TABLE probe ( id serial PRIMARY KEY, "tenantId" text NOT NULL, label text NOT NULL ); `); await db.$executeRawUnsafe(` CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$ SELECT current_setting('app.current_tenant', true); $$ LANGUAGE sql STABLE; `); await db.$executeRawUnsafe(`ALTER TABLE probe ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE probe FORCE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(` CREATE POLICY tenant_isolation_policy ON probe USING ("tenantId" = current_tenant_id()); `); await db.$executeRawUnsafe(`GRANT USAGE ON SCHEMA public TO ${SCRATCH_ROLE_NAME}`); await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON probe TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `GRANT EXECUTE ON FUNCTION current_tenant_id() TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`, ); }); } async function teardownScratchDatabase(adminUrl) { await withAdminPrisma(adminUrl, async (admin) => { await admin.$executeRawUnsafe( `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`, ); await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`); }); } function report(results, kennung, passed, detail) { const status = passed ? 'bestanden' : 'FEHLGESCHLAGEN'; console.log(`${kennung}: ${status} — ${detail}`); results.push({ kennung, passed, detail }); } /** * Repliziert exakt das reparierte forTenant()-Muster aus * apps/api/src/prisma/prisma-tenant.extension.ts: set_config und die * eigentliche Abfrage als Array-Form von $transaction, also auf einer * gemeinsamen Verbindung. */ async function forTenantQuery(prisma, tenantId, queryFn) { const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]); return result; } /** * Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von * forTenant() unter der Rolle ohne BYPASSRLS. */ async function runForTenantChecks(scratchRoleUrl, results) { const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { // 1+2: gleiche Verbindung UND gesetzter Kontext — als zwei Teilmessungen // einer einzigen Array-Transaktion, im selben Format wie die urspruengliche // Fehlerreproduktion (Backend-PID beim set_config-Schritt vs. Backend-PID // bei der eigentlichen Abfrage; siehe Kopfkommentar von // prisma-tenant.extension.ts: "inside tx"/"actual qry"). const [setStepRow, queryStepRow] = await prisma.$transaction([ prisma.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', 'TENANT-A', true) AS applied`, prisma.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`, ]).then(([setRows, queryRows]) => [setRows[0], queryRows[0]]); report( results, 'gleiche-backend-verbindung', setStepRow.pid === queryStepRow.pid, `set_config-Schritt pg_backend_pid()=${setStepRow.pid}, Abfrage-Schritt pg_backend_pid()=${queryStepRow.pid}`, ); report( results, 'mandantenkontext-waehrend-abfrage-gesetzt', queryStepRow.t === 'TENANT-A', `current_tenant_id() waehrend der eigentlichen Abfrage=${JSON.stringify(queryStepRow.t)}`, ); // 3+4: forTenant(A) liefert ausschliesslich Zeilen von A, keine von B. const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM probe ORDER BY id`, ); const onlyA = rowsForA.length > 0 && rowsForA.every((r) => r.tenantId === 'TENANT-A'); report( results, 'nur-eigene-mandanten-zeilen', onlyA, `forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.tenantId))}`, ); const leaksB = rowsForA.some((r) => r.tenantId === 'TENANT-B'); report( results, 'keine-fremdmandanten-zeilen', !leaksB, leaksB ? 'Zeile von TENANT-B sichtbar unter forTenant(TENANT-A)' : 'keine Zeile von TENANT-B sichtbar', ); // 5: ungebundener Zugriff derselben Rolle liefert null Zeilen. const unbound = await prisma.$queryRaw`SELECT "tenantId" FROM probe`; report( results, 'ungebunden-liefert-null-zeilen', unbound.length === 0, `ungebundener SELECT liefert ${unbound.length} Zeile(n)`, ); } finally { await prisma.$disconnect(); } } function readAuthLookupMigrationSql() { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_lookup_functions')) .map((entry) => entry.name); if (dirs.length !== 1) return null; return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } /** * Aufgabe 2 — spielt die auth_lookup_*-Migration in die Wegwerf-Datenbank * ein (mit tessera_app durch die Wegwerf-Rolle ersetzt), legt zwei Benutzer * in zwei Mandanten an und misst unter der Rolle ohne BYPASSRLS: * Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf "User" * liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts. */ async function runAuthLookupChecks(adminUrl, scratchRoleUrl, results) { const migrationSql = readAuthLookupMigrationSql(); if (!migrationSql) { report( results, 'auth-lookup-migration-vorhanden', false, 'Migrationsverzeichnis *_auth_lookup_functions nicht gefunden', ); return; } await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { await db.$executeRawUnsafe(` CREATE TABLE "User" ( id text PRIMARY KEY, username text UNIQUE NOT NULL, email text UNIQUE, "tenantId" text NOT NULL, "passwordHash" text, "ldapDn" text, "isActive" boolean NOT NULL DEFAULT true, role text NOT NULL DEFAULT 'USER', "displayName" text, "mustChangePassword" boolean NOT NULL DEFAULT false ); `); await db.$executeRawUnsafe(`ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "User" FORCE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe( `CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());`, ); await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "User" TO ${SCRATCH_ROLE_NAME}`); await db.$executeRawUnsafe(` INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive") VALUES ('user-a', 'alice', 'TENANT-A', 'hash-a', true), ('user-b', 'bob', 'TENANT-B', 'hash-b', true); `); // Migration nutzt echte Postgres-ENUM-Werte fuer "role" (Typ "Role") — // die Wegwerf-Tabelle oben verwendet stattdessen text, das ist fuer die // hier gemessenen drei Verhaltensweisen ausreichend. Die Funktion // auth_lookup_user_by_username referenziert den Spaltentyp nicht direkt // (SELECT u.role liefert einfach den gespeicherten Wert), daher // funktioniert das ohne den ENUM-Typ anzulegen — mit einer Ausnahme: // die RETURNS TABLE-Deklaration der echten Migration nennt den Typ // "Role" explizit. Fuer die Wegwerf-Pruefung wird er hier nachgebildet. await db.$executeRawUnsafe(` DO $$ BEGIN CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; `); await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;`); await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role TYPE "Role" USING role::"Role";`); await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'USER'::"Role";`); await db.$executeRawUnsafe(` CREATE TABLE "PasswordResetToken" ( id text PRIMARY KEY, token text UNIQUE NOT NULL, "userId" text NOT NULL REFERENCES "User"(id), "expiresAt" timestamp(3) NOT NULL, "usedAt" timestamp(3) ); `); // Die echte Migration erteilt das Ausfuehrungsrecht ausschliesslich an // tessera_app — fuer die Wegwerf-Pruefung an die Scratch-Rolle // umgeleitet, ohne den Rest der Migration zu veraendern. Ueber // executeSqlScript (prisma db execute --file), weil die Migration // mehrere Anweisungen inklusive DO $$ ... $$-Bloecke enthaelt, die sich // nicht als einzelnes $executeRawUnsafe senden lassen. }); const adaptedSql = migrationSql.replaceAll('tessera_app', SCRATCH_ROLE_NAME); executeSqlScript(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), adaptedSql); const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { const found = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`; report( results, 'anmeldesuche-findet-benutzer', found.length === 1 && found[0].username === 'alice', `auth_lookup_user_by_username('alice') liefert ${found.length} Zeile(n)`, ); const notFound = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('unknown-user')`; report( results, 'anmeldesuche-unbekannt-liefert-nichts-und-wirft-nicht', notFound.length === 0, `auth_lookup_user_by_username('unknown-user') liefert ${notFound.length} Zeile(n)`, ); const rawSelect = await prisma.$queryRaw`SELECT * FROM "User"`; report( results, 'gewoehnlicher-select-auf-user-liefert-null-zeilen', rawSelect.length === 0, `SELECT * FROM "User" liefert ${rawSelect.length} Zeile(n)`, ); } finally { await prisma.$disconnect(); } } /** * Liest die ausgelieferte RLS-Basismigration und schneidet die beiden * `CREATE POLICY`-Anweisungen fuer "LdapConfig" und "LdapFieldMapping" bis * zum abschliessenden Semikolon heraus (Vorbild: readAuthLookupMigrationSql). * Der Dateiname wird ueber ein Suffix gesucht, nicht hartkodiert — aber die * Groups-Migration endet ebenfalls auf "_rls_policies" und wird deshalb * ausdruecklich ausgeschlossen, sonst faende der Filter zwei Verzeichnisse. */ function readRlsPoliciesMigrationSql() { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && entry.name.endsWith('_rls_policies') && !entry.name.endsWith('_groups_rls_policies'), ) .map((entry) => entry.name); if (dirs.length !== 1) return null; return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } function extractPolicySql(migrationSql, tableName) { const re = new RegExp( `CREATE POLICY tenant_isolation_policy ON "${tableName}"[\\s\\S]*?;`, ); const match = migrationSql.match(re); return match ? match[0] : null; } /** * Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen * des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies * WORTGLEICH aus der ausgelieferten Migration statt im Werkzeug neu getippt * (T-IPC-08). Findet die Extraktion eine der beiden Policies nicht, meldet * dieser Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit * einer geratenen Policy weiterzumessen. */ async function runLdapAreaChecks(adminUrl, scratchRoleUrl, results) { const migrationSql = readRlsPoliciesMigrationSql(); const ldapConfigPolicy = migrationSql ? extractPolicySql(migrationSql, 'LdapConfig') : null; const ldapFieldMappingPolicy = migrationSql ? extractPolicySql(migrationSql, 'LdapFieldMapping') : null; if (!ldapConfigPolicy || !ldapFieldMappingPolicy) { report( results, 'ldap-policies-aus-migration-gefunden', false, 'CREATE POLICY fuer "LdapConfig" und/oder "LdapFieldMapping" nicht in der ausgelieferten *_rls_policies-Migration gefunden', ); return; } await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { await db.$executeRawUnsafe(` CREATE TABLE "LdapConfig" ( id text PRIMARY KEY, "tenantId" text NOT NULL, "serverUrl" text NOT NULL ); `); await db.$executeRawUnsafe(` CREATE TABLE "LdapFieldMapping" ( id text PRIMARY KEY, "ldapConfigId" text NOT NULL REFERENCES "LdapConfig"(id), "ldapField" text NOT NULL, "tesseraField" text NOT NULL ); `); await db.$executeRawUnsafe(`ALTER TABLE "LdapConfig" ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "LdapConfig" FORCE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "LdapFieldMapping" ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "LdapFieldMapping" FORCE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(ldapConfigPolicy); await db.$executeRawUnsafe(ldapFieldMappingPolicy); await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON "LdapConfig" TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON "LdapFieldMapping" TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe(` INSERT INTO "LdapConfig" (id, "tenantId", "serverUrl") VALUES ('cfg-a', 'TENANT-A', 'ldap://a.example'), ('cfg-b', 'TENANT-B', 'ldap://b.example'); `); await db.$executeRawUnsafe(` INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES ('map-a', 'cfg-a', 'sAMAccountName', 'username'), ('map-b', 'cfg-b', 'sAMAccountName', 'username'); `); }); const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { // 1: forTenant(TENANT-A) sieht genau die LdapConfig-Zeile von A. const configRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "LdapConfig" ORDER BY id`, ); report( results, 'ldapconfig-gebunden-nur-eigene-zeile', configRowsForA.length === 1 && configRowsForA[0].tenantId === 'TENANT-A', `forTenant(TENANT-A) liefert ${configRowsForA.length} Zeile(n): ${JSON.stringify(configRowsForA.map((r) => r.tenantId))}`, ); // 2: derselbe SELECT ohne Bindung liefert 0 Zeilen — die Fehlerrichtung, // an der echten Policy gemessen statt an der Hilfstabelle "probe". const unboundConfigRows = await prisma.$queryRaw`SELECT "tenantId" FROM "LdapConfig"`; report( results, 'ldapconfig-ungebunden-null-zeilen', unboundConfigRows.length === 0, `ungebundener SELECT auf "LdapConfig" liefert ${unboundConfigRows.length} Zeile(n)`, ); // 3: forTenant(TENANT-A) sieht ueber den Join genau die Feldzuordnung, // die an A's Konfiguration haengt. const mappingRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "ldapConfigId" FROM "LdapFieldMapping" ORDER BY id`, ); report( results, 'fieldmapping-folgt-join-auf-ldapconfig', mappingRowsForA.length === 1 && mappingRowsForA[0].ldapConfigId === 'cfg-a', `forTenant(TENANT-A) liefert ${mappingRowsForA.length} Feldzuordnung(en): ${JSON.stringify(mappingRowsForA.map((r) => r.ldapConfigId))}`, ); // 4: gebundenes INSERT mit A's eigener ldapConfigId gelingt. let ownInsertOk = false; let ownInsertDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => tx.$executeRaw`INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES ('map-a-2', 'cfg-a', 'mail', 'email')`, ); ownInsertOk = true; ownInsertDetail = 'INSERT mit eigener ldapConfigId erfolgreich'; } catch (err) { ownInsertDetail = `INSERT mit eigener ldapConfigId fehlgeschlagen: ${err.message}`; } report(results, 'fieldmapping-schreiben-eigene-konfiguration-erlaubt', ownInsertOk, ownInsertDetail); // 5: gebundenes INSERT unter TENANT-A mit B's ldapConfigId wird // abgewiesen — die Abweisung IST das bestandene Ergebnis. let foreignInsertRejected = false; let foreignInsertDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => tx.$executeRaw`INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES ('map-foreign', 'cfg-b', 'mail', 'email')`, ); foreignInsertDetail = 'INSERT mit fremder ldapConfigId ist NICHT fehlgeschlagen'; } catch (err) { foreignInsertRejected = true; foreignInsertDetail = `INSERT mit fremder ldapConfigId abgewiesen: ${err.message}`; } report( results, 'fieldmapping-schreiben-fremde-konfiguration-abgelehnt', foreignInsertRejected, foreignInsertDetail, ); } finally { await prisma.$disconnect(); } } /** * Liest die ausgelieferte Migration, die die drei Policies fuer "Group", * "GroupMembership" und "ModuleGrant" enthaelt. Der Dateiname endet auf * "_groups_rls_policies" — readRlsPoliciesMigrationSql() oben schliesst * diese Migration ausdruecklich AUS, deshalb ein eigenes, unabhaengiges * Lesehilfsmittel statt einer Aenderung am bestehenden (Aufgabe 1, * 260909-jts-PLAN.md). */ function readGroupsRlsPoliciesMigrationSql() { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.endsWith('_groups_rls_policies')) .map((entry) => entry.name); if (dirs.length !== 1) return null; return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } /** * Liest die ausgelieferte Migration, die (unter anderem) die Policy fuer * "TenantModuleActivation" enthaelt (Dateiname endet auf * "_rls_remaining_tenant_tables"). */ function readRemainingTenantTablesMigrationSql() { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.endsWith('_rls_remaining_tenant_tables')) .map((entry) => entry.name); if (dirs.length !== 1) return null; return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } /** * Aufgabe 1 (260909-jts), TEIL 1 — misst die im Plan genannten * Verhaltensweisen des Bereichs groups unter der Rolle ohne BYPASSRLS, mit * den vier Policies WORTGLEICH aus den beiden ausgelieferten Migrationen * (nicht im Werkzeug nachgetippt, vgl. runLdapAreaChecks). Findet die * Extraktion eine der vier nicht, meldet dieser Abschnitt eine * FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer geratenen Policy * weiterzumessen. * * Legt die Tabelle "Group" (samt je einer Zeile fuer TENANT-A und * TENANT-B) an, auf der runTransactionShapeMeasurement() weiter unten * aufsetzt — diese Funktion muss deshalb VOR jener aufgerufen werden. */ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { const groupsMigrationSql = readGroupsRlsPoliciesMigrationSql(); const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); const groupPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'Group') : null; const groupMembershipPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'GroupMembership') : null; const moduleGrantPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'ModuleGrant') : null; const tenantModuleActivationPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenantModuleActivation') : null; if ( !groupPolicy || !groupMembershipPolicy || !moduleGrantPolicy || !tenantModuleActivationPolicy ) { report( results, 'groups-policies-aus-migration-gefunden', false, 'CREATE POLICY fuer "Group", "GroupMembership", "ModuleGrant" und/oder "TenantModuleActivation" nicht in den ausgelieferten Migrationen gefunden', ); return; } await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { await db.$executeRawUnsafe(` CREATE TABLE "Group" ( id text PRIMARY KEY, "tenantId" text NOT NULL, name text NOT NULL, "isDefault" boolean NOT NULL DEFAULT false ); `); await db.$executeRawUnsafe(` CREATE TABLE "GroupMembership" ( id text PRIMARY KEY, "groupId" text NOT NULL, "userId" text NOT NULL, source text NOT NULL DEFAULT 'MANUAL' ); `); await db.$executeRawUnsafe(` CREATE TABLE "ModuleGrant" ( id text PRIMARY KEY, "tenantId" text NOT NULL, "moduleId" text NOT NULL, "groupId" text, "userId" text ); `); await db.$executeRawUnsafe(` CREATE TABLE "TenantModuleActivation" ( id text PRIMARY KEY, "tenantId" text NOT NULL, "moduleId" text NOT NULL, "isActive" boolean NOT NULL DEFAULT true ); `); for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) { await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`); } await db.$executeRawUnsafe(groupPolicy); await db.$executeRawUnsafe(groupMembershipPolicy); await db.$executeRawUnsafe(moduleGrantPolicy); await db.$executeRawUnsafe(tenantModuleActivationPolicy); for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) { await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`, ); } await db.$executeRawUnsafe(` INSERT INTO "Group" (id, "tenantId", name, "isDefault") VALUES ('group-a', 'TENANT-A', 'Gruppe A', true), ('group-b', 'TENANT-B', 'Gruppe B', true); `); await db.$executeRawUnsafe(` INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-a', 'group-a', 'user-a', 'MANUAL'), ('membership-b', 'group-b', 'user-b', 'MANUAL'); `); await db.$executeRawUnsafe(` INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-a', 'TENANT-A', 'mod-1', 'group-a', NULL), ('grant-b', 'TENANT-B', 'mod-1', 'group-b', NULL); `); await db.$executeRawUnsafe(` INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-a', 'TENANT-A', 'mod-1', true), ('activation-b', 'TENANT-B', 'mod-1', true); `); }); const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { // group-gebunden-nur-eigene-zeile const groupRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "Group" ORDER BY id`, ); report( results, 'group-gebunden-nur-eigene-zeile', groupRowsForA.length === 1 && groupRowsForA[0].tenantId === 'TENANT-A', `forTenant(TENANT-A) liefert ${groupRowsForA.length} Zeile(n): ${JSON.stringify(groupRowsForA.map((r) => r.tenantId))}`, ); // group-ungebunden-null-zeilen — die Belegzeile, die die Kritikschrift // traegt, am echten, ausgelieferten Policy-Text gemessen. const unboundGroupRows = await prisma.$queryRaw`SELECT "tenantId" FROM "Group"`; report( results, 'group-ungebunden-null-zeilen', unboundGroupRows.length === 0, `ungebundener SELECT auf "Group" liefert ${unboundGroupRows.length} Zeile(n)`, ); // groupmembership-folgt-join-auf-group const membershipRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "groupId" FROM "GroupMembership" ORDER BY id`, ); report( results, 'groupmembership-folgt-join-auf-group', membershipRowsForA.length === 1 && membershipRowsForA[0].groupId === 'group-a', `forTenant(TENANT-A) liefert ${membershipRowsForA.length} Mitgliedschaft(en): ${JSON.stringify(membershipRowsForA.map((r) => r.groupId))}`, ); // groupmembership-schreiben-fremde-gruppe-abgelehnt let foreignGroupInsertRejected = false; let foreignGroupInsertDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-group', 'group-b', 'user-a', 'MANUAL')`, ); foreignGroupInsertDetail = 'INSERT mit fremder groupId ist NICHT fehlgeschlagen'; } catch (err) { foreignGroupInsertRejected = true; foreignGroupInsertDetail = `INSERT mit fremder groupId abgewiesen: ${err.message}`; } report( results, 'groupmembership-schreiben-fremde-gruppe-abgelehnt', foreignGroupInsertRejected, foreignGroupInsertDetail, ); // groupmembership-schreiben-fremder-benutzer-nicht-verhindert (Befund E): // das GELINGEN dieses INSERTs ist das bestandene Ergebnis — es belegt, // dass die Policy nur die Gruppenseite prueft, nicht die Benutzerseite. let foreignUserInsertSucceeded = false; let foreignUserInsertDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user', 'group-a', 'user-nicht-in-a', 'MANUAL')`, ); foreignUserInsertSucceeded = true; foreignUserInsertDetail = 'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E); die Anwendung muss die Benutzerseite selbst pruefen'; } catch (err) { foreignUserInsertDetail = `INSERT unerwartet abgewiesen: ${err.message}`; } report( results, 'groupmembership-schreiben-fremder-benutzer-nicht-verhindert', foreignUserInsertSucceeded, foreignUserInsertDetail, ); // modulegrant-gebunden-nur-eigene-zeile const grantRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "ModuleGrant" ORDER BY id`, ); report( results, 'modulegrant-gebunden-nur-eigene-zeile', grantRowsForA.length === 1 && grantRowsForA[0].tenantId === 'TENANT-A', `forTenant(TENANT-A) liefert ${grantRowsForA.length} Zeile(n): ${JSON.stringify(grantRowsForA.map((r) => r.tenantId))}`, ); // modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt // (Befund F): auch hier ist das Durchgehen das bestandene Ergebnis. let foreignGroupGrantSucceeded = false; let foreignGroupGrantDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`, ); foreignGroupGrantSucceeded = true; foreignGroupGrantDetail = 'INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F); assertTargetBelongsToTenant ist der einzige Schutz und darf bei der Umstellung nicht entfallen'; } catch (err) { foreignGroupGrantDetail = `INSERT unerwartet abgewiesen: ${err.message}`; } report( results, 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt', foreignGroupGrantSucceeded, foreignGroupGrantDetail, ); // tenantmoduleactivation-gebunden-nur-eigene-zeile const activationRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" ORDER BY id`, ); report( results, 'tenantmoduleactivation-gebunden-nur-eigene-zeile', activationRowsForA.length === 1 && activationRowsForA[0].tenantId === 'TENANT-A', `forTenant(TENANT-A) liefert ${activationRowsForA.length} Zeile(n): ${JSON.stringify(activationRowsForA.map((r) => r.tenantId))}`, ); } finally { await prisma.$disconnect(); } } /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte * traegt. Baut die Erweiterungsform aus prisma-tenant.extension.ts * WORTGLEICH nach ($extends mit $allOperations, Array-Form von * $transaction darin) statt ueber das vereinfachte forTenantQuery(), denn * genau diese Erweiterungsschicht ist hier der Gegenstand der Messung. * * Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits * angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat. */ function buildInlineExtendedClient(prisma, tenantId) { return prisma.$extends({ query: { $allOperations({ args, query }) { const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]); }, }, }); } /** * Druckt die tatsaechlich beobachteten Werte einer Transaktionsform. Fliesst * NICHT in die Pruefliste ein und beeinflusst den Rueckgabewert nicht — eine * Form, die abbricht, ist ein Messergebnis und kein Werkzeugfehler. */ function beobachte(formName, payload) { console.log(` [beobachtet] ${formName}: ${JSON.stringify(payload)}`); } /** * Alle drei Bedingungen aus dem Plan: gleiche Verbindungskennung ueber * beide Teilschritte, gelesener Mandantenkontext gleich TENANT-A in * beiden Teilschritten, und der Lesezugriff liefert genau die eine Zeile * von TENANT-A. */ function traegtKontextAufDerselbenVerbindung(step1, step2) { return Boolean( step1 && step2 && step1.pid === step2.pid && step1.t === 'TENANT-A' && step2.t === 'TENANT-A' && step2.rows === 1, ); } async function measureArrayFormOnBoundClient(scratchRoleUrl) { const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); const [step1Rows, step2Rows] = await bound.$transaction([ bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`, bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`, ]); return { step1: step1Rows[0], step2: step2Rows[0] }; } finally { await prisma.$disconnect(); } } async function measureInteractiveFormOnBoundClient(scratchRoleUrl) { const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); return await bound.$transaction(async (tx) => { const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`; const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`; return { step1: step1Rows[0], step2: step2Rows[0] }; }); } finally { await prisma.$disconnect(); } } async function measureInteractiveFormOnUnboundClient(scratchRoleUrl) { const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); const tenantId = 'TENANT-A'; try { return await prisma.$transaction(async (tx) => { const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', ${tenantId}, true) AS applied, current_tenant_id() AS t`; const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`; return { step1: step1Rows[0], step2: step2Rows[0] }; }); } finally { await prisma.$disconnect(); } } async function runTransactionShapeMeasurement(scratchRoleUrl, results) { const forms = [ { name: 'Form (i) — Array-Form auf gebundenem Client', fn: measureArrayFormOnBoundClient }, { name: 'Form (ii) — interaktive Callback-Form auf gebundenem Client', fn: measureInteractiveFormOnBoundClient, }, { name: 'Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx)', fn: measureInteractiveFormOnUnboundClient, }, ]; const outcomes = []; for (const form of forms) { try { const r = await form.fn(scratchRoleUrl); beobachte(form.name, r); outcomes.push({ name: form.name, passed: traegtKontextAufDerselbenVerbindung(r.step1, r.step2) }); } catch (err) { beobachte(form.name, { abbruch: err.message }); outcomes.push({ name: form.name, passed: false }); } } const passedForms = outcomes.filter((o) => o.passed).map((o) => o.name); const failedForms = outcomes.filter((o) => !o.passed).map((o) => o.name); report( results, 'mindestens-eine-transaktionsform-traegt-den-mandantenkontext', passedForms.length > 0, `bestanden: [${passedForms.join(' ; ')}] — nicht bestanden: [${failedForms.join(' ; ')}]`, ); } async function main() { const adminUrl = parseAdminUrl(); const results = []; console.log(`Richte Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ein...`); await setupScratchDatabase(adminUrl); try { const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME); scratchRoleUrl.username = SCRATCH_ROLE_NAME; scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD; const scratchRoleUrlString = scratchRoleUrl.toString(); await runForTenantChecks(scratchRoleUrlString, results); await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results); await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); await teardownScratchDatabase(adminUrl); } const allPassed = results.every((r) => r.passed); console.log( allPassed ? `Alle ${results.length} Pruefungen bestanden.` : `${results.filter((r) => !r.passed).length} von ${results.length} Pruefungen fehlgeschlagen.`, ); process.exit(allPassed ? 0 : 1); } main().catch((err) => { console.error('FEHLER beim Ausfuehren der Wegwerf-Pruefung:', err.message); process.exit(1); });