Files
tessera-ctl/apps/api/scripts/rls-scratch-check.mjs
T
schalli 7d45e2fffd test(quick-260910-exd): Fehlerrichtung fuer module-registry messen, kein Produktivcode
- rls-scratch-check.mjs: achter Abschnitt runModuleRegistryAreaChecks mit 13
  benannten Pruefungen gegen die echten, aus den ausgelieferten Migrationen
  geschnittenen Regeln (Group/GroupMembership/ModuleGrant/TenantModuleActivation),
  neu angelegt nur: Modulkatalog-Tabelle ohne Zeilenschutz, Eindeutigkeitsindex
  auf TenantModuleActivation, zwei Direkt-Freigaben, eine fremde Mitgliedschaft
- Alle 66 Pruefungen bestanden (53 bisherige + 13 neue), 810 Tests gruen,
  Typpruefung sauber
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
  "Bereich module-registry" mit den fuenf Unterabschnitten (m1-m5), inklusive
  Praezisierung aus Befund E (Katalogbindung ist HEUTE wirkungslos, nicht
  katastrophal — die Bedingung wird als Bedingung notiert) und der
  unbeschoenigten Antwort auf die Signalfrage ("keines")

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 11:17:07 +02:00

2322 lines
100 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-laa) — misst die drei Sonderfaelle des Bereichs tenders
* unter der Rolle ohne BYPASSRLS, mit den fuenf Policies WORTGLEICH aus der
* ausgelieferten Migration `_rls_remaining_tenant_tables` (dieselbe Datei,
* die bereits die TenantModuleActivation-Policy fuer runGroupsAreaChecks
* liefert). Findet die Extraktion eine der fuenf nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Policy weiterzumessen.
*
* Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs
* aufsetzt — anders als runGroupsAreaChecks() (Vorbedingung fuer
* runTransactionShapeMeasurement()) ist dieser Abschnitt in der Aufrufkette
* ein Blatt: er muss NACH runGroupsAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen, weil Letztere weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — dieser
* Abschnitt aendert daran nichts.
*/
async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const emailConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
: null;
const notificationPrefPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref')
: null;
const rssFeedPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderRssFeedSource')
: null;
const savedSearchPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch')
: null;
const triagePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
: null;
if (
!emailConfigPolicy ||
!notificationPrefPolicy ||
!rssFeedPolicy ||
!savedSearchPolicy ||
!triagePolicy
) {
report(
results,
'tenders-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderRssFeedSource", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "TenderEmailConfig" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderNotificationPref" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderRssFeedSource" (
id text PRIMARY KEY,
url text NOT NULL,
"userId" text,
"tenantId" text,
UNIQUE ("userId", url)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
UNIQUE ("userId", name)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderTriage" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"tenderId" text NOT NULL,
UNIQUE ("userId", "tenderId")
);
`);
for (const table of [
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
]) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(emailConfigPolicy);
await db.$executeRawUnsafe(notificationPrefPolicy);
await db.$executeRawUnsafe(rssFeedPolicy);
await db.$executeRawUnsafe(savedSearchPolicy);
await db.$executeRawUnsafe(triagePolicy);
for (const table of [
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
]) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
await db.$executeRawUnsafe(`
INSERT INTO "TenderEmailConfig" (id, "userId", "tenantId") VALUES
('ec-a', 'user-a1', 'TENANT-A'),
('ec-b', 'user-b', 'TENANT-B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderNotificationPref" (id, "userId", "tenantId") VALUES
('np-a', 'user-a1', 'TENANT-A'),
('np-b', 'user-b', 'TENANT-B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES
('rss-a', 'https://a.example-tenders.invalid/feed', 'user-a1', 'TENANT-A'),
('rss-b', 'https://b.example-tenders.invalid/feed', 'user-b', 'TENANT-B'),
('rss-platform', 'https://platform.example-tenders.invalid/feed', NULL, NULL);
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name) VALUES
('ss-a1', 'user-a1', 'TENANT-A', 'Profil A1'),
('ss-a2', 'user-a2', 'TENANT-A', 'Profil A2'),
('ss-b', 'user-b', 'TENANT-B', 'Profil B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES
('tr-a', 'user-a1', 'TENANT-A', 'tender-x'),
('tr-b', 'user-b', 'TENANT-B', 'tender-y'),
('tr-shared', 'user-shared', 'TENANT-B', 'tender-shared');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// tendersavedsearch-gebunden-nur-eigener-mandant
const savedSearchRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`,
);
report(
results,
'tendersavedsearch-gebunden-nur-eigener-mandant',
savedSearchRowsForA.length === 2 &&
savedSearchRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${savedSearchRowsForA.length} Zeile(n): ${JSON.stringify(savedSearchRowsForA.map((r) => r.tenantId))}`,
);
// tendersavedsearch-ungebunden-null-zeilen — die Belegzeile, die diesen
// Abschnitt der Kritikschrift traegt, am echten, ausgelieferten
// Policy-Text gemessen.
const unboundSavedSearchRows = await prisma.$queryRaw`SELECT "tenantId" FROM "TenderSavedSearch"`;
report(
results,
'tendersavedsearch-ungebunden-null-zeilen',
unboundSavedSearchRows.length === 0,
`ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`,
);
// tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund
// E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das
// bestandene Ergebnis. Es belegt, dass die Policy keine
// Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt
// deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern
// desselben Mandanten und darf bei der Umstellung nicht entfallen.
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
report(
results,
'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar',
bothUsersVisible,
bothUsersVisible
? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen`
: `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`,
);
// tenderemailconfig-gebunden-nur-eigener-mandant
const emailConfigRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderEmailConfig" ORDER BY id`,
);
report(
results,
'tenderemailconfig-gebunden-nur-eigener-mandant',
emailConfigRowsForA.length === 1 && emailConfigRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${emailConfigRowsForA.length} Zeile(n): ${JSON.stringify(emailConfigRowsForA.map((r) => r.tenantId))}`,
);
// tendertriage-gebunden-nur-eigener-mandant
const triageRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderTriage" ORDER BY id`,
);
report(
results,
'tendertriage-gebunden-nur-eigener-mandant',
triageRowsForA.length === 1 && triageRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${triageRowsForA.length} Zeile(n): ${JSON.stringify(triageRowsForA.map((r) => r.tenantId))}`,
);
// tendernotificationpref-gebunden-nur-eigener-mandant
const notificationPrefRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderNotificationPref" ORDER BY id`,
);
report(
results,
'tendernotificationpref-gebunden-nur-eigener-mandant',
notificationPrefRowsForA.length === 1 && notificationPrefRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${notificationPrefRowsForA.length} Zeile(n): ${JSON.stringify(notificationPrefRowsForA.map((r) => r.tenantId))}`,
);
// tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar
// (WINDOWS #19): die plattformweite Zeile (userId/tenantId NULL) ist
// unter BEIDEN Mandantenkontexten unsichtbar, weil die ausgelieferte
// Policy "tenantId" = current_tenant_id() NULL nie gleich vergleicht.
// Bestanden, wenn sie unter beiden Kontexten fehlt — die Folge: die
// drei RSS-Pfade, die plattformweite Zeilen beruehren, duerfen nicht
// gebunden werden, solange die Policy-Semantik unveraendert ist.
const rssRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`,
);
const rssRowsForB = await forTenantQuery(prisma, 'TENANT-B', (tx) =>
tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`,
);
const platformRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-platform');
const platformRowVisibleForB = rssRowsForB.some((r) => r.id === 'rss-platform');
report(
results,
'tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar',
!platformRowVisibleForA && !platformRowVisibleForB,
`forTenant(TENANT-A) sieht die Platform-Zeile: ${platformRowVisibleForA}, forTenant(TENANT-B) sieht sie: ${platformRowVisibleForB} — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist`,
);
// tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: ein
// gebundenes INSERT unter TENANT-A mit tenantId=NULL wird abgewiesen —
// die Abweisung IST das bestandene Ergebnis. Folge: createPlatform darf
// nicht gebunden werden.
let platformInsertRejected = false;
let platformInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES ('rss-rejected', 'https://rejected.example-tenders.invalid/feed', NULL, NULL)`,
);
platformInsertDetail = 'gebundenes INSERT mit tenantId=NULL ist NICHT fehlgeschlagen';
} catch (err) {
platformInsertRejected = true;
platformInsertDetail = `gebundenes INSERT mit tenantId=NULL abgewiesen: ${err.message} — createPlatform darf deshalb nicht gebunden werden`;
}
report(
results,
'tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt',
platformInsertRejected,
platformInsertDetail,
);
// tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit
// (Befund F): unter TENANT-A ein INSERT fuer ein Paar (userId,
// tenderId), dessen Zeile existiert, aber zu TENANT-B gehoert und daher
// unsichtbar ist (tr-shared). Bestanden, wenn der Fehler eine
// Verletzung der Eindeutigkeitsbedingung ist (P2002-Familie), nicht
// eine RLS-Policy-Abweisung — Postgres prueft Unique-Indizes gegen die
// physischen Zeilen, unabhaengig von der RLS-Sichtbarkeit. Folge fuer
// Aufgabe 2: aus einem stillen Ueberschreiben wird ein harter Fehler,
// der als verstaendliche Meldung herauskommen muss.
let uniqueViolationOnInvisibleRow = false;
let uniqueViolationDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES ('tr-a-attempt', 'user-shared', 'TENANT-A', 'tender-shared')`,
);
uniqueViolationDetail = 'gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination ist NICHT fehlgeschlagen';
} catch (err) {
uniqueViolationOnInvisibleRow = err.code === '23505' || /unique/i.test(err.message);
uniqueViolationDetail = `gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: ${err.message} — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler`;
}
report(
results,
'tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit',
uniqueViolationOnInvisibleRow,
uniqueViolationDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-mir), TEIL 1 — misst die neun im Plan genannten
* Verhaltensweisen des Bereichs dkv unter der Rolle ohne BYPASSRLS, mit
* den drei Policies fuer "DkvInvoiceHistory", "DkvModuleConfig" und
* "DkvVehicleMaster" WORTGLEICH aus der ausgelieferten
* *_rls_remaining_tenant_tables-Migration extrahiert, nicht im Werkzeug
* nachgetippt. Findet die Extraktion eine der drei nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Policy weiterzumessen.
*
* TEIL 2 (die Nebenlaeufigkeitsform aus Befund C, auf die sich
* getHistory() stuetzt) ist als eigene Pruefung
* `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext` am Ende
* dieser Funktion mit untergebracht, nicht als eigener Abschnitt — sie
* braucht dieselben Tabellen und Testzeilen wie TEIL 1.
*
* Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs
* aufsetzt — wie runTendersAreaChecks() ist dieser Abschnitt in der
* Aufrufkette ein Blatt: er muss NACH runTendersAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen, weil Letztere weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — dieser
* Abschnitt aendert daran nichts.
*/
async function runDkvAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const invoiceHistoryPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvInvoiceHistory')
: null;
const moduleConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvModuleConfig')
: null;
const vehicleMasterPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvVehicleMaster')
: null;
if (!invoiceHistoryPolicy || !moduleConfigPolicy || !vehicleMasterPolicy) {
report(
results,
'dkv-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "DkvInvoiceHistory", "DkvModuleConfig" und/oder "DkvVehicleMaster" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "DkvModuleConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL UNIQUE,
"isActive" boolean NOT NULL DEFAULT true
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "DkvVehicleMaster" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
kennzeichen text NOT NULL,
UNIQUE ("tenantId", kennzeichen)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "DkvInvoiceHistory" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
"exportFilename" text
);
`);
for (const table of ['DkvModuleConfig', 'DkvVehicleMaster', 'DkvInvoiceHistory']) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(moduleConfigPolicy);
await db.$executeRawUnsafe(vehicleMasterPolicy);
await db.$executeRawUnsafe(invoiceHistoryPolicy);
for (const table of ['DkvModuleConfig', 'DkvVehicleMaster', 'DkvInvoiceHistory']) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
await db.$executeRawUnsafe(`
INSERT INTO "DkvModuleConfig" (id, "tenantId", "isActive") VALUES
('config-a', 'TENANT-A', true),
('config-b', 'TENANT-B', true);
`);
await db.$executeRawUnsafe(`
INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES
('veh-a1', 'TENANT-A', 'GEMEINSAM-1'),
('veh-a2', 'TENANT-A', 'A-ONLY-1'),
('veh-b1', 'TENANT-B', 'GEMEINSAM-1'),
('veh-b2', 'TENANT-B', 'B-ONLY-1');
`);
await db.$executeRawUnsafe(`
INSERT INTO "DkvInvoiceHistory" (id, "tenantId", "exportFilename") VALUES
('hist-a', 'TENANT-A', 'RG-DKV-TEST-A-260909.xlsx'),
('hist-b', 'TENANT-B', 'RG-DKV-TEST-B-260909.xlsx');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// dkvmoduleconfig-gebunden-nur-eigene-zeile
const configRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig" ORDER BY id`,
);
report(
results,
'dkvmoduleconfig-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))}`,
);
// dkvmoduleconfig-ungebunden-null-zeilen — die Belegzeile, die den
// gesamten dkv-Abschnitt der Kritikschrift traegt: der IDENTISCHE
// SELECT ohne vorheriges set_config liefert null Zeilen, nicht die
// beiden tatsaechlich vorhandenen.
const unboundConfigRows = await prisma.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig"`;
report(
results,
'dkvmoduleconfig-ungebunden-null-zeilen',
unboundConfigRows.length === 0,
`ungebundener SELECT auf "DkvModuleConfig" liefert ${unboundConfigRows.length} Zeile(n)`,
);
// dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile — die
// eigene, diesem Bereich vorbehaltene Messung: die Form, die der
// Planer-Startpfad heute benutzt (loadConfig() ohne Mandant, also ein
// findFirst() ohne jede Bedingung). Bestanden, wenn KEINE Zeile
// zurueckkommt, obwohl zwei existieren.
const unboundSingleRow = await prisma.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig" LIMIT 1`;
report(
results,
'dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile',
unboundSingleRow.length === 0,
`ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert ${unboundSingleRow.length} Zeile(n), obwohl 2 existieren — die Form, die der Planer-Startpfad heute benutzt: aus einer beliebigen-aber-vorhandenen Zeile wird KEINE Zeile, und der aufrufende Code liest das als "dieses Modul ist nicht eingerichtet"`,
);
// dkvinvoicehistory-gebunden-nur-eigener-mandant
const historyRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvInvoiceHistory" ORDER BY id`,
);
report(
results,
'dkvinvoicehistory-gebunden-nur-eigener-mandant',
historyRowsForA.length === 1 && historyRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${historyRowsForA.length} Zeile(n): ${JSON.stringify(historyRowsForA.map((r) => r.tenantId))}`,
);
// dkvvehiclemaster-gebunden-nur-eigener-mandant
const vehicleRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvVehicleMaster" ORDER BY id`,
);
report(
results,
'dkvvehiclemaster-gebunden-nur-eigener-mandant',
vehicleRowsForA.length === 2 && vehicleRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${vehicleRowsForA.length} Zeile(n): ${JSON.stringify(vehicleRowsForA.map((r) => r.tenantId))}`,
);
// dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt
// (Befund I): die ausgelieferten Policies tragen keine eigene
// WITH-CHECK-Klausel — was PostgreSQL daraus fuer ein INSERT ableitet,
// ist eine Eigenschaft der Datenbank, keine des Policy-Textes. Die
// Abweisung IST das bestandene Ergebnis.
let foreignInsertRejected = false;
let foreignInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES ('veh-rejected', 'TENANT-B', 'REJECTED-PLATE')`,
);
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
foreignInsertRejected = true;
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message}`;
}
report(
results,
'dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignInsertRejected,
foreignInsertDetail,
);
// dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
// (Befund G): ein gebundenes UPDATE unter TENANT-A, das eine Zeile von
// TENANT-B allein ueber deren id anspricht — die Form, die
// updateVehicle()/deleteVehicle() im Schreibschritt nutzen. Bestanden,
// wenn null Zeilen betroffen sind: ein Schreibzugriff ueber die
// Kennung allein scheitert gebunden nicht laut, sondern trifft still
// nichts, deshalb bleibt die vorgeschaltete Besitzpruefung erhalten.
const foreignUpdateAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`UPDATE "DkvVehicleMaster" SET kennzeichen = 'UEBERSCHRIEBEN' WHERE id = 'veh-b1'`,
);
report(
results,
'dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
foreignUpdateAffected === 0,
`gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — Folge fuer Aufgabe 3 (Befund G): die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt`,
);
// dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision
// (Befund H, Gegenbefund zu tenders-Befund F): unter TENANT-A ein
// gebundenes INSERT auf ein Kennzeichen, das unter TENANT-B bereits
// existiert ('B-ONLY-1', veh-b2). Bestanden, wenn es GELINGT — weil der
// Mandant Teil des zusammengesetzten Schluessels (tenantId,
// kennzeichen) ist, gibt es hier keine Kollision auf einer
// unsichtbaren fremden Zeile und keine P2002-Uebersetzung zu bauen.
let noForeignCollision = false;
let noForeignCollisionDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES ('veh-a-neu', 'TENANT-A', 'B-ONLY-1')`,
);
noForeignCollision = true;
noForeignCollisionDetail = `gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' gelingt — der Mandant ist Teil des zusammengesetzten Schluessels, keine Kollision auf einer unsichtbaren fremden Zeile, keine P2002-Uebersetzung noetig`;
} catch (err) {
noForeignCollisionDetail = `gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision',
noForeignCollision,
noForeignCollisionDetail,
);
// dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext (TEIL
// 2, Befund C): getHistory() fuehrt zwei Abfragen ueber Promise.all
// parallel aus — nach der Umstellung also zwei parallele
// Einzeloperationen auf EINEM gebundenen Klienten. Hier fuer ZWEI
// verschiedene Mandanten ueber DENSELBEN Klienten nachgebaut. Bestanden,
// wenn jede Abfrage den Kontext sieht, unter dem sie gestartet wurde,
// und jede die richtige Zeilenzahl liefert. Als Verletzung zaehlt
// beides: ein fremder oder fehlender Kontext und ein Abbruch.
const [parallelA, parallelB] = await Promise.all([
forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "DkvInvoiceHistory") AS rows`,
),
forTenantQuery(prisma, 'TENANT-B', (tx) =>
tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "DkvInvoiceHistory") AS rows`,
),
]);
const rowA = parallelA[0];
const rowB = parallelB[0];
const parallelOk =
Boolean(rowA) &&
Boolean(rowB) &&
rowA.t === 'TENANT-A' &&
rowB.t === 'TENANT-B' &&
rowA.rows === 1 &&
rowB.rows === 1;
report(
results,
'dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext',
parallelOk,
`TENANT-A: pid=${rowA?.pid}, t=${JSON.stringify(rowA?.t)}, rows=${rowA?.rows}; TENANT-B: pid=${rowB?.pid}, t=${JSON.stringify(rowB?.t)}, rows=${rowB?.rows} — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Vergleicht zwei SQL-Fragmente nach Normalisierung von Leerraum und
* abschliessendem Semikolon, damit der Vergleich nicht an Formatierung
* scheitert (Aufgabe 1, 260910-das).
*/
function normalizePolicySql(sql) {
return sql.trim().replace(/;\s*$/, '').replace(/\s+/g, ' ').trim();
}
/**
* Die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte
* Policy auf "User" — Wortlaut identisch mit Zeile 285 dieser Datei. Dieser
* Abschnitt haelt sie GEGEN die aus der ausgelieferten Migration
* `20260618112133_rls_policies` geschnittene Fassung, statt eine dritte
* Fassung zu erzeugen (Befund O).
*/
const AUTH_LOOKUP_USER_POLICY_SQL =
'CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());';
/**
* Liest den SQLSTATE-Fehlercode aus einem PrismaClientKnownRequestError,
* der aus einem fehlgeschlagenen $executeRaw/$queryRaw stammt. Bei
* Rohabfragen setzt Prisma selbst `.code` auf den generischen Wert
* `P2010` ("Raw query failed") und legt den tatsaechlichen
* PostgreSQL-SQLSTATE-Code unter `.meta.code` ab (empirisch geprueft gegen
* `tessera-ctl-db-1`, 2026-09-10: `.code` ist fuer eine Eindeutigkeits-
* UND eine Zeilenschutz-Verletzung gleichermassen `P2010`, waehrend
* `.meta.code` `23505` bzw. `42501` unterscheidet) — deshalb wird hier
* `.meta.code` gelesen, nicht `.code`.
*/
function sqlStateOf(err) {
return err?.meta?.code ?? null;
}
/**
* Aufgabe 1 (260910-das) — misst die zwoelf im Plan genannten
* Verhaltensweisen des Bereichs `user` unter der Rolle ohne BYPASSRLS. Die
* Tabelle "User" existiert bereits (vom runAuthLookupChecks()-Abschnitt
* angelegt, samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
* Eindeutigkeitsbedingungen `username`/`email` und zwei Testzeilen in zwei
* Mandanten, Befund O) — dieser Abschnitt legt sie NICHT neu an, sondern
* baut darauf auf und ergaenzt, was ihm fehlt: eine Tabelle "Tenant" ohne
* Zeilenschutz (die zu messende Eigenschaft selbst, nicht Beiwerk) und je
* eine weitere Benutzerzeile pro Mandant.
*
* Muss NACH runDkvAreaChecks() und VOR runTransactionShapeMeasurement()
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
* Abschnitt aendert daran nichts.
*/
async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) {
const baseMigrationSql = readRlsPoliciesMigrationSql();
const extractedUserPolicy = baseMigrationSql
? extractPolicySql(baseMigrationSql, 'User')
: null;
if (
!extractedUserPolicy ||
normalizePolicySql(extractedUserPolicy) !== normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL)
) {
report(
results,
'user-policy-aus-migration-wortgleich',
false,
extractedUserPolicy
? `Policy aus 20260618112133_rls_policies weicht von der im Anmeldeweg-Abschnitt getippten Fassung ab: extrahiert=${JSON.stringify(normalizePolicySql(extractedUserPolicy))}, getippt=${JSON.stringify(normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL))} — die getippte Fassung wird durch die geschnittene ERSETZT, die Abweichung gehoert in die Kritikschrift`
: 'CREATE POLICY fuer "User" nicht in der ausgelieferten 20260618112133_rls_policies-Migration gefunden',
);
return;
}
report(
results,
'user-policy-aus-migration-wortgleich',
true,
'die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen',
);
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// "Tenant" traegt bewusst KEINEN Zeilenschutz — das ist die zu
// messende Eigenschaft (Befund F/K), nicht ein Versehen.
await db.$executeRawUnsafe(`
CREATE TABLE "Tenant" (
id text PRIMARY KEY,
slug text NOT NULL UNIQUE
);
`);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "Tenant" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(`
INSERT INTO "Tenant" (id, slug) VALUES
('TENANT-A', 'tenant-a'),
('TENANT-B', 'tenant-b');
`);
// Je eine weitere Benutzerzeile pro Mandant, zusaetzlich zu den beiden
// bereits vom Anmeldeweg-Abschnitt angelegten (alice/TENANT-A,
// bob/TENANT-B) — role braucht einen gueltigen Aufzaehlungswert
// (Befund O).
await db.$executeRawUnsafe(`
INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role)
VALUES ('user-a2', 'carol', 'TENANT-A', 'hash-c', true, 'USER'),
('user-b2', 'dave', 'TENANT-B', 'hash-d', true, 'USER');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// user-gebunden-nur-eigener-mandant
const userRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "User" ORDER BY id`,
);
report(
results,
'user-gebunden-nur-eigener-mandant',
userRowsForA.length === 2 && userRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${userRowsForA.length} Zeile(n): ${JSON.stringify(userRowsForA.map((r) => r.tenantId))}`,
);
// user-ungebunden-null-zeilen — die Belegzeile, die den gesamten
// user-Abschnitt der Kritikschrift traegt: der IDENTISCHE SELECT ohne
// vorheriges set_config liefert null Zeilen, nicht die vier
// tatsaechlich vorhandenen.
const unboundUserRows = await prisma.$queryRaw`SELECT "tenantId" FROM "User"`;
report(
results,
'user-ungebunden-null-zeilen',
unboundUserRows.length === 0,
`ungebundener SELECT auf "User" liefert ${unboundUserRows.length} Zeile(n)`,
);
// user-ungebundene-suche-nach-benutzername-liefert-keine-zeile — die
// Form, die die Erstanlage-Pruefung beim Start heute benutzt: ein
// ungebundenes SELECT mit Gleichheitsbedingung auf einen Benutzernamen,
// den es GIBT ('bob', TENANT-B).
const unboundUsernameLookup =
await prisma.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`;
report(
results,
'user-ungebundene-suche-nach-benutzername-liefert-keine-zeile',
unboundUsernameLookup.length === 0,
`ungebundenes SELECT ... WHERE username = 'bob' liefert ${unboundUsernameLookup.length} Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an`,
);
// user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile —
// dieselbe Suche, gebunden an TENANT-A, nach einem Benutzernamen von
// TENANT-B ('bob'). Das ist der Moment, in dem eine Kollisionspruefung
// faelschlich "frei" meldet.
const boundForeignUsernameLookup = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`,
);
report(
results,
'user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile',
boundForeignUsernameLookup.length === 0,
`forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) ${boundForeignUsernameLookup.length} Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"`,
);
// user-eindeutigkeit-greift-trotz-unsichtbarkeit — die wichtigste
// Messung dieser Aufgabe und der Schluss der Kette: unmittelbar nach
// der vorherigen Pruefung ein gebundenes INSERT unter TENANT-A mit
// genau diesem, angeblich freien Benutzernamen ('bob'). Bestanden, wenn
// es abgewiesen wird UND die Ablehnung nachweislich eine
// Eindeutigkeitsverletzung ist (SQLSTATE 23505) und NICHT eine
// Zeilenschutz-Ablehnung (SQLSTATE 42501).
let uniquenessHoldsDespiteInvisibility = false;
let uniquenessDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-a-collision', 'bob', 'TENANT-A', 'hash-x', true, 'USER')`,
);
uniquenessDetail =
'gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
uniquenessHoldsDespiteInvisibility = sqlState === '23505';
uniquenessDetail = `gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()}) — ${
uniquenessHoldsDespiteInvisibility
? 'eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette'
: 'KEINE Eindeutigkeitsverletzung, sondern eine andere Fehlerklasse — das waere NICHT die im Auftrag beschriebene Kette'
}`;
}
report(
results,
'user-eindeutigkeit-greift-trotz-unsichtbarkeit',
uniquenessHoldsDespiteInvisibility,
uniquenessDetail,
);
// user-gebundenes-einfuegen-fremder-mandant-abgelehnt — die Policy
// traegt keine eigene WITH-CHECK-Klausel; was PostgreSQL daraus fuer
// ein INSERT ableitet, ist eine Eigenschaft der Datenbank, keine des
// Policy-Textes.
let foreignTenantInsertRejected = false;
let foreignTenantInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-foreign-tenant', 'erin', 'TENANT-B', 'hash-e', true, 'USER')`,
);
foreignTenantInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
foreignTenantInsertRejected = true;
foreignTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message.trim()}`;
}
report(
results,
'user-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignTenantInsertRejected,
foreignTenantInsertDetail,
);
// user-ungebundenes-einfuegen-abgelehnt — die Zeile, die Befund J
// traegt: bliebe die Erstanlage des Administrators ungebunden, koennte
// eine frische Installation ihren ersten Administrator nach dem
// Scharfschalten nicht anlegen.
let unboundInsertRejected = false;
let unboundInsertDetail = '';
try {
await prisma.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-unbound', 'frank', 'TENANT-A', 'hash-f', true, 'USER')`;
unboundInsertDetail = 'ungebundenes INSERT mit gueltiger Mandantenkennung ist NICHT fehlgeschlagen';
} catch (err) {
unboundInsertRejected = true;
unboundInsertDetail = `ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: ${err.message.trim()}`;
}
report(
results,
'user-ungebundenes-einfuegen-abgelehnt',
unboundInsertRejected,
unboundInsertDetail,
);
// user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
const foreignUpdateAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`UPDATE "User" SET "displayName" = 'ueberschrieben' WHERE id = 'user-b'`,
);
report(
results,
'user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
foreignUpdateAffected === 0,
`gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt`,
);
// user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen
const foreignDeleteAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`DELETE FROM "User" WHERE id = 'user-b2'`,
);
report(
results,
'user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen',
foreignDeleteAffected === 0,
`gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft ${foreignDeleteAffected} Zeile(n)`,
);
// tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar — die Eigenschaft, auf
// der sowohl die umgestellte Plattform-Administratorsicht als auch die
// Standardgruppen-Reparatur beim Start stehen (Befund F, Befund K).
// Zusaetzlich im Systemkatalog geprueft, damit die Aussage nicht allein
// daran haengt, dass dieser Abschnitt selbst keinen Zeilenschutz
// eingeschaltet hat.
const unboundTenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`;
const [rlsRow] =
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Tenant'`;
const tenantReadable = unboundTenantRows.length === 2 && rlsRow?.relrowsecurity === false;
report(
results,
'tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar',
tenantReadable,
`ungebundenes SELECT auf "Tenant" liefert ${unboundTenantRows.length} Zeile(n): ${JSON.stringify(unboundTenantRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Tenant" = ${JSON.stringify(rlsRow?.relrowsecurity)}`,
);
// user-fan-out-je-mandant-gebunden-liefert-alle-zeilen — die
// Nachbildung der umgestellten Plattform-Administratorsicht: erst die
// Mandanten ungebunden lesen, dann je Mandant EIN gebundener SELECT,
// dann die Ergebnisse vereinigen. Die Gesamtmenge wird ueber die
// Wartungsrolle (mit BYPASSRLS) gemessen, nicht angenommen.
const tenantIds = unboundTenantRows.map((r) => r.id).sort();
let fannedOutUsernames = [];
for (const tenantId of tenantIds) {
const rows = await forTenantQuery(prisma, tenantId, (tx) =>
tx.$queryRaw`SELECT username FROM "User" ORDER BY username`,
);
fannedOutUsernames.push(...rows.map((r) => r.username));
}
fannedOutUsernames = fannedOutUsernames.sort();
const groundTruthUsernames = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT username FROM "User" ORDER BY username`;
return rows.map((r) => r.username).sort();
},
);
const fanOutMatches =
fannedOutUsernames.length === groundTruthUsernames.length &&
fannedOutUsernames.every((u, i) => u === groundTruthUsernames[i]);
report(
results,
'user-fan-out-je-mandant-gebunden-liefert-alle-zeilen',
fanOutMatches,
`Vereinigung der je-Mandant gebundenen SELECTs liefert ${fannedOutUsernames.length} Benutzernamen: ${JSON.stringify(fannedOutUsernames)}; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind ${groundTruthUsernames.length}: ${JSON.stringify(groundTruthUsernames)}`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Liest die ausgelieferte Migration, die die Tabellen "Module" und
* "TenantModuleActivation" samt dem Eindeutigkeitsindex auf
* ("tenantId","moduleId") anlegt (Dateiname endet auf
* "_add_module_registry").
*/
function readModuleRegistryMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_module_registry'))
.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 beiden
* partiellen Eindeutigkeitsindizes auf "ModuleGrant" anlegt (Dateiname endet
* auf "_add_groups_and_module_grants") — dieselbe Datei, aus der
* runGroupsAreaChecks() NICHT liest (jene braucht nur die RLS-Policy-
* Migration), deshalb ein eigenes, unabhaengiges Lesehilfsmittel.
*/
function readAddGroupsAndModuleGrantsMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_groups_and_module_grants'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Schneidet eine `CREATE UNIQUE INDEX "<indexName>" ...;`-Anweisung
* wortgleich aus einem Migrationstext, nach demselben Muster wie
* extractPolicySql() oben.
*/
function extractIndexSql(migrationSql, indexName) {
const re = new RegExp(`CREATE UNIQUE INDEX "${indexName}"[\\s\\S]*?;`);
const match = migrationSql.match(re);
return match ? match[0] : null;
}
/**
* Aufgabe 1 (260910-exd) — misst die dreizehn im Plan genannten
* Verhaltensweisen des Bereichs `module-registry` unter der Rolle ohne
* BYPASSRLS. Setzt auf den Tabellen "Group", "GroupMembership",
* "ModuleGrant" und "TenantModuleActivation" auf, die runGroupsAreaChecks()
* bereits angelegt und mit Policies WORTGLEICH aus den ausgelieferten
* Migrationen versehen hat — dieser Abschnitt legt sie NICHT neu an. Neu
* angelegt werden nur: die Tabelle "Module" OHNE Zeilenschutz (die zu
* messende Eigenschaft selbst), der Eindeutigkeitsindex auf
* ("tenantId","moduleId") fuer "TenantModuleActivation" (bis hierhin fehlte
* er, weil runGroupsAreaChecks() ihn nicht braucht), zwei Direkt-Freigaben
* (eine je Mandant) und die Mitgliedschaft (group-b, user-a), die
* runGroupsAreaChecks() unter gebundenem Kontext bewusst nicht anlegen
* konnte.
*
* Die bereits von runGroupsAreaChecks() eingefuegte Zeile
* `grant-foreign-group` (TENANT-A, mod-1, groupId=group-b) wird
* WIEDERVERWENDET, nicht neu erzeugt.
*
* Muss NACH runUserAreaChecks() und VOR runTransactionShapeMeasurement()
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
* Abschnitt aendert daran nichts.
*/
async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
const moduleRegistryMigrationSql = readModuleRegistryMigrationSql();
const activationUniqueIndexSql = moduleRegistryMigrationSql
? extractIndexSql(
moduleRegistryMigrationSql,
'TenantModuleActivation_tenantId_moduleId_key',
)
: null;
const groupsAndGrantsMigrationSql = readAddGroupsAndModuleGrantsMigrationSql();
const grantGroupUniqueIndexSql = groupsAndGrantsMigrationSql
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_group_unique')
: null;
const grantUserUniqueIndexSql = groupsAndGrantsMigrationSql
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_user_unique')
: null;
if (!activationUniqueIndexSql || !grantGroupUniqueIndexSql || !grantUserUniqueIndexSql) {
report(
results,
'module-registry-regeln-aus-migration-gefunden',
false,
'Eindeutigkeitsindex fuer "TenantModuleActivation" und/oder die beiden partiellen Eindeutigkeitsindizes fuer "ModuleGrant" nicht in den ausgelieferten Migrationen gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// (a) Katalogtabelle OHNE Zeilenschutz — die zu messende Eigenschaft
// selbst (Befund E), nach dem Muster der Tabelle "Tenant" im
// Abschnitt des Bereichs `user`.
await db.$executeRawUnsafe(`
CREATE TABLE "Module" (
id text PRIMARY KEY,
name text NOT NULL
);
`);
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "Module" TO ${SCRATCH_ROLE_NAME}`);
await db.$executeRawUnsafe(`
INSERT INTO "Module" (id, name) VALUES ('mod-1', 'Modul Eins'), ('mod-2', 'Modul Zwei');
`);
// (b) Eindeutigkeitsindex auf ("tenantId","moduleId") fuer
// "TenantModuleActivation", wortgleich aus der ausgelieferten Migration
// — ohne ihn liesse sich Pruefung 12 nicht messen.
await db.$executeRawUnsafe(activationUniqueIndexSql);
// Zusaetzliche Aktivierungszeile fuer Pruefung 12: (TENANT-B, mod-2),
// unter TENANT-A unsichtbar, aber physisch vorhanden.
await db.$executeRawUnsafe(`
INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES
('activation-b2', 'TENANT-B', 'mod-2', true);
`);
// (c) je eine Direkt-Freigabezeile pro Mandant (userId, kein groupId).
await db.$executeRawUnsafe(`
INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES
('grant-direct-a', 'TENANT-A', 'mod-1', NULL, 'user-a'),
('grant-direct-b', 'TENANT-B', 'mod-1', NULL, 'user-b');
`);
// (d) die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks()
// unter gebundenem Kontext bewusst NICHT anlegen konnte (die Regel wies
// sie ab) — hier ueber die Verwaltungsrolle gesetzt, weil Pruefung 6
// sonst aus dem falschen Grund bestuende.
await db.$executeRawUnsafe(`
INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES
('membership-cross', 'group-b', 'user-a', 'MANUAL');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1: modulegrant-ungebunden-null-zeilen — die Belegzeile dieses
// Abschnitts.
const actualGrantCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "ModuleGrant"`;
return row.n;
},
);
const unboundGrantRows = await prisma.$queryRaw`SELECT "tenantId" FROM "ModuleGrant"`;
report(
results,
'modulegrant-ungebunden-null-zeilen',
unboundGrantRows.length === 0,
`ungebundener SELECT auf "ModuleGrant" liefert ${unboundGrantRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualGrantCount}`,
);
// 2: tenantmoduleactivation-ungebunden-null-zeilen — dasselbe fuer die
// Aktivierungstabelle, die der Kurzschluss fuer ADMIN/SUPER_ADMIN als
// EINZIGE liest.
const actualActivationCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "TenantModuleActivation"`;
return row.n;
},
);
const unboundActivationRows =
await prisma.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation"`;
report(
results,
'tenantmoduleactivation-ungebunden-null-zeilen',
unboundActivationRows.length === 0,
`ungebundener SELECT auf "TenantModuleActivation" liefert ${unboundActivationRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualActivationCount}`,
);
// 3: admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen
const activeActivationsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" WHERE "isActive" = true ORDER BY id`,
);
report(
results,
'admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen',
activeActivationsForA.length === 1 && activeActivationsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${activeActivationsForA.length} aktive Aktivierung(en): ${JSON.stringify(activeActivationsForA.map((r) => r.tenantId))}`,
);
// 4: direktfreigabe-gebunden-nur-eigene-zeile
const directGrantsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "ModuleGrant" WHERE "userId" = 'user-a' ORDER BY id`,
);
report(
results,
'direktfreigabe-gebunden-nur-eigene-zeile',
directGrantsForA.length === 1 && directGrantsForA[0].id === 'grant-direct-a',
`forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) ${directGrantsForA.length} Zeile(n): ${JSON.stringify(directGrantsForA.map((r) => r.id))}`,
);
// Der Drei-Tabellen-Weg (ModuleGrant ueber Group ueber GroupMembership),
// Grundlage fuer Pruefung 5 und 6 — EINE Abfrage, zwei Aussagen.
const groupPathForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`
SELECT mg.id FROM "ModuleGrant" mg
JOIN "Group" g ON g.id = mg."groupId"
JOIN "GroupMembership" gm ON gm."groupId" = g.id
WHERE mg."tenantId" = current_tenant_id() AND gm."userId" = 'user-a'
ORDER BY mg.id
`,
);
// 5: gruppenpfad-gebunden-folgt-der-gruppenregel
report(
results,
'gruppenpfad-gebunden-folgt-der-gruppenregel',
groupPathForA.length === 1 && groupPathForA[0].id === 'grant-a',
`forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a ${groupPathForA.length} Zeile(n): ${JSON.stringify(groupPathForA.map((r) => r.id))}`,
);
// 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus
const excludesForeignGroup = !groupPathForA.some((r) => r.id === 'grant-foreign-group');
report(
results,
'gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus',
excludesForeignGroup,
`forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ${JSON.stringify(groupPathForA.map((r) => r.id))}), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten`,
);
// 7: gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit —
// die Gegenmessung ueber die Verwaltungsrolle mit BYPASSRLS.
const groupPathViaAdmin = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => db.$queryRaw`
SELECT mg.id FROM "ModuleGrant" mg
JOIN "Group" g ON g.id = mg."groupId"
JOIN "GroupMembership" gm ON gm."groupId" = g.id
WHERE mg."tenantId" = 'TENANT-A' AND gm."userId" = 'user-a'
ORDER BY mg.id
`,
);
const adminIncludesForeignGroup = groupPathViaAdmin.some((r) => r.id === 'grant-foreign-group');
report(
results,
'gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit',
adminIncludesForeignGroup,
`dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ${JSON.stringify(groupPathViaAdmin.map((r) => r.id))} — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau`,
);
// 8: module-tabelle-traegt-keinen-zeilenschutz
const unboundModuleRows = await prisma.$queryRaw`SELECT id FROM "Module" ORDER BY id`;
const [moduleRlsRow] =
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Module'`;
const moduleHasNoRls =
unboundModuleRows.length === 2 && moduleRlsRow?.relrowsecurity === false;
report(
results,
'module-tabelle-traegt-keinen-zeilenschutz',
moduleHasNoRls,
`ungebundenes SELECT auf "Module" liefert ${unboundModuleRows.length} Zeile(n): ${JSON.stringify(unboundModuleRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Module" = ${JSON.stringify(moduleRlsRow?.relrowsecurity)}`,
);
// 9: katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge
const boundModuleRows = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "Module" ORDER BY id`,
);
const catalogUnaffectedByBinding =
boundModuleRows.length === unboundModuleRows.length &&
boundModuleRows.every((r, i) => r.id === unboundModuleRows[i].id);
report(
results,
'katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge',
catalogUnaffectedByBinding,
`forTenant(TENANT-A) liefert ${JSON.stringify(boundModuleRows.map((r) => r.id))}, ungebunden liefert ${JSON.stringify(unboundModuleRows.map((r) => r.id))} — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)`,
);
// 10: gebundener-join-auf-den-katalog-liefert-den-modulnamen
const activationWithModuleName = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`
SELECT tma."moduleId", m.name FROM "TenantModuleActivation" tma
JOIN "Module" m ON m.id = tma."moduleId"
WHERE tma."isActive" = true
ORDER BY tma.id
`,
);
report(
results,
'gebundener-join-auf-den-katalog-liefert-den-modulnamen',
activationWithModuleName.length === 1 &&
activationWithModuleName[0].moduleId === 'mod-1' &&
activationWithModuleName[0].name === 'Modul Eins',
`forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: ${JSON.stringify(activationWithModuleName)}`,
);
// 11: aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignActivationInsertRejected = false;
let foreignActivationInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-rejected-foreign', 'TENANT-B', 'mod-2', true)`,
);
foreignActivationInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignActivationInsertRejected = sqlState === '42501';
foreignActivationInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignActivationInsertRejected,
foreignActivationInsertDetail,
);
// 12: aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision
let ownActivationInsertSucceeded = false;
let ownActivationInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-a2', 'TENANT-A', 'mod-2', true)`,
);
ownActivationInsertSucceeded = true;
ownActivationInsertDetail =
'gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)';
} catch (err) {
ownActivationInsertDetail = `gebundenes INSERT von (TENANT-A, mod-2) unerwartet abgewiesen: ${err.message.trim()}`;
}
report(
results,
'aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision',
ownActivationInsertSucceeded,
ownActivationInsertDetail,
);
// 13: freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung —
// Textmessung statt Datenbankmessung.
const groupIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"groupId"\)/.test(
grantGroupUniqueIndexSql,
);
const userIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"userId"\)/.test(
grantUserUniqueIndexSql,
);
report(
results,
'freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung',
groupIndexLeadsWithTenant && userIndexLeadsWithTenant,
`aus 20260804130130_add_groups_and_module_grants extrahiert: ${JSON.stringify(grantGroupUniqueIndexSql)} und ${JSON.stringify(grantUserUniqueIndexSql)} — beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle`,
);
} 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 runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(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);
});