Files
tessera-ctl/apps/api/scripts/rls-scratch-check.mjs
T
schalli f0b531b712 feat(quick-260911-nke): current_user_id(), Benutzerdimension in den Regeln, forTenant() mit userId — ein Pfad
- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id()
  (NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel,
  SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier
  Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy,
  Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert.
- forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten
  Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20),
  Leerstring ohne Benutzer statt Weglassen.
- tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId
  durch; Detektor-Regex bestaetigt 4 Treffer.
- rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten
  (nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks()
  mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich),
  die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar
  umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer).
  sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten
  Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code).
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden.

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

4928 lines
228 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');
const SCHEMA_PRISMA_PATH = join(__dirname, '../prisma/schema.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')`,
);
// Benutzerdimension (Etappe 3b, 260911-nke): current_user_id() wird aus
// der neuen Migration GESCHNITTEN, nicht getippt (Muster ldap,
// T-IPC-08) — findet die Extraktion nichts, bricht das Werkzeug hier
// ab, statt mit einem geratenen Funktionstext weiterzumessen.
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
if (!userDimensionMigrationSql) {
fail(
'Migration "_rls_user_dimension_personal_tables" nicht gefunden — current_user_id() kann nicht geschnitten werden.',
);
}
const currentUserIdFunctionSql = extractCurrentUserIdFunctionSql(userDimensionMigrationSql);
if (!currentUserIdFunctionSql) {
fail(
'CREATE OR REPLACE FUNCTION current_user_id() nicht in der neuen Migration gefunden.',
);
}
await db.$executeRawUnsafe(currentUserIdFunctionSql);
await db.$executeRawUnsafe(
`GRANT EXECUTE ON FUNCTION current_user_id() TO ${SCRATCH_ROLE_NAME}`,
);
});
}
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, userId) {
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
const [, result] = await prisma.$transaction([setContext, 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)`,
);
// Benutzerdimension (Etappe 3b, 260911-nke): die drei Funktionsfaelle
// von current_user_id(), je innerhalb einer eigenen Transaktion ueber
// $queryRaw gemessen.
const [ungesetztRow] = await prisma.$transaction([
prisma.$queryRaw`SELECT current_user_id() AS v`,
]);
report(
results,
'current-user-id-ungesetzt-ist-null',
ungesetztRow[0].v === null,
`current_user_id() ohne gesetzte Variable=${JSON.stringify(ungesetztRow[0].v)}`,
);
const [, leerQueryRows] = await prisma.$transaction([
prisma.$executeRaw`SELECT set_config('app.current_user', '', true)`,
prisma.$queryRaw`SELECT current_user_id() AS v`,
]);
report(
results,
'current-user-id-leer-ist-null',
leerQueryRows[0].v === null,
`current_user_id() nach set_config('app.current_user', '', true)=${JSON.stringify(leerQueryRows[0].v)}`,
);
const [, gesetztRows] = await prisma.$transaction([
prisma.$executeRaw`SELECT set_config('app.current_user', 'user-a1', true)`,
prisma.$queryRaw`SELECT current_user_id() AS v`,
]);
report(
results,
'current-user-id-gesetzt-liefert-wert',
gesetztRows[0].v === 'user-a1',
`current_user_id() nach set_config('app.current_user', 'user-a1', true)=${JSON.stringify(gesetztRows[0].v)}`,
);
} 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;
}
/**
* Liest die Migration, die T-JTS-02, T-JTS-03 und WINDOWS #19 schliesst
* (Dateiname endet auf "_rls_widen_membership_grant_and_platform_read",
* 260910-jab). Die beiden abgeloesten Regeln (GroupMembership, ModuleGrant)
* MUESSEN ab hier aus dieser Datei extrahiert werden, nicht mehr aus
* `readGroupsRlsPoliciesMigrationSql()` — sonst misst dieses Werkzeug
* weiter die abgeloeste Regel (Befund D).
*/
function readRlsWidenMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.endsWith('_rls_widen_membership_grant_and_platform_read'),
)
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Wie extractPolicySql(), aber liefert ALLE `CREATE POLICY ... ON
* "<Tabelle>"`-Anweisungen einer Tabelle statt nur der ersten (Befund D:
* extractPolicySql() hat keinen globalen Flag und kennt nur EINEN
* Policy-Namen — fuer eine Tabelle mit mehreren, nach Befehl getrennten
* Regeln unter verschiedenen Namen reicht das nicht). Policy-Name ist
* absichtlich ein Platzhalter (`\w+`), nicht `tenant_isolation_policy` —
* die vier Regeln auf "TenderRssFeedSource" heissen unterschiedlich.
*/
function extractAllPolicySql(migrationSql, tableName) {
const re = new RegExp(`CREATE POLICY \\w+ ON "${tableName}"[\\s\\S]*?;`, 'g');
return [...migrationSql.matchAll(re)].map((m) => m[0]);
}
/**
* Liest die Migration der Benutzerdimension (Etappe 3b, 260911-nke,
* Dateiname endet auf "_rls_user_dimension_personal_tables"). Die Regeln der
* zehn persoenlichen Tabellen UND die Funktion `current_user_id()` MUESSEN
* ab hier aus dieser Datei geschnitten werden, nicht getippt und nicht aus
* einer aelteren Migration gelesen — sonst misst dieses Werkzeug ein
* getipptes Wunschbild oder die abgeloeste Regel (Befund D, Praezedenz
* 260910-jab).
*/
function readRlsUserDimensionMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() && entry.name.endsWith('_rls_user_dimension_personal_tables'),
)
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Schneidet die Definition von `current_user_id()` wortgleich aus der neuen
* Migration. Liefert `null`, wenn nichts gefunden wird — der Aufrufer bricht
* dann mit einer FEHLGESCHLAGENEN Pruefung ab, statt die Funktion im
* Werkzeug selbst zu tippen (Muster ldap, T-IPC-08).
*/
function extractCurrentUserIdFunctionSql(migrationSql) {
const re = /CREATE OR REPLACE FUNCTION current_user_id\(\)[\s\S]*?LANGUAGE sql STABLE;/;
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/260910-jab), TEIL 1 — misst die im Plan genannten
* Verhaltensweisen des Bereichs groups unter der Rolle ohne BYPASSRLS, mit
* den vier Policies WORTGLEICH aus den 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.
*
* GroupMembership und ModuleGrant werden seit 260910-jab (T-JTS-02,
* T-JTS-03) aus `readRlsWidenMigrationSql()` gelesen, NICHT mehr aus
* `readGroupsRlsPoliciesMigrationSql()` — jene Datei traegt noch die
* abgeloeste, kuerzer greifende Fassung (Befund D). "Group" bleibt
* unveraendert und wird weiterhin aus der urspruenglichen Datei gelesen.
*
* 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. Setzt
* ausserdem auf der Tabelle "User" auf, die runAuthLookupChecks() vorher
* bereits angelegt hat (Zeilen user-a/TENANT-A, user-b/TENANT-B) — die neue
* GroupMembership/ModuleGrant-Regel prueft ueber diese Tabelle.
*/
async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) {
const groupsMigrationSql = readGroupsRlsPoliciesMigrationSql();
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const widenMigrationSql = readRlsWidenMigrationSql();
const groupPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'Group') : null;
const groupMembershipPolicy = widenMigrationSql
? extractPolicySql(widenMigrationSql, 'GroupMembership')
: null;
const moduleGrantPolicy = widenMigrationSql
? extractPolicySql(widenMigrationSql, '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" (20260804130918_groups_rls_policies), "GroupMembership"/"ModuleGrant" (20260910120000_rls_widen_membership_grant_and_platform_read) und/oder "TenantModuleActivation" (20260909140000_rls_remaining_tenant_tables) 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-abgelehnt (260910-jab,
// T-JTS-02) — Umkehr der bisherigen loch-behauptenden Pruefung
// `groupmembership-schreiben-fremder-benutzer-nicht-verhindert`. Gemessen
// mit `user-b`, den es in TENANT-B TATSAECHLICH gibt (nicht mit einer
// erfundenen Kennung wie vormals `user-nicht-in-a`) — nur so misst diese
// Pruefung den Fall, den T-JTS-02 benannt hat, statt eines
// Fremdschluessel-Nichts. Die ABWEISUNG ist jetzt das bestandene
// Ergebnis: die Regel prueft seit 20260910120000_rls_widen_membership_
// grant_and_platform_read beide Seiten der Beziehung.
let foreignUserInsertRejected = 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-b', 'MANUAL')`,
);
foreignUserInsertDetail =
'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung aus TENANT-B (user-b) ist NICHT fehlgeschlagen';
} catch (err) {
foreignUserInsertRejected = true;
foreignUserInsertDetail = `INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) abgewiesen: ${err.message} — Umkehr von 'groupmembership-schreiben-fremder-benutzer-nicht-verhindert' (T-JTS-02, Befund E): die Policy auf GroupMembership prueft jetzt zusaetzlich die Benutzerseite, nicht mehr nur die Gruppenseite`;
}
report(
results,
'groupmembership-schreiben-fremder-benutzer-abgelehnt',
foreignUserInsertRejected,
foreignUserInsertDetail,
);
// groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich
// — die Gegenmessung: dasselbe Einfuegen ueber die Verwaltungsrolle mit
// Umgehungsrecht GELINGT. Damit steht fest, dass die Abweisung oben von
// der Regel kommt und nicht vom Aufbau. Praezedenzfall:
// `gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit`.
let foreignUserInsertViaAdminSucceeded = false;
let foreignUserInsertViaAdminDetail = '';
try {
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
db.$executeRawUnsafe(
`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user-admin', 'group-a', 'user-b', 'MANUAL')`,
),
);
foreignUserInsertViaAdminSucceeded = true;
foreignUserInsertViaAdminDetail =
'INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — der Ausschluss aus der vorigen Pruefung kommt damit nachweislich von der Regel, nicht vom Aufbau';
} catch (err) {
foreignUserInsertViaAdminDetail = `INSERT ueber die Wartungsrolle unerwartet abgewiesen: ${err.message}`;
}
report(
results,
'groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich',
foreignUserInsertViaAdminSucceeded,
foreignUserInsertViaAdminDetail,
);
// 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-abgelehnt (260910-jab, T-JTS-03) — Umkehr
// der bisherigen loch-behauptenden Pruefung
// `modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt`. Die
// ABWEISUNG ist jetzt das bestandene Ergebnis. Eigene id
// ('grant-foreign-group-rejected-attempt'), NICHT 'grant-foreign-group'
// — jene Kennung bleibt fuer die Zeile reserviert, die die Gegenmessung
// unten ueber die Wartungsrolle anlegt und auf der zwei Pruefungen des
// Bereichs `module-registry` aufsetzen (Befund C).
let foreignGroupGrantRejected = false;
let foreignGroupGrantDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group-rejected-attempt', 'TENANT-A', 'mod-1', 'group-b', NULL)`,
);
foreignGroupGrantDetail = 'INSERT mit korrekter eigener tenantId, aber fremder groupId ist NICHT fehlgeschlagen';
} catch (err) {
foreignGroupGrantRejected = true;
foreignGroupGrantDetail = `INSERT mit korrekter eigener tenantId, aber fremder groupId (group-b, TENANT-B) abgewiesen: ${err.message} — Umkehr von 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt' (T-JTS-03, Befund F): die Policy auf ModuleGrant prueft jetzt zusaetzlich, ob die referenzierte Gruppe zum Mandanten gehoert. assertTargetBelongsToTenant in module-grants.service.ts bleibt DAVON UNABHAENGIG bestehen — dieses zweite Netz wird durch die Regelaenderung nicht ersetzt`;
}
report(
results,
'modulegrant-fremde-gruppe-abgelehnt',
foreignGroupGrantRejected,
foreignGroupGrantDetail,
);
// modulegrant-fremder-benutzer-abgelehnt — NEU, der zweite Zweig des
// Entweder-oder (D-04): eine Freigabe mit eigener Mandantenkennung und
// fremder Benutzerkennung. T-JTS-03 hat nur den Gruppenzweig gemessen;
// ohne diese Pruefung bliebe die Haelfte des Lochs offen.
let foreignUserGrantRejected = false;
let foreignUserGrantDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-user-rejected-attempt', 'TENANT-A', 'mod-1', NULL, 'user-b')`,
);
foreignUserGrantDetail = 'INSERT mit korrekter eigener tenantId, aber fremder userId ist NICHT fehlgeschlagen';
} catch (err) {
foreignUserGrantRejected = true;
foreignUserGrantDetail = `INSERT mit korrekter eigener tenantId, aber fremder userId (user-b, TENANT-B) abgewiesen: ${err.message} — T-JTS-03 hatte nur den Gruppenzweig des Entweder-oder (D-04) gemessen; dieser Zweig ist der zweite und schliesst die andere Haelfte des Lochs`;
}
report(
results,
'modulegrant-fremder-benutzer-abgelehnt',
foreignUserGrantRejected,
foreignUserGrantDetail,
);
// modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich —
// Ersatz/Umkehr-Gegenmessung fuer die vormals loch-behauptende Pruefung.
// Legt ZUGLEICH die Zeile 'grant-foreign-group' an, die vormals als
// Nebenwirkung der loch-behauptenden Pruefung entstand und auf der die
// beiden Pruefungen des Bereichs `module-registry` aufsetzen (Befund C)
// — jetzt ueber die Wartungsrolle bereitgestellt, weil das gebundene
// INSERT oben abgewiesen wird.
let foreignGroupGrantViaAdminSucceeded = false;
let foreignGroupGrantViaAdminDetail = '';
try {
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
db.$executeRawUnsafe(
`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`,
),
);
foreignGroupGrantViaAdminSucceeded = true;
foreignGroupGrantViaAdminDetail =
"INSERT mit korrekter eigener tenantId, aber fremder groupId ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — Umkehr von 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt' (T-JTS-03): die Regel weist diese Zeile unter gebundenem Kontext nachweislich ab (siehe 'modulegrant-fremde-gruppe-abgelehnt'), die Wartungsrolle umgeht RLS grundsaetzlich; legt zugleich die Zeile 'grant-foreign-group' an, auf der zwei Pruefungen des Bereichs module-registry aufsetzen (Befund C)";
} catch (err) {
foreignGroupGrantViaAdminDetail = `INSERT ueber die Wartungsrolle unerwartet abgewiesen: ${err.message}`;
}
report(
results,
'modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich',
foreignGroupGrantViaAdminSucceeded,
foreignGroupGrantViaAdminDetail,
);
// 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/260910-jab) — misst die Sonderfaelle des Bereichs
* tenders unter der Rolle ohne BYPASSRLS. Vier der fuenf Policies
* (TenderEmailConfig, TenderNotificationPref, TenderSavedSearch,
* TenderTriage) kommen WORTGLEICH aus der ausgelieferten Migration
* `_rls_remaining_tenant_tables` (dieselbe Datei, die bereits die
* TenantModuleActivation-Policy fuer runGroupsAreaChecks liefert).
* TenderRssFeedSource (WINDOWS #19) kommt seit 260910-jab NICHT mehr aus
* jener Datei — die dortige Fassung ist die abgeloeste, kuerzer greifende
* Regel (Befund D) — sondern als VIER nach Befehl getrennte Policies aus
* `readRlsWidenMigrationSql()`, extrahiert mit `extractAllPolicySql()`
* (mehrere Treffer je Tabelle, Befund D). Findet die Extraktion eine der
* Policies 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 widenMigrationSql = readRlsWidenMigrationSql();
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
const emailConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
: null;
const notificationPrefPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref')
: null;
const rssFeedPolicies = widenMigrationSql
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
: [];
// Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird
// ab der Benutzerdimension-Migration gelesen, nicht mehr aus
// *_rls_remaining_tenant_tables — sonst misst dieser Abschnitt die
// abgeloeste Regel ohne Benutzerdimension (Befund D, Praezedenz jab).
const savedSearchPolicy = userDimensionMigrationSql
? extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch')
: null;
const triagePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
: null;
if (
!emailConfigPolicy ||
!notificationPrefPolicy ||
rssFeedPolicies.length !== 4 ||
!savedSearchPolicy ||
!triagePolicy
) {
report(
results,
'tenders-policies-aus-migration-gefunden',
false,
`CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" in *_rls_widen_membership_grant_and_platform_read (gefunden: ${rssFeedPolicies.length})`,
);
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);
for (const policySql of rssFeedPolicies) {
await db.$executeRawUnsafe(policySql);
}
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)`,
);
// Umgedreht (260911-nke, Aufgabe 1): die alte Pruefung
// "tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar" (Befund
// E, 260909-laa) mass OHNE Benutzer — nach der Migration
// 20260911120000 ist das GELINGEN (beide Nutzer sichtbar) weiterhin der
// Fall, aber jetzt als GEWOLLTE Eigenschaft der IS-NULL-Form fuer einen
// Aufruf ohne gesetzten Benutzer (Admin, Hintergrunddienst), nicht mehr
// als Luecke. Die alte Messung bleibt unter neuem Namen bestehen; die
// Umkehrung misst MIT Benutzer und erwartet das GEGENTEIL.
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
report(
results,
'tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
bothUsersVisible,
`bis 260911-nke als Befund E unter dem Namen tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst. forTenant(TENANT-A) OHNE Benutzer liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA])}`,
);
const savedSearchRowsForA1 = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`,
'user-a1',
);
const usersVisibleForA1 = new Set(savedSearchRowsForA1.map((r) => r.userId));
const kollegeNichtSichtbar = usersVisibleForA1.has('user-a1') && !usersVisibleForA1.has('user-a2');
report(
results,
'tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden',
kollegeNichtSichtbar,
`forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA1])}`,
);
// 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-gebunden-sichtbar (260910-jab, WINDOWS
// #19) — Umkehr von 'tenderrssfeed-plattformzeile-unter-jedem-
// mandanten-unsichtbar': die plattformweite Zeile (userId/tenantId
// NULL) ist jetzt unter BEIDEN Mandantenkontexten SICHTBAR, weil die
// Leseregel der neuen vier Policies (tenant_platform_read_policy,
// 20260910120000_rls_widen_membership_grant_and_platform_read) Zeilen
// ohne Mandant ausdruecklich einschliesst.
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-gebunden-sichtbar',
platformRowVisibleForA && platformRowVisibleForB,
`forTenant(TENANT-A) sieht die Platform-Zeile: ${platformRowVisibleForA}, forTenant(TENANT-B) sieht sie: ${platformRowVisibleForB} — Umkehr von 'tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar' (WINDOWS #19): eine plattformweite RSS-Quelle ist seit 20260910120000_rls_widen_membership_grant_and_platform_read unter JEDEM Mandantenkontext sichtbar`,
);
// tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar — die andere
// Fehlerrichtung derselben Aenderung: der gebundene Lesezugriff liefert
// ZUSAETZLICH weiterhin die eigene Zeile des Mandanten und NICHT die des
// anderen. Ohne diese Pruefung waere eine zu weit gefasste Leseregel
// unbemerkt geblieben.
const ownRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-a');
const foreignRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-b');
report(
results,
'tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar',
ownRowVisibleForA && !foreignRowVisibleForA,
`forTenant(TENANT-A) liefert ${JSON.stringify(rssRowsForA.map((r) => r.id))} — eigene Zeile (rss-a) sichtbar: ${ownRowVisibleForA}, fremde Zeile (rss-b, TENANT-B) sichtbar: ${foreignRowVisibleForA}`,
);
// tenderrssfeed-ungebunden-nur-die-plattformzeile — die Belegzeile fuer
// Befund F: ohne gesetzten Mandantenkontext liefert der Lesezugriff
// jetzt genau die plattformweiten Zeilen und keine persoenliche. Das ist
// die neue Fehlerrichtung, die Aufgabe 2 traegt (listForUser wird
// gebunden); hier gemessen, nicht behauptet.
const unboundRssRows = await prisma.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`;
const unboundOnlyPlatform =
unboundRssRows.length === 1 && unboundRssRows[0].id === 'rss-platform';
report(
results,
'tenderrssfeed-ungebunden-nur-die-plattformzeile',
unboundOnlyPlatform,
`ungebundener SELECT auf "TenderRssFeedSource" liefert ${unboundRssRows.length} Zeile(n): ${JSON.stringify(unboundRssRows.map((r) => r.id))} — Befund F: aus einer schreienden Leere (vor der Reparatur: null Zeilen) wuerde nach dem Scharfschalten eine kurze, glaubhafte Teilantwort (nur die plattformweiten Zeilen); listForUser() wird deshalb in Aufgabe 2 gebunden`,
);
// 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. Die Abweisung kommt jetzt von der ausdruecklichen
// WITH-CHECK-Klausel der Einfuegeregel (tenant_insert_policy), nicht mehr
// von der stillschweigenden Zweitverwendung eines einzigen USING-
// Ausdrucks.
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 (tenant_insert_policy): ${err.message} — createPlatform darf deshalb nicht gebunden werden`;
}
report(
results,
'tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt',
platformInsertRejected,
platformInsertDetail,
);
// tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt — NEU:
// ein gebundenes UPDATE auf die plattformweite Zeile wird abgewiesen.
// Zusammen mit der folgenden Pruefung die Messung der Richtung "zu
// locker" (Befund K) und damit der eigentliche Grund fuer die Trennung
// nach Befehl: eine einzelne Leseregel, die die plattformweite Zeile
// einschliesst, wuerde ohne diese Trennung auch UPDATE/DELETE erlauben.
let platformUpdateRejected = false;
let platformUpdateDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`UPDATE "TenderRssFeedSource" SET url = 'https://gebunden-veraendert.example-tenders.invalid/feed' WHERE id = 'rss-platform'`,
);
// Ein UPDATE, das keine Zeile trifft, wirft in Postgres KEINEN Fehler
// — es muss deshalb explizit nachgesehen werden, ob die Zeile
// tatsaechlich unveraendert blieb (die USING-Klausel der
// Aenderungsregel filtert die Zielzeile heraus, bevor SET greift).
const [afterUpdate] = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
(db) =>
db.$queryRaw`SELECT url FROM "TenderRssFeedSource" WHERE id = 'rss-platform'`,
);
platformUpdateRejected =
afterUpdate.url !== 'https://gebunden-veraendert.example-tenders.invalid/feed';
platformUpdateDetail = platformUpdateRejected
? `gebundenes UPDATE auf die plattformweite Zeile traf keine Zeile (tenant_update_policy filtert sie heraus) — url unveraendert: ${JSON.stringify(afterUpdate.url)}`
: `gebundenes UPDATE auf die plattformweite Zeile ist UNERWARTET durchgegangen — url jetzt: ${JSON.stringify(afterUpdate.url)}`;
} catch (err) {
platformUpdateRejected = true;
platformUpdateDetail = `gebundenes UPDATE auf die plattformweite Zeile abgewiesen: ${err.message}`;
}
report(
results,
'tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt',
platformUpdateRejected,
platformUpdateDetail,
);
// tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt — NEU:
// ein gebundenes DELETE auf die plattformweite Zeile wird abgewiesen.
let platformDeleteRejected = false;
let platformDeleteDetail = '';
try {
const deleteResult = await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`DELETE FROM "TenderRssFeedSource" WHERE id = 'rss-platform'`,
);
// $executeRaw liefert die Anzahl betroffener Zeilen — 0, wenn die
// USING-Klausel der Loeschregel die Zielzeile herausfiltert, bevor
// DELETE greift (kein Fehler, wie bei UPDATE oben).
platformDeleteRejected = deleteResult === 0;
platformDeleteDetail = platformDeleteRejected
? 'gebundenes DELETE auf die plattformweite Zeile traf 0 Zeilen (tenant_delete_policy filtert sie heraus)'
: `gebundenes DELETE auf die plattformweite Zeile ist UNERWARTET durchgegangen (${deleteResult} Zeile(n) betroffen)`;
} catch (err) {
platformDeleteRejected = true;
platformDeleteDetail = `gebundenes DELETE auf die plattformweite Zeile abgewiesen: ${err.message}`;
}
report(
results,
'tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt',
platformDeleteRejected,
platformDeleteDetail,
);
// 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 (260910-jab) — misst, dass die UNVERAENDERT ausgelieferte Regel
* auf "SearchProvider" (20260909140000_rls_remaining_tenant_tables) eine
* mandantenlose Zeile weiterhin unter JEDEM Kontext unsichtbar laesst.
* Anders als bei TenderRssFeedSource ist das hier KEIN Loch, sondern
* korrektes Verhalten (Befund E): die Praemisse von WINDOWS #19 ist fuer
* diese Tabelle widerlegt — es gibt keinen Codeweg, der eine mandantenlose
* Zeile erzeugt (der einzige Schreibweg, dashboard.service.ts, verlangt die
* Mandantenkennung als Pflichtparameter; die Vorgabe-Suchmaschinen sind
* Konstanten, Entscheidung 05-02, keine Datenbankzeilen). Eine Lockerung
* hier waere die falsche Richtung. Eigene Wegwerf-Tabelle, weil keine
* andere Pruefung dieses Werkzeugs "SearchProvider" beruehrt.
*
* Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs
* aufsetzt — wie runTendersAreaChecks() ist dieser Abschnitt in der
* Aufrufkette ein Blatt.
*/
async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const searchProviderPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SearchProvider')
: null;
if (!searchProviderPolicy) {
report(
results,
'searchprovider-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "SearchProvider" 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 "SearchProvider" (
id text PRIMARY KEY,
"userId" text,
"tenantId" text,
name text NOT NULL
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(searchProviderPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SearchProvider" TO ${SCRATCH_ROLE_NAME}`,
);
// Eine mandantenlose Zeile wird ueber die Wartungsrolle angelegt — unter
// der Anwendungsrolle liesse sie sich mit der unveraenderten Regel
// ohnehin nicht schreiben.
await db.$executeRawUnsafe(`
INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES
('search-tenantless', NULL, NULL, 'Mandantenlose Suchmaschine');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "SearchProvider" ORDER BY id`,
);
const rowsForB = await forTenantQuery(prisma, 'TENANT-B', (tx) =>
tx.$queryRaw`SELECT id FROM "SearchProvider" ORDER BY id`,
);
const visibleForA = rowsForA.some((r) => r.id === 'search-tenantless');
const visibleForB = rowsForB.some((r) => r.id === 'search-tenantless');
report(
results,
'searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar',
!visibleForA && !visibleForB,
`forTenant(TENANT-A) sieht die mandantenlose Zeile: ${visibleForA}, forTenant(TENANT-B) sieht sie: ${visibleForB} — bewusst UNVERAENDERT (WINDOWS #19, Befund E: die Praemisse einer mandantenlosen SearchProvider-Zeile ist widerlegt, es gibt keinen Codeweg, der eine solche Zeile erzeugt — dashboard.service.ts verlangt die Mandantenkennung als Pflichtparameter, die Vorgabe-Suchmaschinen sind Konstanten, 05-02). Neu zu bewerten, WENN ein Schreibweg entsteht, der "tenantId" absichtlich leer laesst`,
);
} 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) {
if (err?.meta?.code) return err.meta.code;
// 260911-nke, Aufgabe 1: ein RLS-abgewiesenes .create() ueber den
// GENERIERTEN Client (Batch-Insert-Pfad) wirft PrismaClientUnknownRequestError
// OHNE `.meta.code` — der PostgreSQL-SQLSTATE steckt stattdessen nur im
// Fehlertext (`code: "42501"` innerhalb der ConnectorError-Ausgabe).
// Empirisch geprueft gegen tessera-ctl-db-1, 2026-09-11:
// `tenderSavedSearch.create()` unter einer RLS-Verletzung liefert genau
// diese Form. Fallback-Regex, damit die Pruefung den tatsaechlichen
// Datenbankfehler misst statt eines falschen "unbekannt".
const match = /code:\s*"(\d{5})"/.exec(err?.message ?? '');
return match ? match[1] : 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 — Meldetext
// seit 260910-jab RICHTIGGESTELLT: er behauptete vormals, die Regel auf
// "ModuleGrant" lasse 'grant-foreign-group' durch (T-JTS-03). Das ist
// nach der Reparatur unwahr — die Regel weist ein gebundenes Einfuegen
// dieser Zeile jetzt nachweislich ab (siehe 'modulegrant-fremde-gruppe-
// abgelehnt'); die Zeile existiert hier nur, weil
// 'modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich'
// sie ueber die Wartungsrolle (BYPASSRLS) angelegt hat.
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))}) — die Zeile wurde ueber die Wartungsrolle angelegt (die Regel auf "ModuleGrant" weist ihr gebundenes Einfuegen seit T-JTS-02/T-JTS-03 nachweislich ab) und die Mitgliedschaft (group-b, user-a) ist vorhanden — 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 (260910-krx) — misst die dreizehn im Plan genannten
* Verhaltensweisen des Bereichs `dashboard` unter der Rolle ohne BYPASSRLS,
* mit den drei Policies fuer "DashboardLayout", "WidgetInstance" und
* "SearchProvider" WORTGLEICH aus der ausgelieferten
* *_rls_remaining_tenant_tables-Migration geschnitten (nicht im Werkzeug
* nachgetippt). Findet die Extraktion eine der drei nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Regel weiterzumessen — wie alle vorherigen Abschnitte.
*
* Legt die Tabellen "DashboardLayout" und "WidgetInstance" selbst neu an
* (bisher von keinem Abschnitt gebraucht). Die Tabelle "SearchProvider"
* dagegen wird von runSearchProviderAreaChecks() bereits angelegt, samt
* eingeschaltetem und erzwungenem Zeilenschutz, der unveraendert strengen
* Regel und der einen mandantenlosen Zeile 'search-tenantless' — dieser
* Abschnitt legt sie NICHT ein zweites Mal an, sondern ERGAENZT nur weitere
* Zeilen. Muss deshalb NACH runSearchProviderAreaChecks() laufen (in main()
* bereits der Fall: runSearchProviderAreaChecks() steht deutlich frueher in
* der Aufrufkette) und VOR runTransactionShapeMeasurement(), das weiterhin
* auf der von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt —
* dieser Abschnitt aendert daran nichts. Die bestehende Pruefung
* `searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar`
* und ihre Testzeile ('search-tenantless') bleiben unveraendert; alle hier
* neu vergebenen Kennungen sind eigene, damit sie nicht kollidieren.
*
* "DashboardLayout" bildet die Eindeutigkeitsbedingung des Schemas
* (`"userId" text UNIQUE`) nach, weil Pruefung 5 (die Konfliktmessung) ohne
* sie nicht stattfinden kann.
*/
async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const dashboardLayoutPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DashboardLayout')
: null;
const widgetInstancePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'WidgetInstance')
: null;
const searchProviderPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SearchProvider')
: null;
if (!dashboardLayoutPolicy || !widgetInstancePolicy || !searchProviderPolicy) {
report(
results,
'dashboard-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "DashboardLayout", "WidgetInstance" und/oder "SearchProvider" 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 "DashboardLayout" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL,
layouts jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "WidgetInstance" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetType" text NOT NULL
);
`);
for (const table of ['DashboardLayout', 'WidgetInstance']) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(dashboardLayoutPolicy);
await db.$executeRawUnsafe(widgetInstancePolicy);
for (const table of ['DashboardLayout', 'WidgetInstance']) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen (Befund
// G: die Regel kennt keine Benutzerdimension) plus eine Zeile unter
// TENANT-B fuer die Mandantengrenze.
await db.$executeRawUnsafe(`
INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES
('layout-a1', 'user-a1', 'TENANT-A', '{}'::jsonb),
('layout-a2', 'user-a2', 'TENANT-A', '{}'::jsonb),
('layout-b1', 'user-b1', 'TENANT-B', '{}'::jsonb);
`);
// Physisch vorhandene, unter TENANT-A unsichtbare Zeile fuer Pruefung 5
// (die Konfliktmessung) — ueber die Wartungsrolle angelegt, weil sich
// eine mandantenfremde Zeile unter der Anwendungsrolle ohnehin nicht
// schreiben liesse.
await db.$executeRawUnsafe(`
INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES
('layout-conflict-target', 'user-conflict', 'TENANT-B', '{}'::jsonb);
`);
await db.$executeRawUnsafe(`
INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES
('widget-a1', 'user-a1', 'TENANT-A', 'clock'),
('widget-a2', 'user-a2', 'TENANT-A', 'search'),
('widget-b1', 'user-b1', 'TENANT-B', 'clock');
`);
// Weitere Zeilen auf der bereits vorhandenen Tabelle "SearchProvider"
// (runSearchProviderAreaChecks) — eigene Kennungen, die bestehende Zeile
// 'search-tenantless' bleibt unberuehrt.
await db.$executeRawUnsafe(`
INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES
('search-a1', 'user-a1', 'TENANT-A', 'Interne Suche A1'),
('search-a2', 'user-a2', 'TENANT-A', 'Interne Suche A2'),
('search-b1', 'user-b1', 'TENANT-B', 'Interne Suche B1');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1 + 3: dashboardlayout-gebunden-nur-eigener-mandant UND
// dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
// eine Abfrage, zwei Aussagen. Die Pruefung 3 bestehen zu lassen IST das
// erwartete Ergebnis: die Regel kennt keine Benutzerdimension.
const layoutRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "DashboardLayout" ORDER BY id`,
);
report(
results,
'dashboardlayout-gebunden-nur-eigener-mandant',
layoutRowsForA.length === 2 && layoutRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${layoutRowsForA.length} Zeile(n): ${JSON.stringify(layoutRowsForA.map((r) => r.id))}`,
);
const secondUserVisible = layoutRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
secondUserVisible,
`forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible} — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten`,
);
// 2: dashboardlayout-ungebunden-null-zeilen — die Belegzeile dieses
// Abschnitts.
const unboundLayoutRows = await prisma.$queryRaw`SELECT "tenantId" FROM "DashboardLayout"`;
report(
results,
'dashboardlayout-ungebunden-null-zeilen',
unboundLayoutRows.length === 0,
`ungebundener SELECT auf "DashboardLayout" liefert ${unboundLayoutRows.length} Zeile(n), tatsaechlich vorhanden sind 4`,
);
// 4 + 13: widgetinstance-ungebunden-null-zeilen und
// widgetinstance-gebunden-nur-eigener-mandant / -fremder-nutzer-...
const unboundWidgetRows = await prisma.$queryRaw`SELECT "tenantId" FROM "WidgetInstance"`;
report(
results,
'widgetinstance-ungebunden-null-zeilen',
unboundWidgetRows.length === 0,
`ungebundener SELECT auf "WidgetInstance" liefert ${unboundWidgetRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
);
const widgetRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`,
);
report(
results,
'widgetinstance-gebunden-nur-eigener-mandant',
widgetRowsForA.length === 2 && widgetRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${widgetRowsForA.length} Zeile(n): ${JSON.stringify(widgetRowsForA.map((r) => r.id))}`,
);
const widgetSecondUserVisible = widgetRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
widgetSecondUserVisible,
`forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${widgetSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs`,
);
// 5: dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-
// zeile-scheitert-laut — MISST, was ein gebundenes Einfuegen mit
// Konfliktbehandlung tut, wenn es auf eine physisch vorhandene, unter
// dem laufenden Mandanten unsichtbare Zeile trifft ('layout-conflict-
// target', TENANT-B). Bestanden ist diese Pruefung genau dann, wenn ein
// HARTER, benannter Fehler zurueckkommt — NICHT, wenn der Vorgang still
// gelingt, eine fremde Zeile aendert oder eine Dublette erzeugt. Das
// Ergebnis wird NICHT vorweggenommen: es haengt am Zusammenspiel von
// Eindeutigkeitsindex und Regel.
let conflictRejectedLoudly = false;
let conflictDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES ('layout-conflict-attempt', 'user-conflict', 'TENANT-A', '{}'::jsonb) ON CONFLICT ("userId") DO UPDATE SET layouts = EXCLUDED.layouts`,
);
conflictDetail =
'gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen — still gelungen oder eine Dublette erzeugt';
} catch (err) {
conflictRejectedLoudly = true;
const sqlState = sqlStateOf(err);
conflictDetail = `gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE ${sqlState ?? 'unbekannt'}: ${err.message.trim()}`;
}
report(
results,
'dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut',
conflictRejectedLoudly,
conflictDetail,
);
// 5b: dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown
//
// Pruefung 5 misst mit Roh-SQL, WAS die Datenbank tut (42501). Der
// Anwendungscode in `saveLayout` faengt aber nicht SQLSTATEs, sondern die
// Fehlerklasse, die der GENERIERTE Prisma-Client daraus macht — und die
// ist nicht dieselbe wie beim P2002-Fall der Bereiche `tenders`/`user`
// (`PrismaClientKnownRequestError`), sondern
// `PrismaClientUnknownRequestError`, weil die Regel den Schreibzugriff
// abweist, bevor eine Eindeutigkeit ueberhaupt geprueft wird. Genau
// DIESE Klasse muss `saveLayout` uebersetzen; eine Uebersetzung der
// falschen Klasse spraenge nie an.
//
// Diese Messung fehlte in der ersten Lieferung von 260910-krx — die
// Zusammenfassung berief sich auf eine nicht committete Ad-hoc-Messung.
// Vom Verifizierer gefunden, hier nachgereicht: derselbe Vorgang wie in
// Pruefung 5, aber ueber `bound.dashboardLayout.upsert(...)` auf dem
// gebundenen generierten Client, und gepruegt wird der KONSTRUKTORNAME
// des geworfenen Fehlers.
let upsertThrewUnknown = false;
let upsertDetail = '';
try {
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
await bound.dashboardLayout.upsert({
where: { userId: 'user-conflict' },
update: { layouts: {} },
create: {
id: 'layout-conflict-attempt-upsert',
userId: 'user-conflict',
tenantId: 'TENANT-A',
layouts: {},
},
});
upsertDetail =
'gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen';
} catch (err) {
const ctor = err?.constructor?.name ?? 'unbekannt';
upsertThrewUnknown = ctor === 'PrismaClientUnknownRequestError';
upsertDetail = `gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''} — saveLayout uebersetzt genau diese Klasse; ${upsertThrewUnknown ? 'stimmt mit dem Anwendungscode ueberein' : 'STIMMT NICHT mit dem Anwendungscode ueberein, die Uebersetzung in saveLayout spraenge nie an'}`;
}
report(
results,
'dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown',
upsertThrewUnknown,
upsertDetail,
);
// 6: widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignWidgetInsertRejected = false;
let foreignWidgetInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES ('widget-rejected', 'user-a1', 'TENANT-B', 'clock')`,
);
foreignWidgetInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignWidgetInsertRejected = sqlState === '42501';
foreignWidgetInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignWidgetInsertRejected,
foreignWidgetInsertDetail,
);
// 7: widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
// — die Datenbankseite von Befund D: ein gebundenes DELETE ueber die
// Kennung einer fremden Zeile (widget-b1, TENANT-B) entfernt nichts und
// meldet keinen Fehler.
const foreignWidgetDeleteAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`DELETE FROM "WidgetInstance" WHERE id = 'widget-b1'`,
);
report(
results,
'widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
foreignWidgetDeleteAffected === 0,
`gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft ${foreignWidgetDeleteAffected} Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten`,
);
// 8 + 11: searchprovider-ungebunden-null-zeilen und
// searchprovider-gebunden-nur-eigener-mandant /
// -fremder-nutzer-desselben-mandanten-gebunden-sichtbar — gemessen
// gegen die neu hinzugefuegten Zeilen (search-a1/search-a2/search-b1),
// NICHT gegen 'search-tenantless' (bereits durch die bestehende
// Pruefung abgedeckt).
const actualSearchProviderCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "SearchProvider"`;
return row.n;
},
);
const unboundSearchProviderRows = await prisma.$queryRaw`SELECT "tenantId" FROM "SearchProvider"`;
report(
results,
'searchprovider-ungebunden-null-zeilen',
unboundSearchProviderRows.length === 0,
`ungebundener SELECT auf "SearchProvider" liefert ${unboundSearchProviderRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualSearchProviderCount}`,
);
const searchProviderRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "SearchProvider" WHERE id IN ('search-a1', 'search-a2', 'search-b1') ORDER BY id`,
);
report(
results,
'searchprovider-gebunden-nur-eigener-mandant',
searchProviderRowsForA.length === 2 &&
searchProviderRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${searchProviderRowsForA.length} Zeile(n) aus den neu hinzugefuegten: ${JSON.stringify(searchProviderRowsForA.map((r) => r.id))}`,
);
const searchProviderSecondUserVisible = searchProviderRowsForA.some(
(r) => r.userId === 'user-a2',
);
report(
results,
'searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
searchProviderSecondUserVisible,
`forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${searchProviderSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)`,
);
// 12: searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt — die
// Verteidigung der widerlegten Praemisse aus Befund F: selbst wenn ein
// kuenftiger Schreibweg es versuchte, kaeme er unter der
// Anwendungsrolle nicht durch, weil die Regel ohne eigene WITH-CHECK-
// Klausel die USING-Klausel dafuer wiederverwendet.
let searchProviderNoTenantInsertRejected = false;
let searchProviderNoTenantInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES ('search-rejected-no-tenant', 'user-a1', NULL, 'Sollte abgewiesen werden')`,
);
searchProviderNoTenantInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=NULL ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
searchProviderNoTenantInsertRejected = sqlState === '42501';
searchProviderNoTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt',
searchProviderNoTenantInsertRejected,
searchProviderNoTenantInsertDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Liest die Feldnamen eines Prisma-Modellblocks direkt aus
* `apps/api/prisma/schema.prisma`, statt sie im Werkzeug zu wiederholen
* (Pruefung 8 in `runCalendarAreaChecks`, Lehre aus Pruefung 5b im Bereich
* `dashboard`: der generierte Client waehlt standardmaessig JEDE Spalte des
* Modells aus und scheitert mit P2022 an jeder fehlenden — eine
* Wegwerf-Tabelle mit unvollstaendigem Spaltensatz wuerde das nie zeigen,
* Roh-SQL merkt es ohnehin nie). Feldname = erstes Wort jeder nicht-leeren
* Zeile im Modellblock, die nicht mit `@@` (Modell-Attribute wie
* `@@index`) und nicht mit `//` (Kommentarzeile) beginnt.
*/
function readSchemaModelFieldNames(modelName) {
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
const match = schemaSource.match(re);
if (!match) return [];
const fields = [];
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('@@')) continue;
if (line.startsWith('//')) continue;
const firstWord = line.split(/\s+/)[0];
if (firstWord) fields.push(firstWord);
}
return fields;
}
/**
* Aufgabe 1 (260911-fh9) — wie `readSchemaModelFieldNames`, aber laesst
* jedes Feld weg, dessen TYP (zweites Wort der Zeile, `?` und `[]`
* abgestreift) selbst der Name eines anderen `model` im Schema ist —
* Relationsfelder haben keine Spalte (Befund M aus 260911-e2s, dort fuer
* "Tenant" ueber die Migration umgangen; bei "User" ist die Spaltenmenge
* ueber drei Migrationen verteilt, deshalb hier der Weg ueber das Schema
* mit Relationsfilter). `Role` ist ein `enum`, kein `model`, und bleibt
* deshalb ein skalares Feld.
*/
function readSchemaModelScalarFieldNames(modelName) {
const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8');
const modelNames = new Set(
[...schemaSource.matchAll(/^model\s+(\w+)\s*\{/gm)].map((m) => m[1]),
);
const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`);
const match = schemaSource.match(re);
if (!match) return [];
const fields = [];
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('@@')) continue;
if (line.startsWith('//')) continue;
const parts = line.split(/\s+/);
const fieldName = parts[0];
if (!fieldName) continue;
const rawType = parts[1] ?? '';
const fieldType = rawType.replace(/\?$/, '').replace(/\[\]$/, '');
if (modelNames.has(fieldType)) continue; // Relationsfeld, keine Spalte
fields.push(fieldName);
}
return fields;
}
/**
* Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten
* Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS,
* an der Regel WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
* Werkzeug nachgetippt (vgl. runDkvAreaChecks/runDashboardAreaChecks).
*
* Prueft zur Laufzeit zusaetzlich die Messfalle aus 260910-jab: die
* `*_rls_widen_membership_grant_and_platform_read`-Migration (260910-jab)
* darf KEINE eigene Regel fuer "CalendarSource" enthalten (Befund G) —
* faende sich dort eine, waere der Regelstand nicht mehr eindeutig auf
* 20260909140000 zurueckzufuehren und dieser Abschnitt braeche ab, statt
* die abgeloeste Regel weiterzumessen.
*
* Legt die Wegwerf-Tabelle "CalendarSource" selbst neu an, mit SAEMTLICHEN
* Spalten des Modells (nicht nur denen, die Roh-SQL braucht — Pruefung 8,
* die dashboard-Lehre aus Pruefung 5b) und setzt auf keiner Tabelle eines
* anderen Abschnitts auf: er ist ein Blatt in der Aufrufkette, muss NACH
* runDashboardAreaChecks() und VOR runTransactionShapeMeasurement() laufen
* (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der von
* runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser Abschnitt
* aendert daran nichts, und keine spaetere Pruefung setzt auf der hier
* angelegten Tabelle auf.
*/
async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnCalendarSourcePolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource'));
report(
results,
'calendarsource-regelstand-eindeutig',
!widenHasOwnCalendarSourcePolicy,
widenHasOwnCalendarSourcePolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnCalendarSourcePolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const calendarSourcePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'CalendarSource')
: null;
if (!calendarSourcePolicy) {
report(
results,
'calendarsource-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "CalendarSource" 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 "CalendarSource" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
type text NOT NULL,
"exchangeMode" text,
domain text,
url text NOT NULL,
username text,
"encryptedPassword" text,
color text DEFAULT '#3B82F6',
"isVisible" boolean NOT NULL DEFAULT true,
"syncIntervalMin" integer NOT NULL DEFAULT 15,
"lastSyncAt" timestamp(3),
"lastSyncError" text,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(calendarSourcePolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "CalendarSource" TO ${SCRATCH_ROLE_NAME}`,
);
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen
// (Befund G — die Regel kennt keine Benutzerdimension), je mit
// gesetztem encryptedPassword-Platzhalter, damit Pruefung 3 zeigen
// kann, dass ein Kollege desselben Mandanten die verschluesselten
// Zugangsdaten sieht; eine Zeile unter TENANT-B fuer die
// Mandantengrenze.
await db.$executeRawUnsafe(`
INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url, "encryptedPassword", "isVisible") VALUES
('source-a1', 'user-a1', 'TENANT-A', 'Quelle A1', 'ics', 'https://example.invalid/a1.ics', 'enc(a1-passwort-platzhalter)', true),
('source-a2', 'user-a2', 'TENANT-A', 'Quelle A2', 'ics', 'https://example.invalid/a2.ics', 'enc(a2-passwort-platzhalter)', true),
('source-b1', 'user-b1', 'TENANT-B', 'Quelle B1', 'ics', 'https://example.invalid/b1.ics', 'enc(b1-passwort-platzhalter)', true);
`);
});
// Pruefung 8 zuerst — faellt sie durch, sind die Client-Messungen (9-12)
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
// sie fehlschlaegt.
const schemaFields = readSchemaModelFieldNames('CalendarSource');
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'CalendarSource'
`;
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model CalendarSource, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1 + 3: calendarsource-gebunden-nur-eigener-mandant UND
// calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
// eine Abfrage, zwei Aussagen. Pruefung 3 zu bestehen IST das erwartete
// Ergebnis: die Regel kennt keine Benutzerdimension.
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`,
);
report(
results,
'calendarsource-gebunden-nur-eigener-mandant',
rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`,
);
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
report(
results,
'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
a2CredentialsVisible,
`forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`,
);
// 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile.
const unboundRows = await prisma.$queryRaw`SELECT "tenantId" FROM "CalendarSource"`;
report(
results,
'calendarsource-ungebunden-null-zeilen',
unboundRows.length === 0,
`ungebundener SELECT auf "CalendarSource" liefert ${unboundRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
);
// 4: calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile
// — die Datenbankseite der drei Besitzpruefungen (Befund I).
const unboundSingleRow = await prisma.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
report(
results,
'calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile',
unboundSingleRow.length === 0,
`ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert ${unboundSingleRow.length} Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException`,
);
// 5: calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignInsertRejected = false;
let foreignInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES ('source-rejected', 'user-a1', 'TENANT-B', 'Sollte abgewiesen werden', 'ics', 'https://example.invalid/rejected.ics')`,
);
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignInsertRejected = sqlState === '42501';
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignInsertRejected,
foreignInsertDetail,
);
// 6: calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
const foreignDeleteAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`DELETE FROM "CalendarSource" WHERE id = 'source-b1'`,
);
const stillThereAfterDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`;
return rows.length === 1;
},
);
report(
results,
'calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
foreignDeleteAffected === 0 && stillThereAfterDelete,
`gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft ${foreignDeleteAffected} Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: ${stillThereAfterDelete}`,
);
// 7: calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
const foreignUpdateAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`UPDATE "CalendarSource" SET "lastSyncError" = 'x' WHERE id = 'source-b1'`,
);
report(
results,
'calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
foreignUpdateAffected === 0,
`gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft ${foreignUpdateAffected} Zeile(n), lastSyncError bleibt unveraendert`,
);
// 9: calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const clientBoundSources = await bound.calendarSource.findMany({
where: { userId: 'user-a1', isVisible: true },
});
report(
results,
'calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant',
clientBoundSources.length === 1 && clientBoundSources[0].id === 'source-a1',
`bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert ${clientBoundSources.length} Zeile(n): ${JSON.stringify(clientBoundSources.map((s) => s.id))} — die Abfrage, die fetchAndCacheEvents stellt`,
);
// 10: calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen
const clientUnboundSources = await prisma.calendarSource.findMany({
where: { userId: 'user-a1', isVisible: true },
});
report(
results,
'calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen',
clientUnboundSources.length === 0,
`dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert ${clientUnboundSources.length} Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht`,
);
// 11: calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut
let updateOnInvisibleThrew = false;
let updateOnInvisibleDetail = '';
try {
await bound.calendarSource.update({
where: { id: 'source-b1' },
data: { lastSyncError: 'x' },
});
updateOnInvisibleDetail =
'bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) ist NICHT fehlgeschlagen';
} catch (err) {
updateOnInvisibleThrew = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
updateOnInvisibleDetail = `bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
report(
results,
'calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut',
updateOnInvisibleThrew,
updateOnInvisibleDetail,
);
// 12: calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt
let createSucceeded = false;
let createDetail = '';
try {
const created = await bound.calendarSource.create({
data: {
userId: 'user-a1',
tenantId: 'TENANT-A',
name: 'Neu angelegte Quelle',
type: 'ics',
url: 'https://example.invalid/neu.ics',
},
});
const readBack = await bound.calendarSource.findMany({ where: { id: created.id } });
createSucceeded = readBack.length === 1;
createDetail = `bound.calendarSource.create unter TENANT-A gelingt (id=${created.id}, createdAt=${JSON.stringify(created.createdAt)}), gebunden lesbar: ${createSucceeded} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt`;
} catch (err) {
createDetail = `bound.calendarSource.create unter TENANT-A ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt',
createSucceeded,
createDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Liest die Migration `20260618112124_auth_multi_tenancy` (Dateiname endet
* auf "_auth_multi_tenancy") — die einzige, die `CREATE TABLE "Tenant"` und
* den Fremdschluessel `User_tenantId_fkey` enthaelt.
*/
function readAuthMultiTenancyMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_multi_tenancy'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Schneidet die Spaltennamen aus dem `CREATE TABLE "Tenant" ( ... );`-Block
* der Migration — NICHT aus `readSchemaModelFieldNames('Tenant')` (Befund M):
* `schema.prisma` fuehrt bei `Tenant` vier Relationsfelder (`users`,
* `ldapConfig`, `groups`, `moduleGrants`), die keine Spalten sind und die
* Client-Vergleichspruefung faelschlich durchfallen liessen.
*/
function readTenantCreateTableColumns(migrationSql) {
const match = migrationSql.match(/CREATE TABLE "Tenant" \(([\s\S]*?)\n\);/);
if (!match) return [];
const columns = [];
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('CONSTRAINT')) continue;
const m = line.match(/^"([a-zA-Z]+)"/);
if (m) columns.push(m[1]);
}
return columns;
}
/**
* Schneidet `ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" ...;`
* wortgleich aus der Migration — nicht getippt (Aufgabe 1, TEIL 1).
*/
function readUserTenantForeignKeySql(migrationSql) {
const match = migrationSql.match(
/ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey"[\s\S]*?;/,
);
return match ? match[0] : null;
}
/**
* Aufgabe 1 (260911-e2s) — misst die neun im Plan genannten
* Verhaltensweisen des Bereichs `tenant` unter der Rolle ohne BYPASSRLS. Auf
* `Tenant` selbst ist nichts zu binden (keine Regel in irgendeiner
* ausgelieferten Migration, einschliesslich `20260910120000_...` — Pruefung
* 1) — dieser Abschnitt hat trotzdem neun Pruefungen, weil drei der acht
* Zugriffsstellen des Controllers ueber eine Relationseinbindung
* (`include: { _count: { select: { users } } }`) in die GESCHUETZTE Tabelle
* "User" hineinzaehlen (Befund F).
*
* Setzt auf den bereits vorhandenen Wegwerf-Tabellen "Tenant" (aus
* `runUserAreaChecks`, dort nur `id`/`slug`) und "User" (aus
* `runAuthLookupChecks`, mit Zeilenschutz und wortgleicher Regel) auf und
* erweitert "Tenant" um die vier fehlenden Spalten sowie den Fremdschluessel
* `User_tenantId_fkey` — keine spaetere Pruefung setzt auf diesen
* Erweiterungen auf (Befund M: `runTransactionShapeMeasurement` und
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an). Muss deshalb
* NACH `runCalendarAreaChecks()` und VOR `runTransactionShapeMeasurement()`
* laufen (siehe Aufrufkette in main()).
*/
async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) {
// Pruefung 1: keine Regel auf "Tenant" in irgendeiner ausgelieferten
// Migration — liest jede Datei zur Laufzeit, statt der Dokumentation zu
// glauben.
const migrationDirNames = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const violatingMigrations = [];
for (const dirName of migrationDirNames) {
const sql = readFileSync(join(MIGRATIONS_DIR, dirName, 'migration.sql'), 'utf-8');
if (
/CREATE POLICY \w+ ON "Tenant"/.test(sql) ||
/ALTER TABLE "Tenant"/.test(sql)
) {
violatingMigrations.push(dirName);
}
}
const widenMigrationDirName = migrationDirNames.find((d) =>
d.endsWith('_rls_widen_membership_grant_and_platform_read'),
);
report(
results,
'tenant-keine-regel-in-allen-ausgelieferten-migrationen',
violatingMigrations.length === 0 && Boolean(widenMigrationDirName),
`${migrationDirNames.length} Migrationsverzeichnisse gelesen, darunter "${widenMigrationDirName ?? 'NICHT GEFUNDEN'}" — "Tenant" kommt darin nicht vor; ${violatingMigrations.length} Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": ${JSON.stringify(violatingMigrations)}`,
);
const authMultiTenancySql = readAuthMultiTenancyMigrationSql();
const tenantColumnsFromMigration = authMultiTenancySql
? readTenantCreateTableColumns(authMultiTenancySql)
: [];
const userTenantFkSql = authMultiTenancySql
? readUserTenantForeignKeySql(authMultiTenancySql)
: null;
if (!authMultiTenancySql || tenantColumnsFromMigration.length === 0 || !userTenantFkSql) {
report(
results,
'tenant-migration-auth-multi-tenancy-und-fremdschluessel-gefunden',
false,
`Migration *_auth_multi_tenancy=${Boolean(authMultiTenancySql)}, CREATE TABLE "Tenant"-Spalten=${tenantColumnsFromMigration.length}, User_tenantId_fkey gefunden=${Boolean(userTenantFkSql)}`,
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// (a) Vier fehlende Spalten, Typen aus dem ausgelieferten
// `CREATE TABLE "Tenant"` (20260618112124_auth_multi_tenancy).
// ABWEICHUNG: fuer "name" und "updatedAt" braucht das Nachruesten gegen
// die beiden bereits vorhandenen Zeilen (TENANT-A/TENANT-B, angelegt von
// runUserAreaChecks) einen DEFAULT, den die ausgelieferte Migration
// selbst nicht hat (dort NOT NULL ohne DEFAULT) — betrifft nur dieses
// Nachruesten hier, keine Aussage ueber den ausgelieferten Stand.
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "name" TEXT NOT NULL DEFAULT 'Platzhalter';`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
// (b) Fremdschluessel wortgleich aus der Migration geschnitten (oben),
// nicht getippt — die vier vorhandenen "User"-Zeilen referenzieren
// ausschliesslich TENANT-A/TENANT-B, beide existieren bereits.
await db.$executeRawUnsafe(userTenantFkSql);
// (c) Dritte Mandantenzeile ohne Benutzer.
await db.$executeRawUnsafe(
`INSERT INTO "Tenant" (id, slug, name) VALUES ('TENANT-C', 'tenant-c', 'Tenant C');`,
);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// Pruefung 2: gebunden (TENANT-A), ungebunden und ueber die Wartungsrolle
// liefern DIESELBEN drei Kennungen — der Beleg "nichts zu binden".
const boundIdsRaw = (
await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`)
).map((r) => r.id);
const unboundIdsRaw = (await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map(
(r) => r.id,
);
const adminIdsRaw = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => (await db.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map((r) => r.id),
);
const allIdenticalRaw =
JSON.stringify(boundIdsRaw) === JSON.stringify(unboundIdsRaw) &&
JSON.stringify(unboundIdsRaw) === JSON.stringify(adminIdsRaw);
report(
results,
'tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen',
allIdenticalRaw,
`Roh-SQL gebunden (TENANT-A): ${JSON.stringify(boundIdsRaw)}; ungebunden: ${JSON.stringify(unboundIdsRaw)}; Wartungsrolle: ${JSON.stringify(adminIdsRaw)}`,
);
// Pruefung 3 — steht VOR den Client-Pruefungen (4-9); faellt sie durch,
// bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich `calendar`).
const migrationColumnsSorted = [...tenantColumnsFromMigration].sort();
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'Tenant'
`;
return rows.map((r) => r.column_name).sort();
},
);
const columnsMatch =
migrationColumnsSorted.length > 0 &&
migrationColumnsSorted.length === tableColumns.length &&
migrationColumnsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (${migrationColumnsSorted.length}): ${JSON.stringify(migrationColumnsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
// Pruefung 4: derselbe Vergleich ueber den generierten Client.
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const clientBoundIds = (await bound.tenant.findMany({ orderBy: { id: 'asc' } })).map(
(t) => t.id,
);
const clientUnboundIds = (await prisma.tenant.findMany({ orderBy: { id: 'asc' } })).map(
(t) => t.id,
);
const clientIdsMatch =
JSON.stringify(clientBoundIds) === JSON.stringify(boundIdsRaw) &&
JSON.stringify(clientUnboundIds) === JSON.stringify(boundIdsRaw);
report(
results,
'tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch',
clientIdsMatch,
`generierter Client gebunden (TENANT-A): ${JSON.stringify(clientBoundIds)}; ungebunden: ${JSON.stringify(clientUnboundIds)}; Roh-SQL-Vergleichswert (Pruefung 2): ${JSON.stringify(boundIdsRaw)}`,
);
// Wartungszahl je Mandant, fuer Pruefung 5/6/9.
const adminUserCountRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`SELECT "tenantId", count(*)::int AS c FROM "User" GROUP BY "tenantId"`,
);
const adminUserCounts = new Map(adminUserCountRows.map((r) => [r.tenantId, r.c]));
// Pruefung 5 — die tragende Belegzeile: die Abfrage, die `findAll`
// heute stellt, UNGEBUNDEN auf dem generierten Client: jeder Zaehler ist
// 0, waehrend die Wartungsrolle je Mandant mehr als 0 zaehlt.
const clientUnboundWithCounts = await prisma.tenant.findMany({
include: { _count: { select: { users: true } } },
orderBy: { id: 'asc' },
});
const allUnboundCountsZero = clientUnboundWithCounts.every((t) => t._count.users === 0);
const groundTruthHasPositiveCounts = ['TENANT-A', 'TENANT-B'].every(
(id) => (adminUserCounts.get(id) ?? 0) > 0,
);
report(
results,
'tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten',
allUnboundCountsZero && groundTruthHasPositiveCounts,
`das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: ${JSON.stringify(clientUnboundWithCounts.map((t) => ({ id: t.id, userCount: t._count.users })))}; Wartungszahl je Mandant: ${JSON.stringify(Object.fromEntries(adminUserCounts))}`,
);
// Pruefung 6: dieselbe Abfrage gebunden unter TENANT-A.
const clientBoundWithCounts = await bound.tenant.findMany({
include: { _count: { select: { users: true } } },
orderBy: { id: 'asc' },
});
const countByIdBound = new Map(clientBoundWithCounts.map((t) => [t.id, t._count.users]));
const boundCountsMatchExpectation =
countByIdBound.get('TENANT-A') === adminUserCounts.get('TENANT-A') &&
countByIdBound.get('TENANT-B') === 0 &&
countByIdBound.get('TENANT-C') === 0 &&
(adminUserCounts.get('TENANT-B') ?? 0) > 0;
report(
results,
'tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant',
boundCountsMatchExpectation,
`gebunden unter TENANT-A: A=${countByIdBound.get('TENANT-A')} (Wartungszahl=${adminUserCounts.get('TENANT-A')}), B=${countByIdBound.get('TENANT-B')} (Wartungszahl=${adminUserCounts.get('TENANT-B') ?? 0}), C=${countByIdBound.get('TENANT-C')}`,
);
// Pruefung 7 — die Abfrage, die `remove` heute stellt, ungebunden:
// Zaehler 0 trotz aktiver Benutzer bei der Wartungsrolle, der Riegel
// T-02-09 liesse das Loeschen durch; das anschliessende ungebundene
// `delete` ueber den generierten Client scheitert LAUT am
// Fremdschluessel, der den Zeilenschutz umgeht.
const removeQueryUnbound = await prisma.tenant.findUnique({
where: { id: 'TENANT-A' },
include: { _count: { select: { users: { where: { isActive: true } } } } },
});
const unboundActiveCount = removeQueryUnbound?._count.users;
const adminActiveCountA = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows =
await db.$queryRaw`SELECT count(*)::int AS c FROM "User" WHERE "tenantId" = 'TENANT-A' AND "isActive" = true`;
return rows[0].c;
},
);
const gateWouldPassThrough = unboundActiveCount === 0 && adminActiveCountA > 0;
let deleteThrew = false;
let deleteErrCtor = 'unbekannt';
let deleteErrCode;
let deleteErrMessage = '';
try {
await prisma.tenant.delete({ where: { id: 'TENANT-A' } });
} catch (err) {
deleteThrew = true;
deleteErrCtor = err?.constructor?.name ?? 'unbekannt';
deleteErrCode = err?.code;
deleteErrMessage = (err.message ?? '').toString().trim();
}
const stillExistsAfterDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-A'`;
return rows.length === 1;
},
);
report(
results,
'tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut',
gateWouldPassThrough && deleteThrew && stillExistsAfterDelete,
`ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=${unboundActiveCount}, Wartungszahl=${adminActiveCountA} — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft ${deleteErrCtor}${deleteErrCode ? ` (code ${deleteErrCode})` : ''}: ${deleteErrMessage} — die Zeile existiert ueber die Wartungsrolle danach noch: ${stillExistsAfterDelete}; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab`,
);
// Pruefung 8 — dasselbe Loeschen fuer TENANT-C (ohne Benutzer) gelingt:
// falsifiziert "Loeschen scheitert immer".
let deleteCSucceeded = false;
let deleteCDetail = '';
try {
await prisma.tenant.delete({ where: { id: 'TENANT-C' } });
deleteCSucceeded = true;
deleteCDetail =
'ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen';
} catch (err) {
deleteCDetail = `ungebundenes prisma.tenant.delete fuer TENANT-C ist unerwartet fehlgeschlagen: ${err.message}`;
}
const goneAfterDeleteC = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-C'`;
return rows.length === 0;
},
);
report(
results,
'tenant-loeschen-ohne-benutzer-gelingt-wie-heute',
deleteCSucceeded && goneAfterDeleteC,
`${deleteCDetail}; ueber die Wartungsrolle danach noch vorhanden: ${!goneAfterDeleteC}`,
);
// Pruefung 9 — die Form, die Aufgabe 3 einbaut: `prisma.tenant.findMany`
// ungebunden als Treiber, dann je VERBLIEBENEM Mandanten (TENANT-C ist
// seit Pruefung 8 geloescht) ein gebundener Zaehlaufruf.
const remainingTenants = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
const fanOutResults = [];
let fanOutAllMatch = remainingTenants.length > 0;
for (const t of remainingTenants) {
const boundForT = buildInlineExtendedClient(prisma, t.id);
const count = await boundForT.user.count({ where: { tenantId: t.id } });
const adminCount = adminUserCounts.get(t.id) ?? 0;
fanOutResults.push({ id: t.id, gebunden: count, wartung: adminCount });
if (count !== adminCount) fanOutAllMatch = false;
}
report(
results,
'tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt',
fanOutAllMatch,
`Fan-out je verbliebenem Mandanten (${remainingTenants.length}): ${JSON.stringify(fanOutResults)}`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260911-fh9) — misst die Grenze zwischen Anmeldeweg (die drei
* SECURITY-DEFINER-Funktionen, gemessen in `runAuthLookupChecks`) und
* Nach-Anmeldung (dieser Abschnitt, gebundener Modellzugriff ueber den
* GENERIERTEN Client) fuer die drei Methoden `getMe`, `changePassword`,
* `adminResetPassword`. Ausdruecklich GETRENNT von `runAuthLookupChecks`:
* jener misst den Anmeldeweg VOR bekanntem Mandanten (Funktionen), dieser
* die drei Methoden NACH der Anmeldung (gebundener Modellzugriff) — die
* Grenze, die dieser Plan festnagelt.
*
* Setzt auf der vorhandenen Wegwerf-Tabelle "User" auf (aus
* `runAuthLookupChecks`, mit eingeschaltetem und erzwungenem Zeilenschutz,
* wortgleicher Policy, zwei Zeilen in zwei Mandanten; seit
* `runTenantAreaChecks` zusaetzlich mit Fremdschluessel auf "Tenant") und
* auf der bereits eingespielten Funktions-Migration; erweitert "User" um die
* fuenf im generierten Client fehlenden Spalten (Befund G). Keine spaetere
* Pruefung setzt auf diesen Erweiterungen auf (`runTransactionShapeMeasurement`/
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an, Befund M aus
* 260911-e2s) — dieser Abschnitt ist ein Blatt in der Aufrufkette und muss
* deshalb NACH `runTenantAreaChecks()` und VOR
* `runTransactionShapeMeasurement()` laufen (siehe Aufrufkette in `main()`).
*/
async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
// Vorbereitung ueber die Wartungsrolle: die fuenf im generierten Client
// fehlenden Spalten nachruesten (Befund G). Typen aus den drei
// ausgelieferten Migrationen (20260618112124_auth_multi_tenancy,
// 20260630095533_add_user_avatar, 20260702000000_add_user_accent_color).
// ABWEICHUNG: "updatedAt" bekommt fuer die zwei bereits vorhandenen
// Wegwerf-Zeilen (user-a/user-b aus runAuthLookupChecks) eine Vorgabe
// CURRENT_TIMESTAMP, die die ausgelieferte Migration nicht hat (dort NOT
// NULL ohne DEFAULT — Prisma setzt den Wert clientseitig ueber
// `@updatedAt`) — betrifft nur dieses Nachruesten hier, keine Aussage
// ueber den ausgelieferten Stand.
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(
`ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "User" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3);`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "avatarPath" TEXT;`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN "accentColor" TEXT;`);
});
// Pruefung — steht VOR den Client-Pruefungen (4-10 unten); faellt sie
// durch, bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich
// `calendar`/Pruefung 3 im Bereich `tenant`).
const schemaFields = readSchemaModelScalarFieldNames('User');
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'User'
`;
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
// Pruefung 1: pg_proc-Messung der drei Anmeldefunktionen — die
// Datenbankseite von "nichts an der Anordnung angefasst" (Befund H).
const functionRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`
SELECT proname, prosecdef, provolatile, proconfig, pg_get_functiondef(oid) AS def
FROM pg_proc WHERE proname LIKE 'auth_lookup_%' ORDER BY proname
`,
);
const expectedFunctionNames = [
'auth_lookup_reset_token',
'auth_lookup_user_by_email',
'auth_lookup_user_by_username',
];
const actualFunctionNames = functionRows.map((r) => r.proname).sort();
const perFunctionProps = functionRows.map((r) => ({
proname: r.proname,
prosecdef: r.prosecdef,
provolatile: r.provolatile,
proconfig: r.proconfig,
hatLimit1: typeof r.def === 'string' && r.def.includes('LIMIT 1'),
}));
const allSecurityDefinerStableFixedSearchPathLimit1 = perFunctionProps.every(
(p) =>
p.prosecdef === true &&
p.provolatile === 's' &&
Array.isArray(p.proconfig) &&
p.proconfig.some((c) => String(c).replace(/\s+/g, '') === 'search_path=public,pg_temp') &&
p.hatLimit1,
);
const functionsOk =
functionRows.length === 3 &&
JSON.stringify(actualFunctionNames) === JSON.stringify(expectedFunctionNames) &&
allSecurityDefinerStableFixedSearchPathLimit1;
report(
results,
'auth-anmeldefunktionen-security-definer-unveraendert',
functionsOk,
`${functionRows.length} Funktion(en) unter 'auth_lookup_%' gefunden: ${JSON.stringify(actualFunctionNames)}; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: ${JSON.stringify(perFunctionProps)}`,
);
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// Pruefung 3: die Anmeldesuche findet den Benutzer weiterhin, mit dem
// FESTEN Spaltensatz der Funktion — die Spaltenerweiterung oben laesst
// weder avatarPath noch accentColor noch email durch.
const lookupRows = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`;
const lookupKeys = lookupRows.length === 1 ? Object.keys(lookupRows[0]).sort() : [];
const lookupOk =
lookupRows.length === 1 &&
lookupKeys.length === 9 &&
!lookupKeys.includes('avatarPath') &&
!lookupKeys.includes('accentColor') &&
!lookupKeys.includes('email');
report(
results,
'auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz',
lookupOk,
`auth_lookup_user_by_username('alice') liefert ${lookupRows.length} Zeile(n) mit ${lookupKeys.length} Schluessel(n): ${JSON.stringify(lookupKeys)} — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch`,
);
// Die tragende Belegzeile: getMe() als Client-Form, UNGEBUNDEN.
const getMeSelect = {
id: true,
username: true,
displayName: true,
role: true,
tenantId: true,
mustChangePassword: true,
passwordHash: true,
ldapDn: true,
avatarPath: true,
accentColor: true,
};
const unboundGetMe = await prisma.user.findUnique({
where: { id: 'user-a' },
select: getMeSelect,
});
report(
results,
'auth-getme-generierter-client-ungebunden-liefert-null',
unboundGetMe === null,
`ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert ${JSON.stringify(unboundGetMe)} — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert`,
);
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundGetMeOwn = await boundA.user.findUnique({
where: { id: 'user-a' },
select: getMeSelect,
});
const ownTenantOk =
Boolean(boundGetMeOwn) &&
boundGetMeOwn.tenantId === 'TENANT-A' &&
Object.keys(boundGetMeOwn).sort().length === 10;
report(
results,
'auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer',
ownTenantOk,
`gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): ${JSON.stringify(boundGetMeOwn)}`,
);
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const boundGetMeForeign = await boundB.user.findUnique({
where: { id: 'user-a' },
select: getMeSelect,
});
report(
results,
'auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null',
boundGetMeForeign === null,
`gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): ${JSON.stringify(boundGetMeForeign)} — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01`,
);
// Die Schreibform, die changePassword heute stellt: UNGEBUNDEN.
let ungebundenesUpdateWarf = false;
let ungebundenesUpdateCtor = 'unbekannt';
let ungebundenesUpdateCode;
let ungebundenesUpdateMessage = '';
try {
await prisma.user.update({
where: { id: 'user-a' },
data: { passwordHash: 'hash-a-neu-ungebunden', mustChangePassword: false },
});
} catch (err) {
ungebundenesUpdateWarf = true;
ungebundenesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
ungebundenesUpdateCode = err?.code;
ungebundenesUpdateMessage = (err.message ?? '').toString().trim();
}
const passwordHashNachUngebundenemVersuch = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
return rows[0]?.passwordHash;
},
);
report(
results,
'auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut',
ungebundenesUpdateWarf && passwordHashNachUngebundenemVersuch === 'hash-a',
`ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft ${ungebundenesUpdateCtor}${ungebundenesUpdateCode ? ` (code ${ungebundenesUpdateCode})` : ''}: ${ungebundenesUpdateMessage} — die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachUngebundenemVersuch)}`,
);
// Dasselbe update, GEBUNDEN unter TENANT-A (eigener Mandant): gelingt.
await boundA.user.update({
where: { id: 'user-a' },
data: { passwordHash: 'hash-a-neu', mustChangePassword: false },
});
const nachGebundenemUpdate = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows =
await db.$queryRaw`SELECT "passwordHash", "updatedAt" FROM "User" WHERE id = 'user-a'`;
return rows[0];
},
);
report(
results,
'auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt',
nachGebundenemUpdate?.passwordHash === 'hash-a-neu' && nachGebundenemUpdate?.updatedAt != null,
`gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash=${JSON.stringify(nachGebundenemUpdate?.passwordHash)}, updatedAt=${JSON.stringify(nachGebundenemUpdate?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt`,
);
// Dieselbe Schreibform, GEBUNDEN unter TENANT-B (fremder Mandant): die
// Form, die adminResetPassword fuer ein fremdmandantiges Ziel stellt.
let fremdesUpdateWarf = false;
let fremdesUpdateCtor = 'unbekannt';
let fremdesUpdateCode;
let fremdesUpdateMessage = '';
try {
await boundB.user.update({
where: { id: 'user-a' },
data: { passwordHash: 'hash-a-fremd' },
});
} catch (err) {
fremdesUpdateWarf = true;
fremdesUpdateCtor = err?.constructor?.name ?? 'unbekannt';
fremdesUpdateCode = err?.code;
fremdesUpdateMessage = (err.message ?? '').toString().trim();
}
const passwordHashNachFremdemVersuch = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT "passwordHash" FROM "User" WHERE id = 'user-a'`;
return rows[0]?.passwordHash;
},
);
report(
results,
'auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut',
fremdesUpdateWarf && passwordHashNachFremdemVersuch === 'hash-a-neu',
`gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) ${fremdesUpdateCtor}${fremdesUpdateCode ? ` (code ${fremdesUpdateCode})` : ''}: ${fremdesUpdateMessage} — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash=${JSON.stringify(passwordHashNachFremdemVersuch)}`,
);
// Fan-out je Mandant, gebunden: die Form, die Aufgabe 2/3 fuer die
// oberste Rolle (SUPER_ADMIN) benutzt — Tenant ungebunden als Treiber
// (Tenant ohne Regel, gemessen in runTenantAreaChecks), je Mandant EIN
// gebundener findUnique.
const tenantsForFanOut = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
let fanOutHit = null;
let fanOutTenantId = null;
for (const t of tenantsForFanOut) {
const boundForT = buildInlineExtendedClient(prisma, t.id);
const hit = await boundForT.user.findUnique({ where: { id: 'user-a' } });
if (hit) {
fanOutHit = hit;
fanOutTenantId = t.id;
break;
}
}
report(
results,
'auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf',
Boolean(fanOutHit) && fanOutTenantId === 'TENANT-A' && fanOutHit.tenantId === 'TENANT-A',
`Fan-out ueber ${tenantsForFanOut.length} Mandant(en): 'user-a' gefunden unter ${JSON.stringify(fanOutTenantId)}, tenantId der Zeile=${JSON.stringify(fanOutHit?.tenantId)} — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen
* des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
* Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks).
*
* Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten
* des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert
* das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die
* falsche Menge) und traegt DEN Fremdschluessel auf die von
* runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance"
* (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme
* unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur
* behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf
* keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der
* Aufrufkette, muss NACH runAuthAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()).
*/
async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnFavoriteLinkPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
report(
results,
'favoritelink-regelstand-eindeutig',
!widenHasOwnFavoriteLinkPolicy,
widenHasOwnFavoriteLinkPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnFavoriteLinkPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const favoriteLinkPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
: null;
if (!favoriteLinkPolicy) {
report(
results,
'favoritelink-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
// Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"-
// Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese
// Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A,
// widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B).
const widgetInstanceRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`;
return rows;
},
);
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung
// (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht
// trotzdem einen Wert) — Form von 260911-e2s/fh9.
await db.$executeRawUnsafe(`
CREATE TABLE "FavoriteLink" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetId" text NOT NULL,
title text NOT NULL,
url text NOT NULL,
"iconUrl" text,
position integer NOT NULL DEFAULT 0,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(favoriteLinkPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`,
);
// fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1
// gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form);
// fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine
// iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs).
await db.$executeRawUnsafe(`
INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES
('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0),
('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1),
('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0),
('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0);
`);
});
// Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8)
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
// sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`).
const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink');
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'FavoriteLink'
`;
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`,
);
if (!columnsMatch) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste
// — die tragende Belegzeile dieses Abschnitts.
const unboundList = await prisma.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
report(
results,
'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste',
unboundList.length === 0,
`ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`,
);
// 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen
// — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die
// Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber
// ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar).
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwnList = await bound.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
const ownListOk =
boundOwnList.length === 2 &&
boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } });
const colleagueVisible =
boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2';
report(
results,
'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
ownListOk && colleagueVisible,
`bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`,
);
// 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
// — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02).
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } });
report(
results,
'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignFind === null,
`bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`,
);
// 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut
// — der generierte Client meldet null getroffene Zeilen bei delete anders
// als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code
// woertlich, das Ergebnis wird nicht vorweggenommen.
let foreignDeleteThrew = false;
let foreignDeleteDetail = '';
try {
await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } });
foreignDeleteDetail =
'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen';
} catch (err) {
foreignDeleteThrew = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
const stillThereAfterForeignDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`;
return rows.length === 1;
},
);
report(
results,
'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut',
foreignDeleteThrew && stillThereAfterForeignDelete,
`${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`,
);
// 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST,
// ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel
// dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten:
// referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis
// steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite
// Haelfte derselben Pruefung: ein gebundenes create mit einer
// WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der
// Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
const widgetB1UnderA = await bound.widgetInstance.findUnique({
where: { id: 'widget-b1' },
select: { userId: true },
});
let foreignWidgetCreateSucceeded = false;
let foreignWidgetCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-fremdes-widget',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-b1',
title: 'Fremdes Widget',
url: 'https://example.invalid/fremd',
position: 0,
},
});
foreignWidgetCreateSucceeded = Boolean(created);
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`;
// Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die
// folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen.
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`),
);
} catch (err) {
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
let missingWidgetCreateRejected = false;
let missingWidgetCreateDetail = '';
try {
await bound.favoriteLink.create({
data: {
id: 'fav-a1-widget-fehlt',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-gibt-es-nicht',
title: 'Widget fehlt',
url: 'https://example.invalid/fehlt',
position: 0,
},
});
missingWidgetCreateDetail =
'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen';
} catch (err) {
missingWidgetCreateRejected = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`;
}
report(
results,
'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei',
missingWidgetCreateRejected,
`${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`,
);
// 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt
let ownCreateSucceeded = false;
let ownCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-neu',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-a1',
title: 'Neu angelegter Favorit',
url: 'https://example.invalid/neu',
position: 2,
},
});
const readBack = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`,
);
ownCreateSucceeded =
readBack.length === 1 &&
readBack[0].tenantId === 'TENANT-A' &&
readBack[0].createdAt != null &&
readBack[0].updatedAt != null;
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`;
} catch (err) {
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt',
ownCreateSucceeded,
ownCreateDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen
* des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die
* Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells
* an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH
* aus `20260629130000_add_missing_tables` — ohne diesen Index misst
* Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht
* nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH
* runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen.
*/
async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnSmtpConfigPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig'));
report(
results,
'smtpconfig-regelstand-eindeutig',
!widenHasOwnSmtpConfigPolicy,
widenHasOwnSmtpConfigPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
: 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnSmtpConfigPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const smtpConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SmtpConfig')
: null;
if (!smtpConfigPolicy) {
report(
results,
'smtpconfig-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "SmtpConfig" 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 "SmtpConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
host text NOT NULL,
port integer NOT NULL DEFAULT 587,
encryption text NOT NULL DEFAULT 'starttls',
username text,
"encryptedPassword" text,
"fromAddress" text NOT NULL,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
// WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index
// misst Pruefung 8 nichts.
await db.$executeRawUnsafe(
`CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`,
);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(smtpConfigPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(`
INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES
('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'),
('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid');
`);
});
// Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`.
const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig');
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'SmtpConfig'
`;
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
const indexRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key'
`;
return rows;
},
);
const indexOk = indexRows.length === 1;
report(
results,
'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch && indexOk,
`Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`,
);
if (!columnsMatch || !indexOk) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null —
// die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung.
const unboundStartup = await prisma.smtpConfig.findFirst();
report(
results,
'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null',
unboundStartup === null,
`ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`,
);
// 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile —
// die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert
// eine beliebige, aber vorhandene Zeile.
const adminStartupRow = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`;
return rows[0];
},
);
report(
results,
'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile',
Boolean(adminStartupRow),
`dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`,
);
// 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null —
// Befund K, der Versandpfad.
const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null',
unboundSendPath === null,
`ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`,
);
// 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten',
Boolean(boundOwn) &&
boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' &&
boundOwn.host === 'smtp-a.example.invalid' &&
boundOwn.fromAddress === 'a@example.invalid',
`gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`,
);
// 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null
// — T-GWH-01.
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignSend === null,
`gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`,
);
// 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut
// — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code
// woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe
// haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat.
let conflictThrew = false;
let conflictCtor = 'unbekannt';
let conflictCode;
let conflictMessage = '';
try {
await prisma.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu',
tenantId: 'TENANT-A',
host: 'smtp-a-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-neu.example.invalid' },
});
} catch (err) {
conflictThrew = true;
conflictCtor = err?.constructor?.name ?? 'unbekannt';
conflictCode = err?.code;
conflictMessage = (err.message ?? '').toString().trim();
}
const hostAfterConflict = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`;
return rows[0]?.host;
},
);
report(
results,
'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut',
conflictThrew && hostAfterConflict === 'smtp-a.example.invalid',
`ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`,
);
// 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert
let boundUpsertSucceeded = false;
let boundUpsertDetail = '';
try {
await boundA.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu-2',
tenantId: 'TENANT-A',
host: 'smtp-a-gebunden-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-gebunden-neu.example.invalid' },
});
const afterBoundUpsert = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`;
return rows[0];
},
);
const bUnchanged = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`;
return rows[0]?.host;
},
);
boundUpsertSucceeded =
afterBoundUpsert?.id === 'smtp-a' &&
afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' &&
afterBoundUpsert?.updatedAt != null &&
bUnchanged === 'smtp-b.example.invalid';
boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`;
} catch (err) {
boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert',
boundUpsertSucceeded,
boundUpsertDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Innere Tabellenroutine, wiederverwendet fuer alle zehn persoenlichen
* Tabellen der Benutzerdimension (Etappe 3b, 260911-nke). Jede Tabelle
* bekommt IHRE EIGENE, VOLLSTAENDIGE Wegwerf-Tabelle unter ihrem
* Produktionsnamen (`DROP TABLE IF EXISTS` davor — andere Bereichsfunktionen
* legen Tabellen desselben Namens mit REDUZIERTEN Spalten fuer reine
* Roh-SQL-Messungen an; dieser Abschnitt braucht den VOLLEN Spaltensatz fuer
* den GENERIERTEN Client und laeuft deshalb nach allen anderen
* Bereichsabschnitten). Misst je Tabelle vier Wahrheiten ueber den
* generierten Client: Benutzer A sieht die eigene Zeile; Benutzer A sieht
* die Zeile eines Kollegen DESSELBEN Mandanten nicht; ein Aufruf OHNE
* Benutzer sieht beide; ein Schreiben als Benutzer A mit der Kennung von B
* wird von der Datenbank abgewiesen (SQLSTATE 42501).
*/
async function runSingleRulePersonalTableCheck(config) {
const {
adminUrl,
scratchRoleUrl,
results,
slug,
tableName,
modelName,
userDimensionMigrationSql,
createTableSql,
seedSql,
ownRowId,
colleagueRowId,
createAttempt,
} = config;
const policySql = extractPolicySql(userDimensionMigrationSql, tableName);
if (!policySql) {
report(
results,
`${slug}-policy-aus-migration-gefunden`,
false,
`CREATE POLICY tenant_isolation_policy ON "${tableName}" nicht in der Benutzerdimension-Migration (20260911120000) gefunden`,
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`);
await db.$executeRawUnsafe(createTableSql);
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(policySql);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(seedSql);
});
// Pruefung 1 zuerst — Spaltenvergleich gegen schema.prisma (260910-krx-
// Falle): faellt sie durch, sind die Client-Messungen darunter wertlos.
const schemaFields = readSchemaModelScalarFieldNames(modelName);
const tableColumns = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRawUnsafe(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`,
);
return rows.map((r) => r.column_name).sort();
},
);
const schemaFieldsSorted = [...schemaFields].sort();
const columnsMatch =
schemaFieldsSorted.length > 0 &&
schemaFieldsSorted.length === tableColumns.length &&
schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
`${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`,
columnsMatch,
`Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1);
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1');
const ownRow = await boundA1[modelAccessor].findUnique({ where: { id: ownRowId } });
report(
results,
`${slug}-benutzer-a-sieht-eigene-zeile`,
Boolean(ownRow) && ownRow.id === ownRowId,
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${ownRowId}' }) liefert ${JSON.stringify(ownRow)}`,
);
const colleagueRow = await boundA1[modelAccessor].findUnique({ where: { id: colleagueRowId } });
report(
results,
`${slug}-benutzer-a-sieht-kollegen-nicht`,
colleagueRow === null,
`bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${colleagueRowId}' }) (gehoert user-a2) liefert ${JSON.stringify(colleagueRow)}`,
);
const boundNoUser = buildInlineExtendedClient(prisma, 'TENANT-A');
const bothRows = await boundNoUser[modelAccessor].findMany({
where: { id: { in: [ownRowId, colleagueRowId] } },
});
report(
results,
`${slug}-ohne-benutzer-sieht-beide`,
bothRows.length === 2,
`bound(TENANT-A, ohne Benutzer).${modelAccessor}.findMany liefert ${bothRows.length} Zeile(n): ${JSON.stringify(bothRows.map((r) => r.id))} — gewollte Eigenschaft der IS-NULL-Form fuer Admin/Hintergrunddienst`,
);
let writeRejected = false;
let writeDetail = '';
try {
await boundA1[modelAccessor].create({
data: {
id: createAttempt.id,
userId: 'user-a2',
tenantId: 'TENANT-A',
...createAttempt.extraData,
},
});
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' ist NICHT fehlgeschlagen`;
} catch (err) {
const sqlState = sqlStateOf(err);
const ctor = err?.constructor?.name ?? 'unbekannt';
writeRejected = sqlState === '42501';
writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim()}`;
}
report(results, `${slug}-schreiben-als-a-mit-kennung-b-abgelehnt`, writeRejected, writeDetail);
} finally {
await prisma.$disconnect();
}
}
/**
* Etappe 3b (260911-nke) — misst die Benutzerdimension der zehn
* persoenlichen Tabellen ueber den GENERIERTEN Client. Aufgabe 1: nur
* "TenderSavedSearch"; die uebrigen neun folgen in Aufgabe 2 ueber
* dieselbe innere Routine (`runSingleRulePersonalTableCheck`).
*/
async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) {
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
if (!userDimensionMigrationSql) {
report(
results,
'user-dimension-migration-gefunden',
false,
'Migration "_rls_user_dimension_personal_tables" nicht gefunden',
);
return;
}
await runSingleRulePersonalTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendersavedsearch',
tableName: 'TenderSavedSearch',
modelName: 'TenderSavedSearch',
userDimensionMigrationSql,
createTableSql: `
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
filters jsonb NOT NULL DEFAULT '{}',
"instantAlert" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", name)
);
`,
seedSql: `
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) 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', '{}');
`,
ownRowId: 'ss-a1',
colleagueRowId: 'ss-a2',
createAttempt: { id: 'ss-a-schreibversuch', extraData: { name: 'Schreibversuch', filters: {} } },
});
}
/**
* 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.
*/
/**
* Spiegelbildlich zu `forTenant()` in apps/api/src/prisma/prisma-tenant.extension.ts
* — bei jeder Aenderung dort HIER nachziehen. Seit Etappe 3b (260911-nke)
* optionaler dritter Parameter `userId`: sendet dieselbe Ein-Anweisungs-Form
* wie der Helfer (beide `set_config` kommasepariert in EINER getaggten
* Anweisung, Leerstring ohne Benutzer).
*/
function buildInlineExtendedClient(prisma, tenantId, userId) {
return prisma.$extends({
query: {
$allOperations({ args, query }) {
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
return prisma.$transaction([setContext, 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 runSearchProviderAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runUserDimensionChecks(adminUrl, 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);
});