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:
@@ -0,0 +1,153 @@
|
|||||||
|
-- WINDOWS #18/#20, 260909-eor Aufgabe 2 — der Anmeldeweg bekommt eine
|
||||||
|
-- bewusst schmale, begruendete Ausnahme von der Mandantentrennung.
|
||||||
|
--
|
||||||
|
-- DIE ENTSCHEIDUNG UND IHRE BEGRUENDUNG. Von drei erwogenen Wegen faellt die
|
||||||
|
-- Wahl auf SECURITY-DEFINER-Funktionen:
|
||||||
|
--
|
||||||
|
-- 1. Eine zusaetzliche Policy auf "User" scheidet aus, weil eine Policy
|
||||||
|
-- ein ZEILENPRAEDIKAT ist und nicht die FORM der Abfrage einschraenken
|
||||||
|
-- kann: eine Regel, die eine Suche nach Benutzername erlaubt, erlaubt
|
||||||
|
-- zwangslaeufig auch das Auslesen aller Zeilen.
|
||||||
|
-- 2. Eine zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil
|
||||||
|
-- sie einen zweiten Verbindungspool und einen zweiten Prisma-Client
|
||||||
|
-- verlangt und auf "User" ohnehin dieselbe Breite haette.
|
||||||
|
-- 3. Eine Funktion dagegen bindet die Ausnahme an eine FESTE Abfrage mit
|
||||||
|
-- festem Spaltensatz, fester Gleichheitsbedingung und LIMIT 1 — ein
|
||||||
|
-- kompromittierter Aufrufer kann damit einen einzelnen Benutzernamen
|
||||||
|
-- erraten, aber die Tabelle nicht ausleeren.
|
||||||
|
--
|
||||||
|
-- Alle drei Funktionen sind SECURITY DEFINER, STABLE, mit fest angeheftetem
|
||||||
|
-- Suchpfad auf "public, pg_temp" und LIMIT 1. Der feste Suchpfad ist bei
|
||||||
|
-- SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche
|
||||||
|
-- Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition
|
||||||
|
-- (z.B. ein Schema namens "pg_temp" oder ein frueh in search_path
|
||||||
|
-- platziertes Schema mit gleichnamiger Tabelle) den Tabellenbezug in der
|
||||||
|
-- Funktion umlenken, und der Aufrufer erbte die Rechte des Eigentuemers auf
|
||||||
|
-- die falsche Tabelle.
|
||||||
|
--
|
||||||
|
-- Keine dieser Funktionen schreibt. Nach jeder Anlage werden zuerst
|
||||||
|
-- saemtliche Rechte von PUBLIC entzogen und danach ausschliesslich
|
||||||
|
-- tessera_app das Ausfuehrungsrecht erteilt — die Rolle, die spaeter
|
||||||
|
-- tatsaechlich verbindet (siehe 20260909130000_rls_app_role), ist die
|
||||||
|
-- einzige, die die Ausnahme nutzen darf.
|
||||||
|
--
|
||||||
|
-- Wiederholbar: CREATE OR REPLACE FUNCTION ist idempotent, REVOKE/GRANT
|
||||||
|
-- sind es ebenfalls. Existiert tessera_app nicht (z.B. auf einer frischen
|
||||||
|
-- Installation vor 20260909130000), scheitert das GRANT laut mit einer
|
||||||
|
-- verstaendlichen Anleitung statt still zu ueberspringen — dasselbe Muster
|
||||||
|
-- wie in 20260909130000_rls_app_role/migration.sql.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'tessera_app existiert nicht. Diese Migration setzt 20260909130000_rls_app_role '
|
||||||
|
'voraus. Siehe docs/mandantentrennung-datenbankrolle.md.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- auth_lookup_user_by_username — ersetzt this.prisma.user.findUnique in
|
||||||
|
-- auth.service.ts validateUser() (Zeile 39). Liefert nur die Felder, die
|
||||||
|
-- der Anmeldeweg tatsaechlich braucht: Kennung, Benutzername, Mandant,
|
||||||
|
-- Kennwort-Hash, LDAP-DN, Aktiv-Merkmal, Rolle, Anzeigename,
|
||||||
|
-- Kennwortwechsel-Merkmal. Kein E-Mail-Feld — der Anmeldeweg braucht es
|
||||||
|
-- hier nicht.
|
||||||
|
CREATE OR REPLACE FUNCTION auth_lookup_user_by_username(p_username text)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id text,
|
||||||
|
username text,
|
||||||
|
"tenantId" text,
|
||||||
|
"passwordHash" text,
|
||||||
|
"ldapDn" text,
|
||||||
|
"isActive" boolean,
|
||||||
|
role "Role",
|
||||||
|
"displayName" text,
|
||||||
|
"mustChangePassword" boolean
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public, pg_temp
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u."tenantId",
|
||||||
|
u."passwordHash",
|
||||||
|
u."ldapDn",
|
||||||
|
u."isActive",
|
||||||
|
u.role,
|
||||||
|
u."displayName",
|
||||||
|
u."mustChangePassword"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u.username = p_username
|
||||||
|
LIMIT 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION auth_lookup_user_by_username(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_username(text) TO tessera_app;
|
||||||
|
|
||||||
|
-- auth_lookup_user_by_email — ersetzt this.prisma.user.findUnique in
|
||||||
|
-- auth.service.ts requestPasswordReset() (Zeile 144). Liefert Kennung,
|
||||||
|
-- Mandant, E-Mail und Aktiv-Merkmal; keinen Kennwort-Hash, denn dieser Pfad
|
||||||
|
-- prueft kein Kennwort.
|
||||||
|
CREATE OR REPLACE FUNCTION auth_lookup_user_by_email(p_email text)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id text,
|
||||||
|
"tenantId" text,
|
||||||
|
email text,
|
||||||
|
"isActive" boolean
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public, pg_temp
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u."tenantId",
|
||||||
|
u.email,
|
||||||
|
u."isActive"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u.email = p_email
|
||||||
|
LIMIT 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION auth_lookup_user_by_email(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_email(text) TO tessera_app;
|
||||||
|
|
||||||
|
-- auth_lookup_reset_token — ersetzt this.prisma.passwordResetToken.findUnique
|
||||||
|
-- (mit include: { user: true }) in auth.service.ts resetPassword()
|
||||||
|
-- (Zeile 178). Gleichheitsvergleich gegen das Token, liefert den
|
||||||
|
-- Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers.
|
||||||
|
-- Das Token ist eine randomUUID() und nicht erratbar (T-EOR-04).
|
||||||
|
CREATE OR REPLACE FUNCTION auth_lookup_reset_token(p_token text)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id text,
|
||||||
|
token text,
|
||||||
|
"userId" text,
|
||||||
|
"expiresAt" timestamp(3),
|
||||||
|
"usedAt" timestamp(3),
|
||||||
|
"tenantId" text
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public, pg_temp
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
prt.id,
|
||||||
|
prt.token,
|
||||||
|
prt."userId",
|
||||||
|
prt."expiresAt",
|
||||||
|
prt."usedAt",
|
||||||
|
u."tenantId"
|
||||||
|
FROM "PasswordResetToken" prt
|
||||||
|
JOIN "User" u ON u.id = prt."userId"
|
||||||
|
WHERE prt.token = p_token
|
||||||
|
LIMIT 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION auth_lookup_reset_token(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION auth_lookup_reset_token(text) TO tessera_app;
|
||||||
@@ -29,11 +29,40 @@
|
|||||||
// Verbindungszeichenkette aus.
|
// Verbindungszeichenkette aus.
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
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 ADMIN_ENV_VAR = 'TESSERA_SCRATCH_ADMIN_URL';
|
||||||
const SCRATCH_DB_NAME = 'tessera_rls_scratch';
|
const SCRATCH_DB_NAME = 'tessera_rls_scratch';
|
||||||
const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
|
const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
|
||||||
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
|
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) {
|
function fail(message) {
|
||||||
console.error(`FEHLER: ${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() {
|
async function main() {
|
||||||
const adminUrl = parseAdminUrl();
|
const adminUrl = parseAdminUrl();
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -219,8 +371,10 @@ async function main() {
|
|||||||
const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME);
|
const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME);
|
||||||
scratchRoleUrl.username = SCRATCH_ROLE_NAME;
|
scratchRoleUrl.username = SCRATCH_ROLE_NAME;
|
||||||
scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD;
|
scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD;
|
||||||
|
const scratchRoleUrlString = scratchRoleUrl.toString();
|
||||||
|
|
||||||
await runForTenantChecks(scratchRoleUrl.toString(), results);
|
await runForTenantChecks(scratchRoleUrlString, results);
|
||||||
|
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
|
||||||
await teardownScratchDatabase(adminUrl);
|
await teardownScratchDatabase(adminUrl);
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
|
||||||
|
// ist hier nicht die Pruefung) — dasselbe Muster wie in
|
||||||
|
// ldap.service.spec.ts.
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut eine Tagged-Template-Attrappe fuer prisma.$queryRaw, die die
|
||||||
|
* uebergebenen SQL-Textstuecke und interpolierten Werte aufzeichnet und ein
|
||||||
|
* konfigurierbares Ergebnis liefert — ohne laufende Datenbank.
|
||||||
|
*/
|
||||||
|
function fakeQueryRaw(resultsByCall: unknown[][]) {
|
||||||
|
let callIndex = 0;
|
||||||
|
const calls: { strings: TemplateStringsArray; values: unknown[] }[] = [];
|
||||||
|
const fn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||||
|
calls.push({ strings, values });
|
||||||
|
const result = resultsByCall[callIndex] ?? [];
|
||||||
|
callIndex += 1;
|
||||||
|
return Promise.resolve(result);
|
||||||
|
});
|
||||||
|
return { fn, calls };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
|
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
|
||||||
* have no local passwordHash and must be authenticated by binding as their own
|
* have no local passwordHash and must be authenticated by binding as their own
|
||||||
* DN against the tenant's directory. These tests cover that branch; the local
|
* DN against the tenant's directory. These tests cover that branch; the local
|
||||||
* password path (argon2) is unchanged and exercised elsewhere.
|
* password path (argon2) is unchanged and exercised elsewhere.
|
||||||
|
*
|
||||||
|
* Aufgabe 2 (260909-eor): validateUser sucht ab jetzt ueber
|
||||||
|
* auth_lookup_user_by_username() via $queryRaw statt this.prisma.user.findUnique
|
||||||
|
* — die Tests hier zeichnen $queryRaw statt user.findUnique auf.
|
||||||
*/
|
*/
|
||||||
describe('AuthService.validateUser — LDAP login', () => {
|
describe('AuthService.validateUser — LDAP login', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
let prisma: any;
|
let prisma: any;
|
||||||
let ldapService: any;
|
let ldapService: any;
|
||||||
let ldapConfigService: any;
|
let ldapConfigService: any;
|
||||||
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
||||||
|
|
||||||
const ldapUser = {
|
const ldapUser = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
@@ -24,9 +53,10 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
queryRaw = fakeQueryRaw([[ldapUser]]);
|
||||||
prisma = {
|
prisma = {
|
||||||
|
$queryRaw: queryRaw.fn,
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue(ldapUser),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -87,10 +117,8 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({
|
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
|
||||||
...ldapUser,
|
prisma.$queryRaw = queryRaw.fn;
|
||||||
ldapDn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.validateUser('alice', 'pw');
|
const result = await service.validateUser('alice', 'pw');
|
||||||
|
|
||||||
@@ -99,11 +127,176 @@ describe('AuthService.validateUser — LDAP login', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an inactive LDAP user before any bind', async () => {
|
it('rejects an inactive LDAP user before any bind', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue({ ...ldapUser, isActive: false });
|
queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]);
|
||||||
|
prisma.$queryRaw = queryRaw.fn;
|
||||||
|
|
||||||
const result = await service.validateUser('alice', 'pw');
|
const result = await service.validateUser('alice', 'pw');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
|
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lowercases the username before calling auth_lookup_user_by_username', async () => {
|
||||||
|
ldapService.verifyUserCredentials.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await service.validateUser('Alice', 'ad-password');
|
||||||
|
|
||||||
|
expect(queryRaw.calls).toHaveLength(1);
|
||||||
|
expect(queryRaw.calls[0].values).toEqual(['alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null (never throws) when auth_lookup_user_by_username finds nothing', async () => {
|
||||||
|
queryRaw = fakeQueryRaw([[]]);
|
||||||
|
prisma.$queryRaw = queryRaw.fn;
|
||||||
|
|
||||||
|
const result = await service.validateUser('unknown', 'pw');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler Kennwort-Anmeldeweg (argon2) sowie requestPasswordReset/
|
||||||
|
* resetPassword — decken die Aufgabe-2-Verhaltensfaelle ab: Anmeldesuche
|
||||||
|
* findet den Benutzer weiterhin, Schreibzugriffe laufen nach gefundenem
|
||||||
|
* Benutzer mandantengebunden.
|
||||||
|
*/
|
||||||
|
describe('AuthService.validateUser — lokales Kennwort', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: any;
|
||||||
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
||||||
|
let localUser: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
ldapDn: null;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Echter argon2-Hash statt Mock — argon2.verify laesst sich in ESM
|
||||||
|
// nicht ueber vi.spyOn ersetzen (nicht konfigurierbarer Modul-Export).
|
||||||
|
const argon2 = await import('argon2');
|
||||||
|
localUser = {
|
||||||
|
id: 'u2',
|
||||||
|
tenantId: 't1',
|
||||||
|
username: 'bob',
|
||||||
|
passwordHash: await argon2.hash('correct-password'),
|
||||||
|
ldapDn: null,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
queryRaw = fakeQueryRaw([[localUser]]);
|
||||||
|
prisma = {
|
||||||
|
$queryRaw: queryRaw.fn,
|
||||||
|
user: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet den Benutzer weiterhin und aktualisiert lastLoginAt mandantengebunden', async () => {
|
||||||
|
const result = await service.validateUser('bob', 'correct-password');
|
||||||
|
|
||||||
|
expect(result).toEqual(localUser);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'u2' },
|
||||||
|
data: { lastLoginAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthService.requestPasswordReset', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: any;
|
||||||
|
let mailService: any;
|
||||||
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
||||||
|
|
||||||
|
const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
queryRaw = fakeQueryRaw([[emailUser]]);
|
||||||
|
prisma = {
|
||||||
|
$queryRaw: queryRaw.fn,
|
||||||
|
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
|
||||||
|
await service.requestPasswordReset('bob@example.com');
|
||||||
|
|
||||||
|
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
token: expect.any(String),
|
||||||
|
userId: 'u3',
|
||||||
|
expiresAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||||
|
'bob@example.com',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kehrt bei unbekannter E-Mail wortlos zurueck (T-02-12, keine Enumeration)', async () => {
|
||||||
|
queryRaw = fakeQueryRaw([[]]);
|
||||||
|
prisma.$queryRaw = queryRaw.fn;
|
||||||
|
|
||||||
|
await service.requestPasswordReset('unknown@example.com');
|
||||||
|
|
||||||
|
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
|
||||||
|
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthService.resetPassword', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: any;
|
||||||
|
let queryRaw: ReturnType<typeof fakeQueryRaw>;
|
||||||
|
|
||||||
|
const resetTokenRow = {
|
||||||
|
id: 'rt1',
|
||||||
|
token: 'a-uuid-token',
|
||||||
|
userId: 'u4',
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
usedAt: null,
|
||||||
|
tenantId: 't1',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
queryRaw = fakeQueryRaw([[resetTokenRow]]);
|
||||||
|
prisma = {
|
||||||
|
$queryRaw: queryRaw.fn,
|
||||||
|
user: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
|
||||||
|
await service.resetPassword('a-uuid-token', 'new-password');
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'u4' },
|
||||||
|
data: { passwordHash: expect.any(String), mustChangePassword: false },
|
||||||
|
});
|
||||||
|
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'rt1' },
|
||||||
|
data: { usedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft bei unbekanntem Token', async () => {
|
||||||
|
queryRaw = fakeQueryRaw([[]]);
|
||||||
|
prisma.$queryRaw = queryRaw.fn;
|
||||||
|
|
||||||
|
await expect(service.resetPassword('unknown', 'pw')).rejects.toThrow(
|
||||||
|
'Invalid or expired reset token',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,40 @@ import { LdapConfigService } from '../ldap/ldap-config.service';
|
|||||||
import { LdapService } from '../ldap/ldap.service';
|
import { LdapService } from '../ldap/ldap.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
|
||||||
|
* (20260909160000_auth_lookup_functions). Siehe Kopf der Migration fuer die
|
||||||
|
* Begruendung der schmalen Ausnahme (T-EOR-01/T-EOR-02).
|
||||||
|
*/
|
||||||
|
interface AuthLookupUserByUsernameRow {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
tenantId: string;
|
||||||
|
passwordHash: string | null;
|
||||||
|
ldapDn: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
role: string;
|
||||||
|
displayName: string | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthLookupUserByEmailRow {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthLookupResetTokenRow {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
usedAt: Date | null;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -28,22 +62,31 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate user credentials. Uses unscoped Prisma (no tenant context)
|
* Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also
|
||||||
* because login must work across all tenants.
|
* geht die Suche ueber auth_lookup_user_by_username() (SECURITY DEFINER,
|
||||||
|
* 20260909160000_auth_lookup_functions) statt eines gewoehnlichen
|
||||||
|
* this.prisma.user.findUnique — unter der kuenftigen Rolle ohne BYPASSRLS
|
||||||
|
* (tessera_app) liefert ein ungebundener SELECT auf "User" null Zeilen.
|
||||||
|
* Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle
|
||||||
|
* Schreibzugriffe ueber forTenant(), gebunden an genau diesen Mandanten
|
||||||
|
* (WINDOWS #20, Aufgabe 1).
|
||||||
*
|
*
|
||||||
* T-02-01: Returns null on any failure (never reveals which field is wrong).
|
* T-02-01: Returns null on any failure (never reveals which field is wrong).
|
||||||
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
|
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
|
||||||
*/
|
*/
|
||||||
async validateUser(username: string, password: string): Promise<any> {
|
async validateUser(username: string, password: string): Promise<any> {
|
||||||
// Usernames are stored lowercase (case-insensitive login).
|
// Usernames are stored lowercase (case-insensitive login).
|
||||||
const user = await this.prisma.user.findUnique({
|
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
|
||||||
where: { username: username.toLowerCase() },
|
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
|
||||||
});
|
`;
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
|
||||||
|
|
||||||
// LDAP users have no local password — authenticate them against the
|
// LDAP users have no local password — authenticate them against the
|
||||||
// directory by binding as their OWN DN with the password they entered.
|
// directory by binding as their OWN DN with the password they entered.
|
||||||
if (!user.passwordHash) {
|
if (!user.passwordHash) {
|
||||||
@@ -68,7 +111,7 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
@@ -81,7 +124,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update lastLoginAt
|
// Update lastLoginAt
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
@@ -141,9 +184,10 @@ export class AuthService {
|
|||||||
* T-02-13: Single-use token with 1-hour expiry.
|
* T-02-13: Single-use token with 1-hour expiry.
|
||||||
*/
|
*/
|
||||||
async requestPasswordReset(email: string): Promise<void> {
|
async requestPasswordReset(email: string): Promise<void> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const rows = await this.prisma.$queryRaw<AuthLookupUserByEmailRow[]>`
|
||||||
where: { email },
|
SELECT * FROM auth_lookup_user_by_email(${email})
|
||||||
});
|
`;
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
// Always return success to prevent email enumeration (T-02-12)
|
// Always return success to prevent email enumeration (T-02-12)
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
@@ -157,8 +201,10 @@ export class AuthService {
|
|||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||||
|
|
||||||
// Create the reset token record
|
// Create the reset token record — mandantengebunden, sobald der
|
||||||
await this.prisma.passwordResetToken.create({
|
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
|
||||||
|
await tenantPrisma.passwordResetToken.create({
|
||||||
data: {
|
data: {
|
||||||
token,
|
token,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -175,10 +221,10 @@ export class AuthService {
|
|||||||
* T-02-13: Validates token not expired, not used. Marks as used after success.
|
* T-02-13: Validates token not expired, not used. Marks as used after success.
|
||||||
*/
|
*/
|
||||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
const rows = await this.prisma.$queryRaw<AuthLookupResetTokenRow[]>`
|
||||||
where: { token },
|
SELECT * FROM auth_lookup_reset_token(${token})
|
||||||
include: { user: true },
|
`;
|
||||||
});
|
const resetToken = rows[0];
|
||||||
|
|
||||||
if (!resetToken) {
|
if (!resetToken) {
|
||||||
throw new BadRequestException('Invalid or expired reset token');
|
throw new BadRequestException('Invalid or expired reset token');
|
||||||
@@ -194,9 +240,13 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Reset token has expired');
|
throw new BadRequestException('Reset token has expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mandant ist ab hier bekannt (aus der Funktion mitgeliefert) — beide
|
||||||
|
// Schreibzugriffe laufen gebunden (WINDOWS #20, Aufgabe 1).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId) as any;
|
||||||
|
|
||||||
// Hash the new password and update user
|
// Hash the new password and update user
|
||||||
const passwordHash = await argon2.hash(newPassword);
|
const passwordHash = await argon2.hash(newPassword);
|
||||||
await this.prisma.user.update({
|
await tenantPrisma.user.update({
|
||||||
where: { id: resetToken.userId },
|
where: { id: resetToken.userId },
|
||||||
data: {
|
data: {
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -205,7 +255,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark token as used (T-02-13)
|
// Mark token as used (T-02-13)
|
||||||
await this.prisma.passwordResetToken.update({
|
await tenantPrisma.passwordResetToken.update({
|
||||||
where: { id: resetToken.id },
|
where: { id: resetToken.id },
|
||||||
data: { usedAt: new Date() },
|
data: { usedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die auth_lookup_*-Migration (Aufgabe 2, WINDOWS #18/#20, T-EOR-01/
|
||||||
|
* T-EOR-02) rein textuell gegen die generierte migration.sql — keine
|
||||||
|
* Datenbank noetig. Baut die Hilfsfunktion aus rls-app-role.spec.ts bewusst
|
||||||
|
* nach, statt sie zu importieren (dieselbe Begruendung wie dort: die
|
||||||
|
* vorhandene ist in ihrer Datei privat).
|
||||||
|
*
|
||||||
|
* Zaehlbedingungen ueber den Migrationstext filtern Kommentarzeilen vorher
|
||||||
|
* heraus, sonst zaehlt die Begruendung im Kopf der Datei als Treffer mit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
|
||||||
|
const FUNCTION_NAMES = [
|
||||||
|
'auth_lookup_user_by_username',
|
||||||
|
'auth_lookup_user_by_email',
|
||||||
|
'auth_lookup_reset_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
function readMigrationSql(suffix: string): string {
|
||||||
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
|
||||||
|
if (dirs.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSqlComments(sql: string): string {
|
||||||
|
// Migrations verwenden ausschliesslich `--`-Zeilenkommentare, keine
|
||||||
|
// Blockkommentare — eine einfache Zeilenfilterung reicht.
|
||||||
|
return sql
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.trim().startsWith('--'))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth_lookup_functions migration.sql (Aufgabe 2, WINDOWS #18/#20)', () => {
|
||||||
|
const rawSql = readMigrationSql('_auth_lookup_functions');
|
||||||
|
const sql = stripSqlComments(rawSql);
|
||||||
|
|
||||||
|
it('legt alle drei Funktionen an', () => {
|
||||||
|
for (const name of FUNCTION_NAMES) {
|
||||||
|
expect(sql).toContain(`CREATE OR REPLACE FUNCTION ${name}(`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jede Funktion traegt SECURITY DEFINER', () => {
|
||||||
|
const occurrences = sql.match(/SECURITY DEFINER/g) ?? [];
|
||||||
|
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jede Funktion traegt STABLE', () => {
|
||||||
|
const occurrences = sql.match(/\bSTABLE\b/g) ?? [];
|
||||||
|
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jede Funktion heftet den Suchpfad fest auf public, pg_temp — der eigentliche Schutz bei SECURITY DEFINER', () => {
|
||||||
|
const occurrences = sql.match(/SET search_path = public, pg_temp/g) ?? [];
|
||||||
|
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jede Funktion begrenzt das Ergebnis auf LIMIT 1', () => {
|
||||||
|
const occurrences = sql.match(/LIMIT 1;/g) ?? [];
|
||||||
|
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entzieht fuer jede Funktion zuerst alle Rechte von PUBLIC, bevor tessera_app das Ausfuehrungsrecht bekommt', () => {
|
||||||
|
for (const name of FUNCTION_NAMES) {
|
||||||
|
const revokeIdx = sql.indexOf(`REVOKE ALL ON FUNCTION ${name}(`);
|
||||||
|
const grantIdx = sql.indexOf(`GRANT EXECUTE ON FUNCTION ${name}(`);
|
||||||
|
expect(revokeIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(grantIdx).toBeGreaterThan(revokeIdx);
|
||||||
|
expect(sql.slice(revokeIdx, revokeIdx + 200)).toContain('FROM PUBLIC');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erteilt das Ausfuehrungsrecht ausschliesslich an tessera_app — keine andere Rolle taucht in einem GRANT EXECUTE auf', () => {
|
||||||
|
const grantLines = sql
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().startsWith('GRANT EXECUTE ON FUNCTION'));
|
||||||
|
expect(grantLines.length).toBe(FUNCTION_NAMES.length);
|
||||||
|
for (const line of grantLines) {
|
||||||
|
expect(line).toContain('TO tessera_app');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => {
|
||||||
|
expect(sql).not.toMatch(/PASSWORD\s*'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keine der drei Funktionen schreibt — kein INSERT/UPDATE/DELETE/DROP im Funktionskoerper', () => {
|
||||||
|
for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP']) {
|
||||||
|
expect(sql).not.toContain(`${verb} `);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prueft vorab, dass tessera_app existiert, statt still zu ueberspringen (wiederholbares Muster wie 20260909130000)', () => {
|
||||||
|
expect(sql).toContain("pg_roles WHERE rolname = 'tessera_app'");
|
||||||
|
expect(sql).toContain('RAISE EXCEPTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auth_lookup_user_by_username liefert keinen der drei Textblock-Kandidaten fuer Kennwortfelder ausserhalb passwordHash', () => {
|
||||||
|
// Feste Regressionspruefung: der Spaltensatz ist genau der im Plan
|
||||||
|
// benannte, kein SELECT *.
|
||||||
|
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"User"/);
|
||||||
|
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"PasswordResetToken"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user