Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+84 -67
View File
@@ -1,8 +1,10 @@
"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import {
Prisma,
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
@@ -17,6 +19,8 @@ import { z } from "zod";
import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { sendAppEmail } from "@/lib/mail";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
// =====================================================================
@@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma";
// =====================================================================
const terminSchema = z.object({
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().trim().optional(),
title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160),
description: z.string().trim().max(20000).optional(),
type: z.nativeEnum(TerminType),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
@@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = {
async function getSwapAssignment(
assignmentId: string,
kitaId: string,
db: Prisma.TransactionClient = prisma,
): Promise<SwapAssignmentWithFamily | null> {
return prisma.notdienstAssignment.findFirst({
return db.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
select: {
id: true,
@@ -114,7 +119,7 @@ async function getSwapAssignment(
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: {
email: true,
firstName: true,
@@ -295,6 +300,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
export async function createNotdienstSwapRequest(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
if (session.user.role !== UserRole.ELTERN) {
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
}
@@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
const today = getTodayUtcDateOnly();
try {
const [targetAssignment, offeredAssignment, kita, existingRequest] =
await Promise.all([
getSwapAssignment(targetAssignmentId, session.user.kitaId),
getSwapAssignment(offeredAssignmentId, session.user.kitaId),
prisma.kita.findUnique({
where: { id: session.user.kitaId },
select: { name: true },
}),
prisma.notdienstSwapRequest.findFirst({
where: {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId },
{ offeredAssignmentId },
{ targetAssignmentId: offeredAssignmentId },
{ offeredAssignmentId: targetAssignmentId },
],
},
select: { id: true },
}),
]);
const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => {
const [targetAssignment, offeredAssignment, kita, existingRequest] =
await Promise.all([
getSwapAssignment(targetAssignmentId, session.user.kitaId, tx),
getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx),
tx.kita.findUnique({
where: { id: session.user.kitaId },
select: { name: true },
}),
tx.notdienstSwapRequest.findFirst({
where: {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ targetAssignmentId },
{ offeredAssignmentId },
{ targetAssignmentId: offeredAssignmentId },
{ offeredAssignmentId: targetAssignmentId },
],
},
select: { id: true },
}),
]);
const validationError = validateSwapAssignmentPair({
targetAssignment,
offeredAssignment,
requesterFamilyId: session.user.familyId,
today,
});
if (validationError) {
return { error: validationError };
}
if (!targetAssignment || !offeredAssignment || !kita) {
return { error: "Notdienst nicht gefunden." };
}
if (existingRequest) {
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
}
const validationError = validateSwapAssignmentPair({
targetAssignment,
offeredAssignment,
requesterFamilyId: session.user.familyId!,
today,
});
if (validationError) {
throw new UserFacingError(validationError);
}
if (!targetAssignment || !offeredAssignment || !kita) {
throw new UserFacingError("Notdienst nicht gefunden.");
}
if (existingRequest) {
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
}
await prisma.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
requesterUserId: session.user.id,
requesterFamilyId: session.user.familyId,
targetAssignmentId,
offeredAssignmentId,
message: message || null,
},
await tx.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
requesterUserId: session.user.id,
requesterFamilyId: session.user.familyId!,
targetAssignmentId,
offeredAssignmentId,
message: message || null,
},
});
return { targetAssignment, offeredAssignment, kita };
});
await notifyFamilyAboutSwap({
@@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
}
return { error: "Tauschanfrage konnte nicht erstellt werden." };
}
}
@@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
const today = getTodayUtcDateOnly();
try {
const result = await prisma.$transaction(async (tx) => {
const result = await serializableTransaction(async (tx) => {
const request = await tx.notdienstSwapRequest.findFirst({
where: {
id: parsed.data,
@@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
});
if (!request) {
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen.");
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
}
const validationError = validateSwapAssignmentPair({
@@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
today,
});
if (validationError) {
throw new Error(validationError);
throw new UserFacingError(validationError);
}
await tx.notdienstAssignment.update({
@@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
where: { id: request.offeredAssignmentId },
data: { childId: request.targetAssignment.childId },
});
await tx.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await tx.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.ACCEPTED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen.");
await tx.notdienstSwapRequest.updateMany({
where: {
id: { not: request.id },
@@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."),
};
}
}
@@ -585,7 +598,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
select: {
name: true,
users: {
where: { role: UserRole.ELTERN },
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
@@ -622,14 +635,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
}
await prisma.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.DECLINED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
const kita = await prisma.kita.findUnique({
where: { id: session.user.kitaId },
@@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
await prisma.notdienstSwapRequest.update({
where: { id: request.id },
const responded = await prisma.notdienstSwapRequest.updateMany({
where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING },
data: {
status: NotdienstSwapStatus.CANCELLED,
respondedById: session.user.id,
respondedAt: new Date(),
},
});
if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." };
revalidateCalendarAndNotdienst();
return { success: true };
@@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
@@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
const mitbringselSchema = z.object({
terminId: z.string(),
content: z.string().min(1, "Eintrag darf nicht leer sein"),
content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500),
});
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {