feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst null Zeilen liefern und die Anmeldung waere unmoeglich. Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp, fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts: - auth_lookup_user_by_username (validateUser) - auth_lookup_user_by_email (requestPasswordReset) - auth_lookup_reset_token (resetPassword) Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(), gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/ adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2. rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen. Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -29,11 +29,40 @@
|
||||
// Verbindungszeichenkette aus.
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ADMIN_ENV_VAR = 'TESSERA_SCRATCH_ADMIN_URL';
|
||||
const SCRATCH_DB_NAME = 'tessera_rls_scratch';
|
||||
const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
|
||||
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
|
||||
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
|
||||
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
|
||||
|
||||
/**
|
||||
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
|
||||
* -Bloecke) als EIN Kommando aus. `prisma.$executeRawUnsafe` nutzt das
|
||||
* erweiterte Protokoll und erlaubt pro Aufruf nur eine einzelne Anweisung —
|
||||
* `prisma db execute --file` sendet das gesamte Skript dagegen als ein
|
||||
* Kommando (einfaches Protokoll) und ist genau dafuer vorgesehen, ganze
|
||||
* Migrationsdateien auszufuehren.
|
||||
*/
|
||||
function executeSqlScript(databaseUrl, sql) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rls-scratch-check-'));
|
||||
const file = join(dir, 'script.sql');
|
||||
writeFileSync(file, sql, 'utf-8');
|
||||
try {
|
||||
execFileSync(PRISMA_BIN, ['db', 'execute', '--file', file, '--url', databaseUrl], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`FEHLER: ${message}`);
|
||||
@@ -208,6 +237,129 @@ async function runForTenantChecks(scratchRoleUrl, results) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const adminUrl = parseAdminUrl();
|
||||
const results = [];
|
||||
@@ -219,8 +371,10 @@ async function main() {
|
||||
const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME);
|
||||
scratchRoleUrl.username = SCRATCH_ROLE_NAME;
|
||||
scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD;
|
||||
const scratchRoleUrlString = scratchRoleUrl.toString();
|
||||
|
||||
await runForTenantChecks(scratchRoleUrl.toString(), results);
|
||||
await runForTenantChecks(scratchRoleUrlString, results);
|
||||
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
|
||||
} finally {
|
||||
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
||||
await teardownScratchDatabase(adminUrl);
|
||||
|
||||
Reference in New Issue
Block a user