9782bea1b2
- rls-scratch-check.mjs: dreizehnter Abschnitt runAuthAreaChecks, getrennt von runAuthLookupChecks — misst die Grenze zwischen Anmeldeweg (drei SECURITY-DEFINER-Funktionen, unveraendert) und Nach-Anmeldung (getMe, changePassword, adminResetPassword) ueber den generierten Client; neuer Helfer readSchemaModelScalarFieldNames() filtert Relationsfelder ueber ihren Typ heraus - zehn neue, namentlich benannte Pruefungen (120/120 insgesamt), sechs davon ueber den generierten Client an der auf 15 Spalten erweiterten Wegwerf-Tabelle "User"; pg_proc bestaetigt SECURITY DEFINER/STABLE/festen Suchpfad/LIMIT 1 fuer alle drei Anmeldefunktionen - docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt "## Bereich auth" (h1)-(h5) vor "## Verweis" — Signaltabelle je Pfad, getMe-Kette als "verschluckt" statt "laut", Etappe-3-Vorbehalt, Schwesterweg-Luecke in user.controller.ts Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
4038 lines
185 KiB
JavaScript
4038 lines
185 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')`,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function teardownScratchDatabase(adminUrl) {
|
|
await withAdminPrisma(adminUrl, async (admin) => {
|
|
await admin.$executeRawUnsafe(
|
|
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`,
|
|
);
|
|
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`);
|
|
await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`);
|
|
});
|
|
}
|
|
|
|
function report(results, kennung, passed, detail) {
|
|
const status = passed ? 'bestanden' : 'FEHLGESCHLAGEN';
|
|
console.log(`${kennung}: ${status} — ${detail}`);
|
|
results.push({ kennung, passed, detail });
|
|
}
|
|
|
|
/**
|
|
* Repliziert exakt das reparierte forTenant()-Muster aus
|
|
* apps/api/src/prisma/prisma-tenant.extension.ts: set_config und die
|
|
* eigentliche Abfrage als Array-Form von $transaction, also auf einer
|
|
* gemeinsamen Verbindung.
|
|
*/
|
|
async function forTenantQuery(prisma, tenantId, queryFn) {
|
|
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
|
const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von
|
|
* forTenant() unter der Rolle ohne BYPASSRLS.
|
|
*/
|
|
async function runForTenantChecks(scratchRoleUrl, results) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
|
|
try {
|
|
// 1+2: gleiche Verbindung UND gesetzter Kontext — als zwei Teilmessungen
|
|
// einer einzigen Array-Transaktion, im selben Format wie die urspruengliche
|
|
// Fehlerreproduktion (Backend-PID beim set_config-Schritt vs. Backend-PID
|
|
// bei der eigentlichen Abfrage; siehe Kopfkommentar von
|
|
// prisma-tenant.extension.ts: "inside tx"/"actual qry").
|
|
const [setStepRow, queryStepRow] = await prisma.$transaction([
|
|
prisma.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', 'TENANT-A', true) AS applied`,
|
|
prisma.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`,
|
|
]).then(([setRows, queryRows]) => [setRows[0], queryRows[0]]);
|
|
|
|
report(
|
|
results,
|
|
'gleiche-backend-verbindung',
|
|
setStepRow.pid === queryStepRow.pid,
|
|
`set_config-Schritt pg_backend_pid()=${setStepRow.pid}, Abfrage-Schritt pg_backend_pid()=${queryStepRow.pid}`,
|
|
);
|
|
report(
|
|
results,
|
|
'mandantenkontext-waehrend-abfrage-gesetzt',
|
|
queryStepRow.t === 'TENANT-A',
|
|
`current_tenant_id() waehrend der eigentlichen Abfrage=${JSON.stringify(queryStepRow.t)}`,
|
|
);
|
|
|
|
// 3+4: forTenant(A) liefert ausschliesslich Zeilen von A, keine von B.
|
|
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM probe ORDER BY id`,
|
|
);
|
|
const onlyA = rowsForA.length > 0 && rowsForA.every((r) => r.tenantId === 'TENANT-A');
|
|
report(
|
|
results,
|
|
'nur-eigene-mandanten-zeilen',
|
|
onlyA,
|
|
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
const leaksB = rowsForA.some((r) => r.tenantId === 'TENANT-B');
|
|
report(
|
|
results,
|
|
'keine-fremdmandanten-zeilen',
|
|
!leaksB,
|
|
leaksB ? 'Zeile von TENANT-B sichtbar unter forTenant(TENANT-A)' : 'keine Zeile von TENANT-B sichtbar',
|
|
);
|
|
|
|
// 5: ungebundener Zugriff derselben Rolle liefert null Zeilen.
|
|
const unbound = await prisma.$queryRaw`SELECT "tenantId" FROM probe`;
|
|
report(
|
|
results,
|
|
'ungebunden-liefert-null-zeilen',
|
|
unbound.length === 0,
|
|
`ungebundener SELECT liefert ${unbound.length} Zeile(n)`,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
function readAuthLookupMigrationSql() {
|
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_lookup_functions'))
|
|
.map((entry) => entry.name);
|
|
if (dirs.length !== 1) return null;
|
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Aufgabe 2 — spielt die auth_lookup_*-Migration in die Wegwerf-Datenbank
|
|
* ein (mit tessera_app durch die Wegwerf-Rolle ersetzt), legt zwei Benutzer
|
|
* in zwei Mandanten an und misst unter der Rolle ohne BYPASSRLS:
|
|
* Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf "User"
|
|
* liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts.
|
|
*/
|
|
async function runAuthLookupChecks(adminUrl, scratchRoleUrl, results) {
|
|
const migrationSql = readAuthLookupMigrationSql();
|
|
if (!migrationSql) {
|
|
report(
|
|
results,
|
|
'auth-lookup-migration-vorhanden',
|
|
false,
|
|
'Migrationsverzeichnis *_auth_lookup_functions nicht gefunden',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
|
await db.$executeRawUnsafe(`
|
|
CREATE TABLE "User" (
|
|
id text PRIMARY KEY,
|
|
username text UNIQUE NOT NULL,
|
|
email text UNIQUE,
|
|
"tenantId" text NOT NULL,
|
|
"passwordHash" text,
|
|
"ldapDn" text,
|
|
"isActive" boolean NOT NULL DEFAULT true,
|
|
role text NOT NULL DEFAULT 'USER',
|
|
"displayName" text,
|
|
"mustChangePassword" boolean NOT NULL DEFAULT false
|
|
);
|
|
`);
|
|
await db.$executeRawUnsafe(`ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;`);
|
|
await db.$executeRawUnsafe(`ALTER TABLE "User" FORCE ROW LEVEL SECURITY;`);
|
|
await db.$executeRawUnsafe(
|
|
`CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());`,
|
|
);
|
|
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "User" TO ${SCRATCH_ROLE_NAME}`);
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive")
|
|
VALUES ('user-a', 'alice', 'TENANT-A', 'hash-a', true),
|
|
('user-b', 'bob', 'TENANT-B', 'hash-b', true);
|
|
`);
|
|
|
|
// Migration nutzt echte Postgres-ENUM-Werte fuer "role" (Typ "Role") —
|
|
// die Wegwerf-Tabelle oben verwendet stattdessen text, das ist fuer die
|
|
// hier gemessenen drei Verhaltensweisen ausreichend. Die Funktion
|
|
// auth_lookup_user_by_username referenziert den Spaltentyp nicht direkt
|
|
// (SELECT u.role liefert einfach den gespeicherten Wert), daher
|
|
// funktioniert das ohne den ENUM-Typ anzulegen — mit einer Ausnahme:
|
|
// die RETURNS TABLE-Deklaration der echten Migration nennt den Typ
|
|
// "Role" explizit. Fuer die Wegwerf-Pruefung wird er hier nachgebildet.
|
|
await db.$executeRawUnsafe(`
|
|
DO $$ BEGIN
|
|
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN');
|
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
END $$;
|
|
`);
|
|
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;`);
|
|
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role TYPE "Role" USING role::"Role";`);
|
|
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'USER'::"Role";`);
|
|
|
|
await db.$executeRawUnsafe(`
|
|
CREATE TABLE "PasswordResetToken" (
|
|
id text PRIMARY KEY,
|
|
token text UNIQUE NOT NULL,
|
|
"userId" text NOT NULL REFERENCES "User"(id),
|
|
"expiresAt" timestamp(3) NOT NULL,
|
|
"usedAt" timestamp(3)
|
|
);
|
|
`);
|
|
|
|
// Die echte Migration erteilt das Ausfuehrungsrecht ausschliesslich an
|
|
// tessera_app — fuer die Wegwerf-Pruefung an die Scratch-Rolle
|
|
// umgeleitet, ohne den Rest der Migration zu veraendern. Ueber
|
|
// executeSqlScript (prisma db execute --file), weil die Migration
|
|
// mehrere Anweisungen inklusive DO $$ ... $$-Bloecke enthaelt, die sich
|
|
// nicht als einzelnes $executeRawUnsafe senden lassen.
|
|
});
|
|
|
|
const adaptedSql = migrationSql.replaceAll('tessera_app', SCRATCH_ROLE_NAME);
|
|
executeSqlScript(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), adaptedSql);
|
|
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
const found = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`;
|
|
report(
|
|
results,
|
|
'anmeldesuche-findet-benutzer',
|
|
found.length === 1 && found[0].username === 'alice',
|
|
`auth_lookup_user_by_username('alice') liefert ${found.length} Zeile(n)`,
|
|
);
|
|
|
|
const notFound = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('unknown-user')`;
|
|
report(
|
|
results,
|
|
'anmeldesuche-unbekannt-liefert-nichts-und-wirft-nicht',
|
|
notFound.length === 0,
|
|
`auth_lookup_user_by_username('unknown-user') liefert ${notFound.length} Zeile(n)`,
|
|
);
|
|
|
|
const rawSelect = await prisma.$queryRaw`SELECT * FROM "User"`;
|
|
report(
|
|
results,
|
|
'gewoehnlicher-select-auf-user-liefert-null-zeilen',
|
|
rawSelect.length === 0,
|
|
`SELECT * FROM "User" liefert ${rawSelect.length} Zeile(n)`,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liest die ausgelieferte RLS-Basismigration und schneidet die beiden
|
|
* `CREATE POLICY`-Anweisungen fuer "LdapConfig" und "LdapFieldMapping" bis
|
|
* zum abschliessenden Semikolon heraus (Vorbild: readAuthLookupMigrationSql).
|
|
* Der Dateiname wird ueber ein Suffix gesucht, nicht hartkodiert — aber die
|
|
* Groups-Migration endet ebenfalls auf "_rls_policies" und wird deshalb
|
|
* ausdruecklich ausgeschlossen, sonst faende der Filter zwei Verzeichnisse.
|
|
*/
|
|
function readRlsPoliciesMigrationSql() {
|
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
|
.filter(
|
|
(entry) =>
|
|
entry.isDirectory() &&
|
|
entry.name.endsWith('_rls_policies') &&
|
|
!entry.name.endsWith('_groups_rls_policies'),
|
|
)
|
|
.map((entry) => entry.name);
|
|
if (dirs.length !== 1) return null;
|
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
|
}
|
|
|
|
function extractPolicySql(migrationSql, tableName) {
|
|
const re = new RegExp(
|
|
`CREATE POLICY tenant_isolation_policy ON "${tableName}"[\\s\\S]*?;`,
|
|
);
|
|
const match = migrationSql.match(re);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
|
|
/**
|
|
* 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 emailConfigPolicy = remainingMigrationSql
|
|
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
|
|
: null;
|
|
const notificationPrefPolicy = remainingMigrationSql
|
|
? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref')
|
|
: null;
|
|
const rssFeedPolicies = widenMigrationSql
|
|
? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')
|
|
: [];
|
|
const savedSearchPolicy = remainingMigrationSql
|
|
? extractPolicySql(remainingMigrationSql, '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)`,
|
|
);
|
|
|
|
// tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund
|
|
// E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das
|
|
// bestandene Ergebnis. Es belegt, dass die Policy keine
|
|
// Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt
|
|
// deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern
|
|
// desselben Mandanten und darf bei der Umstellung nicht entfallen.
|
|
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
|
|
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
|
|
report(
|
|
results,
|
|
'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar',
|
|
bothUsersVisible,
|
|
bothUsersVisible
|
|
? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen`
|
|
: `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`,
|
|
);
|
|
|
|
// tenderemailconfig-gebunden-nur-eigener-mandant
|
|
const emailConfigRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM "TenderEmailConfig" ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'tenderemailconfig-gebunden-nur-eigener-mandant',
|
|
emailConfigRowsForA.length === 1 && emailConfigRowsForA[0].tenantId === 'TENANT-A',
|
|
`forTenant(TENANT-A) liefert ${emailConfigRowsForA.length} Zeile(n): ${JSON.stringify(emailConfigRowsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
// tendertriage-gebunden-nur-eigener-mandant
|
|
const triageRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM "TenderTriage" ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'tendertriage-gebunden-nur-eigener-mandant',
|
|
triageRowsForA.length === 1 && triageRowsForA[0].tenantId === 'TENANT-A',
|
|
`forTenant(TENANT-A) liefert ${triageRowsForA.length} Zeile(n): ${JSON.stringify(triageRowsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
// tendernotificationpref-gebunden-nur-eigener-mandant
|
|
const notificationPrefRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM "TenderNotificationPref" ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'tendernotificationpref-gebunden-nur-eigener-mandant',
|
|
notificationPrefRowsForA.length === 1 && notificationPrefRowsForA[0].tenantId === 'TENANT-A',
|
|
`forTenant(TENANT-A) liefert ${notificationPrefRowsForA.length} Zeile(n): ${JSON.stringify(notificationPrefRowsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
// tenderrssfeed-plattformzeile-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) {
|
|
return err?.meta?.code ?? null;
|
|
}
|
|
|
|
/**
|
|
* Aufgabe 1 (260910-das) — misst die zwoelf im Plan genannten
|
|
* Verhaltensweisen des Bereichs `user` unter der Rolle ohne BYPASSRLS. Die
|
|
* Tabelle "User" existiert bereits (vom runAuthLookupChecks()-Abschnitt
|
|
* angelegt, samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
|
|
* Eindeutigkeitsbedingungen `username`/`email` und zwei Testzeilen in zwei
|
|
* Mandanten, Befund O) — dieser Abschnitt legt sie NICHT neu an, sondern
|
|
* baut darauf auf und ergaenzt, was ihm fehlt: eine Tabelle "Tenant" ohne
|
|
* Zeilenschutz (die zu messende Eigenschaft selbst, nicht Beiwerk) und je
|
|
* eine weitere Benutzerzeile pro Mandant.
|
|
*
|
|
* Muss NACH runDkvAreaChecks() und VOR runTransactionShapeMeasurement()
|
|
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
|
|
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
|
|
* Abschnitt aendert daran nichts.
|
|
*/
|
|
async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|
const baseMigrationSql = readRlsPoliciesMigrationSql();
|
|
const extractedUserPolicy = baseMigrationSql
|
|
? extractPolicySql(baseMigrationSql, 'User')
|
|
: null;
|
|
|
|
if (
|
|
!extractedUserPolicy ||
|
|
normalizePolicySql(extractedUserPolicy) !== normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL)
|
|
) {
|
|
report(
|
|
results,
|
|
'user-policy-aus-migration-wortgleich',
|
|
false,
|
|
extractedUserPolicy
|
|
? `Policy aus 20260618112133_rls_policies weicht von der im Anmeldeweg-Abschnitt getippten Fassung ab: extrahiert=${JSON.stringify(normalizePolicySql(extractedUserPolicy))}, getippt=${JSON.stringify(normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL))} — die getippte Fassung wird durch die geschnittene ERSETZT, die Abweichung gehoert in die Kritikschrift`
|
|
: 'CREATE POLICY fuer "User" nicht in der ausgelieferten 20260618112133_rls_policies-Migration gefunden',
|
|
);
|
|
return;
|
|
}
|
|
report(
|
|
results,
|
|
'user-policy-aus-migration-wortgleich',
|
|
true,
|
|
'die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen',
|
|
);
|
|
|
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
|
// "Tenant" traegt bewusst KEINEN Zeilenschutz — das ist die zu
|
|
// messende Eigenschaft (Befund F/K), nicht ein Versehen.
|
|
await db.$executeRawUnsafe(`
|
|
CREATE TABLE "Tenant" (
|
|
id text PRIMARY KEY,
|
|
slug text NOT NULL UNIQUE
|
|
);
|
|
`);
|
|
await db.$executeRawUnsafe(
|
|
`GRANT SELECT, INSERT, UPDATE, DELETE ON "Tenant" TO ${SCRATCH_ROLE_NAME}`,
|
|
);
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "Tenant" (id, slug) VALUES
|
|
('TENANT-A', 'tenant-a'),
|
|
('TENANT-B', 'tenant-b');
|
|
`);
|
|
|
|
// Je eine weitere Benutzerzeile pro Mandant, zusaetzlich zu den beiden
|
|
// bereits vom Anmeldeweg-Abschnitt angelegten (alice/TENANT-A,
|
|
// bob/TENANT-B) — role braucht einen gueltigen Aufzaehlungswert
|
|
// (Befund O).
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role)
|
|
VALUES ('user-a2', 'carol', 'TENANT-A', 'hash-c', true, 'USER'),
|
|
('user-b2', 'dave', 'TENANT-B', 'hash-d', true, 'USER');
|
|
`);
|
|
});
|
|
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
// user-gebunden-nur-eigener-mandant
|
|
const userRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM "User" ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'user-gebunden-nur-eigener-mandant',
|
|
userRowsForA.length === 2 && userRowsForA.every((r) => r.tenantId === 'TENANT-A'),
|
|
`forTenant(TENANT-A) liefert ${userRowsForA.length} Zeile(n): ${JSON.stringify(userRowsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
// user-ungebunden-null-zeilen — die Belegzeile, die den gesamten
|
|
// user-Abschnitt der Kritikschrift traegt: der IDENTISCHE SELECT ohne
|
|
// vorheriges set_config liefert null Zeilen, nicht die vier
|
|
// tatsaechlich vorhandenen.
|
|
const unboundUserRows = await prisma.$queryRaw`SELECT "tenantId" FROM "User"`;
|
|
report(
|
|
results,
|
|
'user-ungebunden-null-zeilen',
|
|
unboundUserRows.length === 0,
|
|
`ungebundener SELECT auf "User" liefert ${unboundUserRows.length} Zeile(n)`,
|
|
);
|
|
|
|
// user-ungebundene-suche-nach-benutzername-liefert-keine-zeile — die
|
|
// Form, die die Erstanlage-Pruefung beim Start heute benutzt: ein
|
|
// ungebundenes SELECT mit Gleichheitsbedingung auf einen Benutzernamen,
|
|
// den es GIBT ('bob', TENANT-B).
|
|
const unboundUsernameLookup =
|
|
await prisma.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`;
|
|
report(
|
|
results,
|
|
'user-ungebundene-suche-nach-benutzername-liefert-keine-zeile',
|
|
unboundUsernameLookup.length === 0,
|
|
`ungebundenes SELECT ... WHERE username = 'bob' liefert ${unboundUsernameLookup.length} Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an`,
|
|
);
|
|
|
|
// user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile —
|
|
// dieselbe Suche, gebunden an TENANT-A, nach einem Benutzernamen von
|
|
// TENANT-B ('bob'). Das ist der Moment, in dem eine Kollisionspruefung
|
|
// faelschlich "frei" meldet.
|
|
const boundForeignUsernameLookup = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`,
|
|
);
|
|
report(
|
|
results,
|
|
'user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile',
|
|
boundForeignUsernameLookup.length === 0,
|
|
`forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) ${boundForeignUsernameLookup.length} Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"`,
|
|
);
|
|
|
|
// user-eindeutigkeit-greift-trotz-unsichtbarkeit — die wichtigste
|
|
// Messung dieser Aufgabe und der Schluss der Kette: unmittelbar nach
|
|
// der vorherigen Pruefung ein gebundenes INSERT unter TENANT-A mit
|
|
// genau diesem, angeblich freien Benutzernamen ('bob'). Bestanden, wenn
|
|
// es abgewiesen wird UND die Ablehnung nachweislich eine
|
|
// Eindeutigkeitsverletzung ist (SQLSTATE 23505) und NICHT eine
|
|
// Zeilenschutz-Ablehnung (SQLSTATE 42501).
|
|
let uniquenessHoldsDespiteInvisibility = false;
|
|
let uniquenessDetail = '';
|
|
try {
|
|
await forTenantQuery(
|
|
prisma,
|
|
'TENANT-A',
|
|
(tx) =>
|
|
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-a-collision', 'bob', 'TENANT-A', 'hash-x', true, 'USER')`,
|
|
);
|
|
uniquenessDetail =
|
|
'gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" ist NICHT fehlgeschlagen';
|
|
} catch (err) {
|
|
const sqlState = sqlStateOf(err);
|
|
uniquenessHoldsDespiteInvisibility = sqlState === '23505';
|
|
uniquenessDetail = `gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()}) — ${
|
|
uniquenessHoldsDespiteInvisibility
|
|
? 'eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette'
|
|
: 'KEINE Eindeutigkeitsverletzung, sondern eine andere Fehlerklasse — das waere NICHT die im Auftrag beschriebene Kette'
|
|
}`;
|
|
}
|
|
report(
|
|
results,
|
|
'user-eindeutigkeit-greift-trotz-unsichtbarkeit',
|
|
uniquenessHoldsDespiteInvisibility,
|
|
uniquenessDetail,
|
|
);
|
|
|
|
// user-gebundenes-einfuegen-fremder-mandant-abgelehnt — die Policy
|
|
// traegt keine eigene WITH-CHECK-Klausel; was PostgreSQL daraus fuer
|
|
// ein INSERT ableitet, ist eine Eigenschaft der Datenbank, keine des
|
|
// Policy-Textes.
|
|
let foreignTenantInsertRejected = false;
|
|
let foreignTenantInsertDetail = '';
|
|
try {
|
|
await forTenantQuery(
|
|
prisma,
|
|
'TENANT-A',
|
|
(tx) =>
|
|
tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-foreign-tenant', 'erin', 'TENANT-B', 'hash-e', true, 'USER')`,
|
|
);
|
|
foreignTenantInsertDetail =
|
|
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
|
|
} catch (err) {
|
|
foreignTenantInsertRejected = true;
|
|
foreignTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message.trim()}`;
|
|
}
|
|
report(
|
|
results,
|
|
'user-gebundenes-einfuegen-fremder-mandant-abgelehnt',
|
|
foreignTenantInsertRejected,
|
|
foreignTenantInsertDetail,
|
|
);
|
|
|
|
// user-ungebundenes-einfuegen-abgelehnt — die Zeile, die Befund J
|
|
// traegt: bliebe die Erstanlage des Administrators ungebunden, koennte
|
|
// eine frische Installation ihren ersten Administrator nach dem
|
|
// Scharfschalten nicht anlegen.
|
|
let unboundInsertRejected = false;
|
|
let unboundInsertDetail = '';
|
|
try {
|
|
await prisma.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-unbound', 'frank', 'TENANT-A', 'hash-f', true, 'USER')`;
|
|
unboundInsertDetail = 'ungebundenes INSERT mit gueltiger Mandantenkennung ist NICHT fehlgeschlagen';
|
|
} catch (err) {
|
|
unboundInsertRejected = true;
|
|
unboundInsertDetail = `ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: ${err.message.trim()}`;
|
|
}
|
|
report(
|
|
results,
|
|
'user-ungebundenes-einfuegen-abgelehnt',
|
|
unboundInsertRejected,
|
|
unboundInsertDetail,
|
|
);
|
|
|
|
// user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
|
|
const foreignUpdateAffected = await forTenantQuery(
|
|
prisma,
|
|
'TENANT-A',
|
|
(tx) =>
|
|
tx.$executeRaw`UPDATE "User" SET "displayName" = 'ueberschrieben' WHERE id = 'user-b'`,
|
|
);
|
|
report(
|
|
results,
|
|
'user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
|
|
foreignUpdateAffected === 0,
|
|
`gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt`,
|
|
);
|
|
|
|
// user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen
|
|
const foreignDeleteAffected = await forTenantQuery(
|
|
prisma,
|
|
'TENANT-A',
|
|
(tx) => tx.$executeRaw`DELETE FROM "User" WHERE id = 'user-b2'`,
|
|
);
|
|
report(
|
|
results,
|
|
'user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen',
|
|
foreignDeleteAffected === 0,
|
|
`gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft ${foreignDeleteAffected} Zeile(n)`,
|
|
);
|
|
|
|
// tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar — die Eigenschaft, auf
|
|
// der sowohl die umgestellte Plattform-Administratorsicht als auch die
|
|
// Standardgruppen-Reparatur beim Start stehen (Befund F, Befund K).
|
|
// Zusaetzlich im Systemkatalog geprueft, damit die Aussage nicht allein
|
|
// daran haengt, dass dieser Abschnitt selbst keinen Zeilenschutz
|
|
// eingeschaltet hat.
|
|
const unboundTenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`;
|
|
const [rlsRow] =
|
|
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Tenant'`;
|
|
const tenantReadable = unboundTenantRows.length === 2 && rlsRow?.relrowsecurity === false;
|
|
report(
|
|
results,
|
|
'tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar',
|
|
tenantReadable,
|
|
`ungebundenes SELECT auf "Tenant" liefert ${unboundTenantRows.length} Zeile(n): ${JSON.stringify(unboundTenantRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Tenant" = ${JSON.stringify(rlsRow?.relrowsecurity)}`,
|
|
);
|
|
|
|
// user-fan-out-je-mandant-gebunden-liefert-alle-zeilen — die
|
|
// Nachbildung der umgestellten Plattform-Administratorsicht: erst die
|
|
// Mandanten ungebunden lesen, dann je Mandant EIN gebundener SELECT,
|
|
// dann die Ergebnisse vereinigen. Die Gesamtmenge wird ueber die
|
|
// Wartungsrolle (mit BYPASSRLS) gemessen, nicht angenommen.
|
|
const tenantIds = unboundTenantRows.map((r) => r.id).sort();
|
|
let fannedOutUsernames = [];
|
|
for (const tenantId of tenantIds) {
|
|
const rows = await forTenantQuery(prisma, tenantId, (tx) =>
|
|
tx.$queryRaw`SELECT username FROM "User" ORDER BY username`,
|
|
);
|
|
fannedOutUsernames.push(...rows.map((r) => r.username));
|
|
}
|
|
fannedOutUsernames = fannedOutUsernames.sort();
|
|
|
|
const groundTruthUsernames = await withAdminPrisma(
|
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
|
async (db) => {
|
|
const rows = await db.$queryRaw`SELECT username FROM "User" ORDER BY username`;
|
|
return rows.map((r) => r.username).sort();
|
|
},
|
|
);
|
|
|
|
const fanOutMatches =
|
|
fannedOutUsernames.length === groundTruthUsernames.length &&
|
|
fannedOutUsernames.every((u, i) => u === groundTruthUsernames[i]);
|
|
report(
|
|
results,
|
|
'user-fan-out-je-mandant-gebunden-liefert-alle-zeilen',
|
|
fanOutMatches,
|
|
`Vereinigung der je-Mandant gebundenen SELECTs liefert ${fannedOutUsernames.length} Benutzernamen: ${JSON.stringify(fannedOutUsernames)}; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind ${groundTruthUsernames.length}: ${JSON.stringify(groundTruthUsernames)}`,
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liest die ausgelieferte Migration, die die Tabellen "Module" und
|
|
* "TenantModuleActivation" samt dem Eindeutigkeitsindex auf
|
|
* ("tenantId","moduleId") anlegt (Dateiname endet auf
|
|
* "_add_module_registry").
|
|
*/
|
|
function readModuleRegistryMigrationSql() {
|
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_module_registry'))
|
|
.map((entry) => entry.name);
|
|
if (dirs.length !== 1) return null;
|
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Liest die ausgelieferte Migration, die (unter anderem) die beiden
|
|
* partiellen Eindeutigkeitsindizes auf "ModuleGrant" anlegt (Dateiname endet
|
|
* auf "_add_groups_and_module_grants") — dieselbe Datei, aus der
|
|
* runGroupsAreaChecks() NICHT liest (jene braucht nur die RLS-Policy-
|
|
* Migration), deshalb ein eigenes, unabhaengiges Lesehilfsmittel.
|
|
*/
|
|
function readAddGroupsAndModuleGrantsMigrationSql() {
|
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_groups_and_module_grants'))
|
|
.map((entry) => entry.name);
|
|
if (dirs.length !== 1) return null;
|
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Schneidet eine `CREATE UNIQUE INDEX "<indexName>" ...;`-Anweisung
|
|
* wortgleich aus einem Migrationstext, nach demselben Muster wie
|
|
* extractPolicySql() oben.
|
|
*/
|
|
function extractIndexSql(migrationSql, indexName) {
|
|
const re = new RegExp(`CREATE UNIQUE INDEX "${indexName}"[\\s\\S]*?;`);
|
|
const match = migrationSql.match(re);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Aufgabe 1 (260910-exd) — misst die dreizehn im Plan genannten
|
|
* Verhaltensweisen des Bereichs `module-registry` unter der Rolle ohne
|
|
* BYPASSRLS. Setzt auf den Tabellen "Group", "GroupMembership",
|
|
* "ModuleGrant" und "TenantModuleActivation" auf, die runGroupsAreaChecks()
|
|
* bereits angelegt und mit Policies WORTGLEICH aus den ausgelieferten
|
|
* Migrationen versehen hat — dieser Abschnitt legt sie NICHT neu an. Neu
|
|
* angelegt werden nur: die Tabelle "Module" OHNE Zeilenschutz (die zu
|
|
* messende Eigenschaft selbst), der Eindeutigkeitsindex auf
|
|
* ("tenantId","moduleId") fuer "TenantModuleActivation" (bis hierhin fehlte
|
|
* er, weil runGroupsAreaChecks() ihn nicht braucht), zwei Direkt-Freigaben
|
|
* (eine je Mandant) und die Mitgliedschaft (group-b, user-a), die
|
|
* runGroupsAreaChecks() unter gebundenem Kontext bewusst nicht anlegen
|
|
* konnte.
|
|
*
|
|
* Die bereits von runGroupsAreaChecks() eingefuegte Zeile
|
|
* `grant-foreign-group` (TENANT-A, mod-1, groupId=group-b) wird
|
|
* WIEDERVERWENDET, nicht neu erzeugt.
|
|
*
|
|
* Muss NACH runUserAreaChecks() und VOR runTransactionShapeMeasurement()
|
|
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
|
|
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
|
|
* Abschnitt aendert daran nichts.
|
|
*/
|
|
async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
|
|
const moduleRegistryMigrationSql = readModuleRegistryMigrationSql();
|
|
const activationUniqueIndexSql = moduleRegistryMigrationSql
|
|
? extractIndexSql(
|
|
moduleRegistryMigrationSql,
|
|
'TenantModuleActivation_tenantId_moduleId_key',
|
|
)
|
|
: null;
|
|
|
|
const groupsAndGrantsMigrationSql = readAddGroupsAndModuleGrantsMigrationSql();
|
|
const grantGroupUniqueIndexSql = groupsAndGrantsMigrationSql
|
|
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_group_unique')
|
|
: null;
|
|
const grantUserUniqueIndexSql = groupsAndGrantsMigrationSql
|
|
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_user_unique')
|
|
: null;
|
|
|
|
if (!activationUniqueIndexSql || !grantGroupUniqueIndexSql || !grantUserUniqueIndexSql) {
|
|
report(
|
|
results,
|
|
'module-registry-regeln-aus-migration-gefunden',
|
|
false,
|
|
'Eindeutigkeitsindex fuer "TenantModuleActivation" und/oder die beiden partiellen Eindeutigkeitsindizes fuer "ModuleGrant" nicht in den ausgelieferten Migrationen gefunden',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
|
|
// (a) Katalogtabelle OHNE Zeilenschutz — die zu messende Eigenschaft
|
|
// selbst (Befund E), nach dem Muster der Tabelle "Tenant" im
|
|
// Abschnitt des Bereichs `user`.
|
|
await db.$executeRawUnsafe(`
|
|
CREATE TABLE "Module" (
|
|
id text PRIMARY KEY,
|
|
name text NOT NULL
|
|
);
|
|
`);
|
|
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "Module" TO ${SCRATCH_ROLE_NAME}`);
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "Module" (id, name) VALUES ('mod-1', 'Modul Eins'), ('mod-2', 'Modul Zwei');
|
|
`);
|
|
|
|
// (b) Eindeutigkeitsindex auf ("tenantId","moduleId") fuer
|
|
// "TenantModuleActivation", wortgleich aus der ausgelieferten Migration
|
|
// — ohne ihn liesse sich Pruefung 12 nicht messen.
|
|
await db.$executeRawUnsafe(activationUniqueIndexSql);
|
|
|
|
// Zusaetzliche Aktivierungszeile fuer Pruefung 12: (TENANT-B, mod-2),
|
|
// unter TENANT-A unsichtbar, aber physisch vorhanden.
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES
|
|
('activation-b2', 'TENANT-B', 'mod-2', true);
|
|
`);
|
|
|
|
// (c) je eine Direkt-Freigabezeile pro Mandant (userId, kein groupId).
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES
|
|
('grant-direct-a', 'TENANT-A', 'mod-1', NULL, 'user-a'),
|
|
('grant-direct-b', 'TENANT-B', 'mod-1', NULL, 'user-b');
|
|
`);
|
|
|
|
// (d) die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks()
|
|
// unter gebundenem Kontext bewusst NICHT anlegen konnte (die Regel wies
|
|
// sie ab) — hier ueber die Verwaltungsrolle gesetzt, weil Pruefung 6
|
|
// sonst aus dem falschen Grund bestuende.
|
|
await db.$executeRawUnsafe(`
|
|
INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES
|
|
('membership-cross', 'group-b', 'user-a', 'MANUAL');
|
|
`);
|
|
});
|
|
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
// 1: modulegrant-ungebunden-null-zeilen — die Belegzeile dieses
|
|
// Abschnitts.
|
|
const actualGrantCount = await withAdminPrisma(
|
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
|
async (db) => {
|
|
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "ModuleGrant"`;
|
|
return row.n;
|
|
},
|
|
);
|
|
const unboundGrantRows = await prisma.$queryRaw`SELECT "tenantId" FROM "ModuleGrant"`;
|
|
report(
|
|
results,
|
|
'modulegrant-ungebunden-null-zeilen',
|
|
unboundGrantRows.length === 0,
|
|
`ungebundener SELECT auf "ModuleGrant" liefert ${unboundGrantRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualGrantCount}`,
|
|
);
|
|
|
|
// 2: tenantmoduleactivation-ungebunden-null-zeilen — dasselbe fuer die
|
|
// Aktivierungstabelle, die der Kurzschluss fuer ADMIN/SUPER_ADMIN als
|
|
// EINZIGE liest.
|
|
const actualActivationCount = await withAdminPrisma(
|
|
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
|
|
async (db) => {
|
|
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "TenantModuleActivation"`;
|
|
return row.n;
|
|
},
|
|
);
|
|
const unboundActivationRows =
|
|
await prisma.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation"`;
|
|
report(
|
|
results,
|
|
'tenantmoduleactivation-ungebunden-null-zeilen',
|
|
unboundActivationRows.length === 0,
|
|
`ungebundener SELECT auf "TenantModuleActivation" liefert ${unboundActivationRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualActivationCount}`,
|
|
);
|
|
|
|
// 3: admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen
|
|
const activeActivationsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" WHERE "isActive" = true ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen',
|
|
activeActivationsForA.length === 1 && activeActivationsForA[0].tenantId === 'TENANT-A',
|
|
`forTenant(TENANT-A) liefert ${activeActivationsForA.length} aktive Aktivierung(en): ${JSON.stringify(activeActivationsForA.map((r) => r.tenantId))}`,
|
|
);
|
|
|
|
// 4: direktfreigabe-gebunden-nur-eigene-zeile
|
|
const directGrantsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`SELECT id FROM "ModuleGrant" WHERE "userId" = 'user-a' ORDER BY id`,
|
|
);
|
|
report(
|
|
results,
|
|
'direktfreigabe-gebunden-nur-eigene-zeile',
|
|
directGrantsForA.length === 1 && directGrantsForA[0].id === 'grant-direct-a',
|
|
`forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) ${directGrantsForA.length} Zeile(n): ${JSON.stringify(directGrantsForA.map((r) => r.id))}`,
|
|
);
|
|
|
|
// Der Drei-Tabellen-Weg (ModuleGrant ueber Group ueber GroupMembership),
|
|
// Grundlage fuer Pruefung 5 und 6 — EINE Abfrage, zwei Aussagen.
|
|
const groupPathForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
|
|
tx.$queryRaw`
|
|
SELECT mg.id FROM "ModuleGrant" mg
|
|
JOIN "Group" g ON g.id = mg."groupId"
|
|
JOIN "GroupMembership" gm ON gm."groupId" = g.id
|
|
WHERE mg."tenantId" = current_tenant_id() AND gm."userId" = 'user-a'
|
|
ORDER BY mg.id
|
|
`,
|
|
);
|
|
|
|
// 5: gruppenpfad-gebunden-folgt-der-gruppenregel
|
|
report(
|
|
results,
|
|
'gruppenpfad-gebunden-folgt-der-gruppenregel',
|
|
groupPathForA.length === 1 && groupPathForA[0].id === 'grant-a',
|
|
`forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a ${groupPathForA.length} Zeile(n): ${JSON.stringify(groupPathForA.map((r) => r.id))}`,
|
|
);
|
|
|
|
// 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus — 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 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
|
* traegt. Baut die Erweiterungsform aus prisma-tenant.extension.ts
|
|
* WORTGLEICH nach ($extends mit $allOperations, Array-Form von
|
|
* $transaction darin) statt ueber das vereinfachte forTenantQuery(), denn
|
|
* genau diese Erweiterungsschicht ist hier der Gegenstand der Messung.
|
|
*
|
|
* Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits
|
|
* angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat.
|
|
*/
|
|
function buildInlineExtendedClient(prisma, tenantId) {
|
|
return prisma.$extends({
|
|
query: {
|
|
$allOperations({ args, query }) {
|
|
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
|
return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]);
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Druckt die tatsaechlich beobachteten Werte einer Transaktionsform. Fliesst
|
|
* NICHT in die Pruefliste ein und beeinflusst den Rueckgabewert nicht — eine
|
|
* Form, die abbricht, ist ein Messergebnis und kein Werkzeugfehler.
|
|
*/
|
|
function beobachte(formName, payload) {
|
|
console.log(` [beobachtet] ${formName}: ${JSON.stringify(payload)}`);
|
|
}
|
|
|
|
/**
|
|
* Alle drei Bedingungen aus dem Plan: gleiche Verbindungskennung ueber
|
|
* beide Teilschritte, gelesener Mandantenkontext gleich TENANT-A in
|
|
* beiden Teilschritten, und der Lesezugriff liefert genau die eine Zeile
|
|
* von TENANT-A.
|
|
*/
|
|
function traegtKontextAufDerselbenVerbindung(step1, step2) {
|
|
return Boolean(
|
|
step1 &&
|
|
step2 &&
|
|
step1.pid === step2.pid &&
|
|
step1.t === 'TENANT-A' &&
|
|
step2.t === 'TENANT-A' &&
|
|
step2.rows === 1,
|
|
);
|
|
}
|
|
|
|
async function measureArrayFormOnBoundClient(scratchRoleUrl) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
|
const [step1Rows, step2Rows] = await bound.$transaction([
|
|
bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`,
|
|
bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`,
|
|
]);
|
|
return { step1: step1Rows[0], step2: step2Rows[0] };
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function measureInteractiveFormOnBoundClient(scratchRoleUrl) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
|
|
return await bound.$transaction(async (tx) => {
|
|
const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`;
|
|
const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`;
|
|
return { step1: step1Rows[0], step2: step2Rows[0] };
|
|
});
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function measureInteractiveFormOnUnboundClient(scratchRoleUrl) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
const tenantId = 'TENANT-A';
|
|
try {
|
|
return await prisma.$transaction(async (tx) => {
|
|
const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', ${tenantId}, true) AS applied, current_tenant_id() AS t`;
|
|
const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`;
|
|
return { step1: step1Rows[0], step2: step2Rows[0] };
|
|
});
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function runTransactionShapeMeasurement(scratchRoleUrl, results) {
|
|
const forms = [
|
|
{ name: 'Form (i) — Array-Form auf gebundenem Client', fn: measureArrayFormOnBoundClient },
|
|
{
|
|
name: 'Form (ii) — interaktive Callback-Form auf gebundenem Client',
|
|
fn: measureInteractiveFormOnBoundClient,
|
|
},
|
|
{
|
|
name: 'Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx)',
|
|
fn: measureInteractiveFormOnUnboundClient,
|
|
},
|
|
];
|
|
|
|
const outcomes = [];
|
|
for (const form of forms) {
|
|
try {
|
|
const r = await form.fn(scratchRoleUrl);
|
|
beobachte(form.name, r);
|
|
outcomes.push({ name: form.name, passed: traegtKontextAufDerselbenVerbindung(r.step1, r.step2) });
|
|
} catch (err) {
|
|
beobachte(form.name, { abbruch: err.message });
|
|
outcomes.push({ name: form.name, passed: false });
|
|
}
|
|
}
|
|
|
|
const passedForms = outcomes.filter((o) => o.passed).map((o) => o.name);
|
|
const failedForms = outcomes.filter((o) => !o.passed).map((o) => o.name);
|
|
report(
|
|
results,
|
|
'mindestens-eine-transaktionsform-traegt-den-mandantenkontext',
|
|
passedForms.length > 0,
|
|
`bestanden: [${passedForms.join(' ; ')}] — nicht bestanden: [${failedForms.join(' ; ')}]`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ein einzelner Aufruf in Form (ii): interaktive Callback-Transaktion auf dem
|
|
* GEBUNDENEN Client. Liefert den im Inneren gelesenen Mandantenkontext.
|
|
*/
|
|
async function runOneBoundInteractive(prisma, tenantId) {
|
|
const bound = buildInlineExtendedClient(prisma, tenantId);
|
|
return bound.$transaction(async (tx) => {
|
|
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`;
|
|
return rows[0]?.t;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ein einzelner Aufruf in Form (iii): interaktive Callback-Transaktion auf dem
|
|
* UNGEBUNDENEN Client, set_config als erste Anweisung direkt auf tx.
|
|
*/
|
|
async function runOneUnboundInteractive(prisma, tenantId) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
|
const rows = await tx.$queryRaw`SELECT current_tenant_id() AS t`;
|
|
return rows[0]?.t;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Belastet eine Transaktionsform mit `parallelCount` gleichzeitigen Aufrufen,
|
|
* abwechselnd fuer TENANT-A und TENANT-B, ueber EINEN gemeinsamen Client —
|
|
* also aus demselben endlichen Verbindungsvorrat, so wie im Betrieb.
|
|
*
|
|
* Als Verletzung zaehlt beides: ein Aufruf, der einen fremden oder gar keinen
|
|
* Mandantenkontext sieht, UND ein Aufruf, der abbricht. Der Abbruch ist hier
|
|
* kein Werkzeugfehler, sondern das Messergebnis — genau deshalb wird er
|
|
* gefangen und gezaehlt statt nach oben durchgereicht.
|
|
*/
|
|
async function measureUnderLoad(scratchRoleUrl, runOne, parallelCount) {
|
|
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
|
|
try {
|
|
const calls = Array.from({ length: parallelCount }, (_, i) => {
|
|
const tenantId = i % 2 === 0 ? 'TENANT-A' : 'TENANT-B';
|
|
return runOne(prisma, tenantId).then(
|
|
(seen) => (seen === tenantId ? null : `erwartet ${tenantId}, gesehen ${seen ?? 'NULL'}`),
|
|
(err) => `abbruch ${err.code ?? ''}: ${String(err.message).split('\n')[0]}`.trim(),
|
|
);
|
|
});
|
|
const verletzungen = (await Promise.all(calls)).filter(Boolean);
|
|
return {
|
|
aufrufe: parallelCount,
|
|
verletzungen: verletzungen.length,
|
|
beispiele: verletzungen.slice(0, 2),
|
|
};
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die Lastprobe hinter der Entscheidung fuer Form (iii).
|
|
*
|
|
* Sie existierte zunaechst nur als Fliesstext im Kopf von
|
|
* `prisma-tenant.extension.ts` — eine Zahl, die eine Entscheidung trug, aber
|
|
* nicht nachvollziehbar war. Genau das Anti-Muster, das dieses Projekt sich
|
|
* selbst verboten hat. Deshalb steht sie jetzt hier.
|
|
*
|
|
* GEPRUEFT wird nur die Eigenschaft, auf die sich der Code stuetzt: die
|
|
* GEWAEHLTE Form ueberlebt Nebenlaeufigkeit ohne Verletzung. Das Verhalten von
|
|
* Form (ii) wird daneben BEOBACHTET und ausgedruckt, aber nicht gepruft —
|
|
* ob und ab welcher Last sie abbricht, haengt an Verbindungsvorrat und
|
|
* Maschine und taugt nicht als Bedingung fuer einen gruenen Lauf.
|
|
*/
|
|
async function runConcurrencyProbe(scratchRoleUrl, results) {
|
|
const PARALLEL = 40;
|
|
|
|
const formZwei = await measureUnderLoad(scratchRoleUrl, runOneBoundInteractive, PARALLEL);
|
|
beobachte(`Form (ii) unter ${PARALLEL} parallelen Aufrufen`, formZwei);
|
|
|
|
const formDrei = await measureUnderLoad(scratchRoleUrl, runOneUnboundInteractive, PARALLEL);
|
|
beobachte(`Form (iii) unter ${PARALLEL} parallelen Aufrufen`, formDrei);
|
|
|
|
report(
|
|
results,
|
|
'gewaehlte-transaktionsform-uebersteht-nebenlaeufigkeit',
|
|
formDrei.verletzungen === 0,
|
|
`Form (iii): ${formDrei.verletzungen} Verletzung(en) bei ${PARALLEL} parallelen Aufrufen` +
|
|
` — Form (ii) zum Vergleich, nicht gepruft: ${formZwei.verletzungen}` +
|
|
(formZwei.beispiele.length ? ` (z.B. ${formZwei.beispiele[0]})` : ''),
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const adminUrl = parseAdminUrl();
|
|
const results = [];
|
|
|
|
console.log(`Richte Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ein...`);
|
|
await setupScratchDatabase(adminUrl);
|
|
|
|
try {
|
|
const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME);
|
|
scratchRoleUrl.username = SCRATCH_ROLE_NAME;
|
|
scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD;
|
|
const scratchRoleUrlString = scratchRoleUrl.toString();
|
|
|
|
await runForTenantChecks(scratchRoleUrlString, results);
|
|
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
|
|
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
|
|
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
|
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
|
|
await 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 runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
|
} finally {
|
|
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
|
await teardownScratchDatabase(adminUrl);
|
|
}
|
|
|
|
const allPassed = results.every((r) => r.passed);
|
|
console.log(
|
|
allPassed
|
|
? `Alle ${results.length} Pruefungen bestanden.`
|
|
: `${results.filter((r) => !r.passed).length} von ${results.length} Pruefungen fehlgeschlagen.`,
|
|
);
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('FEHLER beim Ausfuehren der Wegwerf-Pruefung:', err.message);
|
|
process.exit(1);
|
|
});
|