1063 lines
42 KiB
JavaScript
1063 lines
42 KiB
JavaScript
#!/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(' ; ')}]`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ein einzelner Aufruf in Form (ii): interaktive Callback-Transaktion auf dem
|
|
* GEBUNDENEN Client. Liefert den im Inneren gelesenen Mandantenkontext.
|
|
*/
|
|
async function runOneBoundInteractive(prisma, tenantId) {
|
|
const bound = buildInlineExtendedClient(prisma, tenantId);
|
|
return bound.$transaction(async (tx) => {
|
|
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`;
|
|
return rows[0]?.t;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ein einzelner Aufruf in Form (iii): interaktive Callback-Transaktion auf dem
|
|
* UNGEBUNDENEN Client, set_config als erste Anweisung direkt auf tx.
|
|
*/
|
|
async function runOneUnboundInteractive(prisma, tenantId) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
|
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`;
|
|
return rows[0]?.t;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Belastet eine Transaktionsform mit `parallelCount` gleichzeitigen Aufrufen,
|
|
* abwechselnd fuer TENANT-A und TENANT-B, ueber EINEN gemeinsamen Client —
|
|
* also aus demselben endlichen Verbindungsvorrat, so wie im Betrieb.
|
|
*
|
|
* Als Verletzung zaehlt beides: ein Aufruf, der einen fremden oder gar keinen
|
|
* Mandantenkontext sieht, UND ein Aufruf, der abbricht. Der Abbruch ist hier
|
|
* kein Werkzeugfehler, sondern das Messergebnis — genau deshalb wird er
|
|
* gefangen und gezaehlt statt nach oben durchgereicht.
|
|
*/
|
|
async function measureUnderLoad(scratchRoleUrl, runOne, parallelCount) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
const calls = Array.from({ length: parallelCount }, (_, i) => {
|
|
const tenantId = i % 2 === 0 ? 'TENANT-A' : 'TENANT-B';
|
|
return runOne(prisma, tenantId).then(
|
|
(seen) => (seen === tenantId ? null : `erwartet ${tenantId}, gesehen ${seen ?? 'NULL'}`),
|
|
(err) => `abbruch ${err.code ?? ''}: ${String(err.message).split('\n')[0]}`.trim(),
|
|
);
|
|
});
|
|
const verletzungen = (await Promise.all(calls)).filter(Boolean);
|
|
return {
|
|
aufrufe: parallelCount,
|
|
verletzungen: verletzungen.length,
|
|
beispiele: verletzungen.slice(0, 2),
|
|
};
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die Lastprobe hinter der Entscheidung fuer Form (iii).
|
|
*
|
|
* Sie existierte zunaechst nur als Fliesstext im Kopf von
|
|
* `prisma-tenant.extension.ts` — eine Zahl, die eine Entscheidung trug, aber
|
|
* nicht nachvollziehbar war. Genau das Anti-Muster, das dieses Projekt sich
|
|
* selbst verboten hat. Deshalb steht sie jetzt hier.
|
|
*
|
|
* GEPRUEFT wird nur die Eigenschaft, auf die sich der Code stuetzt: die
|
|
* GEWAEHLTE Form ueberlebt Nebenlaeufigkeit ohne Verletzung. Das Verhalten von
|
|
* Form (ii) wird daneben BEOBACHTET und ausgedruckt, aber nicht gepruft —
|
|
* ob und ab welcher Last sie abbricht, haengt an Verbindungsvorrat und
|
|
* Maschine und taugt nicht als Bedingung fuer einen gruenen Lauf.
|
|
*/
|
|
async function runConcurrencyProbe(scratchRoleUrl, results) {
|
|
const PARALLEL = 40;
|
|
|
|
const formZwei = await measureUnderLoad(scratchRoleUrl, runOneBoundInteractive, PARALLEL);
|
|
beobachte(`Form (ii) unter ${PARALLEL} parallelen Aufrufen`, formZwei);
|
|
|
|
const formDrei = await measureUnderLoad(scratchRoleUrl, runOneUnboundInteractive, PARALLEL);
|
|
beobachte(`Form (iii) unter ${PARALLEL} parallelen Aufrufen`, formDrei);
|
|
|
|
report(
|
|
results,
|
|
'gewaehlte-transaktionsform-uebersteht-nebenlaeufigkeit',
|
|
formDrei.verletzungen === 0,
|
|
`Form (iii): ${formDrei.verletzungen} Verletzung(en) bei ${PARALLEL} parallelen Aufrufen` +
|
|
` — Form (ii) zum Vergleich, nicht gepruft: ${formZwei.verletzungen}` +
|
|
(formZwei.beispiele.length ? ` (z.B. ${formZwei.beispiele[0]})` : ''),
|
|
);
|
|
}
|
|
|
|
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);
|
|
await runConcurrencyProbe(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);
|
|
});
|