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:
2026-09-09 10:56:41 +02:00
parent bbf179503c
commit de50297467
5 changed files with 692 additions and 25 deletions
@@ -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;
+155 -1
View File
@@ -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);
+199 -6
View File
@@ -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',
);
});
}); });
+68 -18
View File
@@ -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"/);
});
});