Phase B1: Anmeldung, Benutzer, Gruppen und Rollen

Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 12:31:16 +02:00
parent 94a373ff48
commit f1cdb3da13
39 changed files with 3076 additions and 64 deletions
+28 -4
View File
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -14,6 +16,11 @@ export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const WITH_RELATIONS = {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
} as const;
const updateSchema = z.object({
data: z.record(z.string(), z.unknown()).optional(),
design: designSchema.optional(),
@@ -26,7 +33,10 @@ const updateSchema = z.object({
});
async function loadCode(id: string) {
const code = await prisma.qrCode.findUnique({ where: { id } });
const code = await prisma.qrCode.findUnique({
where: { id },
include: WITH_RELATIONS,
});
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
@@ -39,7 +49,10 @@ export async function GET(
): Promise<Response> {
try {
const { id } = await context.params;
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
const actor = await getActor();
const code = await loadCode(id);
requirePermission(actor, "codes:read", { groupId: code.groupId });
return Response.json({ code: toQrCodeDto(code) });
} catch (error) {
return errorResponse(error);
}
@@ -52,12 +65,16 @@ export async function PATCH(
): Promise<Response> {
try {
const { id } = await context.params;
const actor = await getActor();
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await loadCode(id);
requirePermission(actor, "codes:update", { groupId: existing.groupId });
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
@@ -91,7 +108,11 @@ export async function PATCH(
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({ where: { id }, data: update });
const updated = await prisma.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
@@ -104,7 +125,10 @@ export async function DELETE(
): Promise<Response> {
try {
const { id } = await context.params;
await loadCode(id);
const actor = await getActor();
const existing = await loadCode(id);
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
await prisma.qrCode.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {