diff --git a/apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql b/apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql new file mode 100644 index 0000000..1d70f55 --- /dev/null +++ b/apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql @@ -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; diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index c75205f..91b607e 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -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); diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 29641e9..f5b2f3d 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -1,17 +1,46 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 * 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 * 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', () => { let service: AuthService; let prisma: any; let ldapService: any; let ldapConfigService: any; + let queryRaw: ReturnType; const ldapUser = { id: 'u1', @@ -24,9 +53,10 @@ describe('AuthService.validateUser — LDAP login', () => { beforeEach(() => { vi.clearAllMocks(); + queryRaw = fakeQueryRaw([[ldapUser]]); prisma = { + $queryRaw: queryRaw.fn, user: { - findUnique: vi.fn().mockResolvedValue(ldapUser), 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 () => { - prisma.user.findUnique.mockResolvedValue({ - ...ldapUser, - ldapDn: null, - }); + queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]); + prisma.$queryRaw = queryRaw.fn; 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 () => { - prisma.user.findUnique.mockResolvedValue({ ...ldapUser, isActive: false }); + queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]); + prisma.$queryRaw = queryRaw.fn; const result = await service.validateUser('alice', 'pw'); expect(result).toBeNull(); 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; + 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; + + 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; + + 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', + ); + }); }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 7f83bb7..327466a 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -13,6 +13,40 @@ import { LdapConfigService } from '../ldap/ldap-config.service'; import { LdapService } from '../ldap/ldap.service'; import { MailService } from '../mail/mail.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() export class AuthService { @@ -28,22 +62,31 @@ export class AuthService { ) {} /** - * Validate user credentials. Uses unscoped Prisma (no tenant context) - * because login must work across all tenants. + * Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also + * 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). * Pitfall 6: Checks isActive to prevent deactivated users from logging in. */ async validateUser(username: string, password: string): Promise { // Usernames are stored lowercase (case-insensitive login). - const user = await this.prisma.user.findUnique({ - where: { username: username.toLowerCase() }, - }); + const rows = await this.prisma.$queryRaw` + SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()}) + `; + const user = rows[0]; if (!user || !user.isActive) { return null; } + const tenantPrisma = forTenant(this.prisma, user.tenantId) as any; + // LDAP users have no local password — authenticate them against the // directory by binding as their OWN DN with the password they entered. if (!user.passwordHash) { @@ -68,7 +111,7 @@ export class AuthService { return null; } - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); @@ -81,7 +124,7 @@ export class AuthService { } // Update lastLoginAt - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); @@ -141,9 +184,10 @@ export class AuthService { * T-02-13: Single-use token with 1-hour expiry. */ async requestPasswordReset(email: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + const rows = await this.prisma.$queryRaw` + SELECT * FROM auth_lookup_user_by_email(${email}) + `; + const user = rows[0]; // Always return success to prevent email enumeration (T-02-12) if (!user || !user.isActive) { @@ -157,8 +201,10 @@ export class AuthService { const token = randomUUID(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour - // Create the reset token record - await this.prisma.passwordResetToken.create({ + // Create the reset token record — mandantengebunden, sobald der + // 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: { token, userId: user.id, @@ -175,10 +221,10 @@ export class AuthService { * T-02-13: Validates token not expired, not used. Marks as used after success. */ async resetPassword(token: string, newPassword: string): Promise { - const resetToken = await this.prisma.passwordResetToken.findUnique({ - where: { token }, - include: { user: true }, - }); + const rows = await this.prisma.$queryRaw` + SELECT * FROM auth_lookup_reset_token(${token}) + `; + const resetToken = rows[0]; if (!resetToken) { throw new BadRequestException('Invalid or expired reset token'); @@ -194,9 +240,13 @@ export class AuthService { 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 const passwordHash = await argon2.hash(newPassword); - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: resetToken.userId }, data: { passwordHash, @@ -205,7 +255,7 @@ export class AuthService { }); // Mark token as used (T-02-13) - await this.prisma.passwordResetToken.update({ + await tenantPrisma.passwordResetToken.update({ where: { id: resetToken.id }, data: { usedAt: new Date() }, }); diff --git a/apps/api/src/prisma/auth-lookup-functions.spec.ts b/apps/api/src/prisma/auth-lookup-functions.spec.ts new file mode 100644 index 0000000..9589dbf --- /dev/null +++ b/apps/api/src/prisma/auth-lookup-functions.spec.ts @@ -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"/); + }); +});