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
+68 -18
View File
@@ -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<any> {
// Usernames are stored lowercase (case-insensitive login).
const user = await this.prisma.user.findUnique({
where: { username: username.toLowerCase() },
});
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
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<void> {
const user = await this.prisma.user.findUnique({
where: { email },
});
const rows = await this.prisma.$queryRaw<AuthLookupUserByEmailRow[]>`
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<void> {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token },
include: { user: true },
});
const rows = await this.prisma.$queryRaw<AuthLookupResetTokenRow[]>`
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() },
});