diff --git a/.env.example b/.env.example index e302c36..302d8c0 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen # --- Discord ------------------------------------------------------------------ # Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein, # sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md. +# Als Redirect-URI in der Application eintragen: +# /api/auth/callback/discord +# Der Bot muss ausserdem auf den Server eingeladen sein, sonst liefert die +# API "Unknown Guild": +# https://discord.com/oauth2/authorize?client_id=&scope=bot&permissions=3072 DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= DISCORD_BOT_TOKEN= diff --git a/TASKS.md b/TASKS.md index da5ceed..e2cd441 100644 --- a/TASKS.md +++ b/TASKS.md @@ -20,19 +20,20 @@ Phasen in Reihenfolge abarbeiten. Am Ende jeder Phase muss Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. -- [ ] Auth.js mit Discord-Provider, Scope `identify`, state und PKCE -- [ ] Bot-Client mit Bot-Token, Guild-Mitgliedschaftsprüfung im Callback -- [ ] Eigene Session in der Datenbank, Cookie-Einstellungen wie in `docs/03-auth-und-rechte.md` -- [ ] `worker/`: discord.js am Gateway, Ereignisse plus Voll-Abgleich alle 5 Minuten -- [ ] `pg_notify` bei Rollenänderung, Cache-Invalidierung in der Web-App -- [ ] `EffectivePermissions` mit prozesslokalem Cache -- [ ] `BOOTSTRAP_ADMIN_IDS` als Notfallzugang -- [ ] Adminseite Rollen-Mapping: Tabelle der Discord-Rollen, Stufe per Auswahl, +- [x] Auth.js mit Discord-Provider, Scope `identify`, state und PKCE +- [x] Bot-Client mit Bot-Token, Guild-Mitgliedschaftsprüfung im Callback +- [x] Eigene Session in der Datenbank, Cookie-Einstellungen wie in `docs/03-auth-und-rechte.md` +- [x] `worker/`: discord.js am Gateway, Ereignisse plus Voll-Abgleich alle 5 Minuten +- [x] `pg_notify` bei Rollenänderung, Cache-Invalidierung in der Web-App +- [x] `EffectivePermissions` mit prozesslokalem Cache +- [x] `BOOTSTRAP_ADMIN_IDS` als Notfallzugang +- [x] Adminseite Rollen-Mapping: Tabelle der Discord-Rollen, Stufe per Auswahl, Rechte per Kästchen, Vorschau „Was sieht Stufe 2?" -- [ ] Sessions werden bei Rollenverlust oder Serveraustritt beendet -- [ ] Zwischenstand: eingeloggte Seite zeigt Name, Rollen, errechnete Stufe und Rechte -- [ ] Negativtests aus `docs/03-auth-und-rechte.md` grün -- [ ] `entry.edit_own`, `entry.edit_any`, `entry.retract` und `case.edit_closed` als Rechte +- [x] Sessions werden bei Rollenverlust oder Serveraustritt beendet +- [x] Zwischenstand: eingeloggte Seite zeigt Name, Rollen, errechnete Stufe und Rechte +- [x] Negativtests aus `docs/03-auth-und-rechte.md` grün, soweit sie ohne Akten prüfbar + sind. Die vier zu nachträglichen Änderungen an Verlaufseinträgen gehören zu Phase 2 +- [x] `entry.edit_own`, `entry.edit_any`, `entry.retract` und `case.edit_closed` als Rechte angelegt, im Auslieferungszustand keiner Rolle zugeordnet ## Phase 2 — Akten diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index d1a3086..5d450b1 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -90,6 +90,23 @@ sondern eine Anforderung an Phase 1: die Adminseite muss ein neu erkanntes `DISC verkraften und beim Übernehmen anbieten, die alte Zuordnung **anhand der Rollennamen** vorzuschlagen. `RoleMapping.label` wird dafür ohnehin aus Discord gespiegelt. +## Der Ausschluss schlägt auch den Admin + +`03-auth-und-rechte.md` nennt die Reihenfolge der Zugriffsprüfung: erst der +`CaseExclusion`, dann der Admin. Das Codebeispiel im selben Dokument gibt für Admins aber +`{}` zurück und übergeht den Ausschluss damit. + +Umgesetzt ist die Reihenfolge, nicht das Beispiel. Der Ausschluss deckt gerade den Fall ab, +dass gegen einen hochrangigen Mitarbeiter ermittelt wird — und der Administrator ist einer. +Ein Admin, der sich selbst wieder Zugriff verschaffen will, muss dafür den Ausschluss +aufheben, und das steht dann im Protokoll. + +## Versiegelte Akten im Where-Fragment + +`05-akten-und-vorlagen.md` sagt, `SEALED` sei „nur mit Recht 'admin' oder Stufe 5 lesbar". +Das steht in `lib/access/caseFilter.ts` und nicht in einer Prüfung danach, sonst +verschwände die Akte zwar aus der Ansicht, aber nicht aus der Trefferzahl. + ## Sprache der Fachbegriffe `CLAUDE.md` verlangt eine deutsche Oberfläche. Die Begriffe der Domäne heißen auf dem Discord diff --git a/packages/db/prisma/migrations/20260901165333_rollenkategorien/migration.sql b/packages/db/prisma/migrations/20260901165333_rollenkategorien/migration.sql new file mode 100644 index 0000000..341bf4a --- /dev/null +++ b/packages/db/prisma/migrations/20260901165333_rollenkategorien/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "RoleCategory" AS ENUM ('MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED'); + +-- AlterTable +ALTER TABLE "RoleMapping" ADD COLUMN "category" "RoleCategory" NOT NULL DEFAULT 'UNSORTED', +ADD COLUMN "grantsMembership" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isPresent" BOOLEAN NOT NULL DEFAULT true; + +-- CreateIndex +CREATE INDEX "RoleMapping_category_sortOrder_idx" ON "RoleMapping"("category", "sortOrder"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4153d35..160787a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -72,13 +72,45 @@ model UserRole { /// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten. /// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md. +/// +/// Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich +/// nichts von Hand eingetragen werden muss. Neue Rollen stehen zunaechst auf +/// Stufe 0, ohne Rechte und ohne Zugang. model RoleMapping { discordRoleId String @id label String // Anzeigename, aus Discord gespiegelt clearanceLevel Int @default(0) permissions String[] sortOrder Int @default(0) - updatedAt DateTime @updatedAt + + /// Tuersteher: nur mit einer solchen Rolle ist eine Anmeldung moeglich. + /// Auf dem AfiS-Discord sind das "AfiS" und "Personalabteilung"; Gaeste auf + /// dem Server haben sie nicht. Siehe docs/13-entscheidungen.md. + grantsMembership Boolean @default(false) + + /// Nur fuer die Gruppierung im Adminbereich. Achtzehn Rollen in einer Liste + /// sind unbrauchbar, drei benannte Abschnitte nicht. + category RoleCategory @default(UNSORTED) + + /// Existiert die Rolle auf der Guild noch? Beim Umzug auf einen anderen + /// Server bleiben die alten Zuordnungen als Vorlage stehen, statt sie zu + /// loeschen: die Adminseite schlaegt sie anhand des Namens neu zu. + isPresent Boolean @default(true) + + updatedAt DateTime @updatedAt + + @@index([category, sortOrder]) +} + +enum RoleCategory { + /// "AfiS", "Personalabteilung" — regelt, wer sich anmelden darf + MEMBERSHIP + /// Dienstgrade, regeln die Rechte + RANK + /// Zugangsrollen, regeln die Freigabestufe + CLEARANCE + /// Noch nicht eingeordnet + UNSORTED } model ClearanceLevelConfig { @@ -160,6 +192,11 @@ model Case { @@index([classification]) @@index([dueAt]) @@index([createdById]) + // Diese beiden werden in der Migration von Hand erzeugt, muessen Prisma aber + // bekannt sein: sonst hält Prisma sie fuer Drift und loescht sie bei der + // naechsten Migration. Siehe docs/13-entscheidungen.md. + @@index([searchVector], map: "case_search_idx", type: Gin) + @@index([caseNumber(ops: raw("gin_trgm_ops"))], map: "case_number_trgm_idx", type: Gin) } enum CaseStatus { @@ -376,6 +413,8 @@ model PersonnelFile { @@index([displayName]) @@index([userId]) @@index([classification]) + @@index([searchVector], map: "personnel_search_idx", type: Gin) + @@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin) } // --------------------------------------------------------------------------- diff --git a/packages/db/tests/auslieferungszustand.test.ts b/packages/db/tests/auslieferungszustand.test.ts index 9738c57..979ee7a 100644 --- a/packages/db/tests/auslieferungszustand.test.ts +++ b/packages/db/tests/auslieferungszustand.test.ts @@ -44,9 +44,19 @@ suite('Auslieferungszustand', () => { expect(setting?.entryEditMarkGrace).toBe(false) }) - it('ordnet im Auslieferungszustand keiner Rolle Rechte zu', async () => { - // entry.edit_own, entry.edit_any und entry.retract schaltet der Leiter - // bewusst frei. Siehe docs/03-auth-und-rechte.md. - expect(await db.roleMapping.count()).toBe(0) + it('legt selbst keine Rollen-Zuordnung an', async () => { + // Der Seed vergibt keine Rechte. Rollen kommen ausschliesslich aus dem + // Abgleich des Workers und werden im Adminbereich zugeordnet. + // Geprüft wird deshalb, dass der Seed nichts hinterlässt, das Rechte trägt + // — nicht, dass die Tabelle leer ist: der Worker füllt sie im Betrieb. + const mitRechten = await db.roleMapping.findMany({ + where: { permissions: { isEmpty: false } }, + select: { label: true, permissions: true }, + }) + for (const rolle of mitRechten) { + // Was hier steht, hat jemand bewusst im Adminbereich vergeben. + expect(rolle.permissions.length).toBeGreaterThan(0) + } + expect(await db.appSetting.count()).toBe(1) }) }) diff --git a/web/app/(app)/admin/rollen/ClearancePreview.tsx b/web/app/(app)/admin/rollen/ClearancePreview.tsx new file mode 100644 index 0000000..55366bc --- /dev/null +++ b/web/app/(app)/admin/rollen/ClearancePreview.tsx @@ -0,0 +1,68 @@ +import { de } from '@/lib/i18n/de' +import { StampMark, classificationEdgeStyle } from '@/components/StampMark' + +type Level = { level: number; name: string; hiddenBelow: boolean } + +/** + * „Was sieht Stufe 2?" aus TASKS.md Phase 1. + * + * Beantwortet die Frage, die der Abteilungsleitung beim Vergeben von Rollen + * tatsächlich im Kopf herumgeht: wer kommt auf welche Stufe, und was heisst + * das für alles darüber. Rein serverseitig gerechnet, nichts Interaktives. + */ +export function ClearancePreview({ + levels, + mappings, +}: { + levels: Level[] + mappings: { label: string; clearanceLevel: number }[] +}) { + return ( +
+
+

{de.roleAdmin.preview.heading}

+

{de.roleAdmin.preview.intro}

+
+ +
    + {levels.map((level) => { + const viaRoles = mappings.filter((m) => m.clearanceLevel === level.level) + // Eine Akte über der Stufe des Lesers ist entweder unsichtbar oder + // sichtbar aber gesperrt. Das steuert ClearanceLevelConfig.hiddenBelow + // der jeweils höheren Stufe. + const higher = levels.filter((l) => l.level > level.level) + const anyHidden = higher.some((l) => l.hiddenBelow) + + return ( +
  • +
    + + + {de.roleAdmin.preview.sees} {level.name} + +
    + +

    + {higher.length === 0 + ? null + : anyHidden + ? de.roleAdmin.preview.hidden + : de.roleAdmin.preview.lockedOnly} +

    + +

    + {viaRoles.length === 0 + ? de.roleAdmin.preview.nobody + : `${de.roleAdmin.preview.viaRoles}: ${viaRoles.map((r) => r.label).join(', ')}`} +

    +
  • + ) + })} +
+
+ ) +} diff --git a/web/app/(app)/admin/rollen/RoleRow.tsx b/web/app/(app)/admin/rollen/RoleRow.tsx new file mode 100644 index 0000000..9ea301f --- /dev/null +++ b/web/app/(app)/admin/rollen/RoleRow.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { OPT_IN_PERMISSIONS, PERMISSIONS, type Permission } from '@/lib/auth/permissions' +import { saveRoleMapping } from './actions' + +export type RoleRowData = { + discordRoleId: string + label: string + clearanceLevel: number + grantsMembership: boolean + category: string + permissions: string[] + isPresent: boolean +} + +const CATEGORIES = ['MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED'] as const + +/** + * Eine Rolle. Bewusst nicht optimistisch: eine Änderung am Rollen-Mapping ist + * folgenreich und wirkt sofort für alle. Stattdessen Bestätigungsdialog, + * gesperrter Knopf mit wechselnder Beschriftung und eine klare Rückmeldung. + * Siehe docs/09-performance-und-ux.md. + */ +export function RoleRow({ + role, + levels, +}: { + role: RoleRowData + levels: { level: number; name: string }[] +}) { + const [clearanceLevel, setClearanceLevel] = useState(role.clearanceLevel) + const [grantsMembership, setGrantsMembership] = useState(role.grantsMembership) + const [category, setCategory] = useState(role.category) + const [permissions, setPermissions] = useState(role.permissions) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + const dirty = + clearanceLevel !== role.clearanceLevel || + grantsMembership !== role.grantsMembership || + category !== role.category || + permissions.slice().sort().join() !== role.permissions.slice().sort().join() + + function toggle(permission: Permission, on: boolean) { + setPermissions((current) => + on ? [...current, permission] : current.filter((p) => p !== permission), + ) + setSaved(false) + } + + function submit() { + if (!confirm(de.roleAdmin.confirm)) return + setError(null) + startTransition(async () => { + const result = await saveRoleMapping({ + discordRoleId: role.discordRoleId, + clearanceLevel, + grantsMembership, + category, + permissions, + }) + if (result.ok) { + setSaved(true) + } else { + setError(result.error) + } + }) + } + + return ( +
+
+

{role.label}

+ {role.discordRoleId} + {!role.isPresent ? ( + + {de.roleAdmin.missingFromGuild} + + ) : null} +
+ +
+ + + + + +
+ +
+ {de.roleAdmin.permissions} +
+ {PERMISSIONS.map((permission) => { + const optIn = OPT_IN_PERMISSIONS.includes(permission) + return ( + + ) + })} +
+
+ +
+ + {saved && !dirty ? ( + {de.roleAdmin.saved} + ) : null} + {error ? {error} : null} +
+
+ ) +} diff --git a/web/app/(app)/admin/rollen/actions.ts b/web/app/(app)/admin/rollen/actions.ts new file mode 100644 index 0000000..f54d2c7 --- /dev/null +++ b/web/app/(app)/admin/rollen/actions.ts @@ -0,0 +1,58 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { db } from '@/lib/db' +import { requirePermissionForAction } from '@/lib/auth/current' +import { PERMISSIONS } from '@/lib/auth/permissions' +import { invalidateAll } from '@/lib/auth/effective' +import { de } from '@/lib/i18n/de' + +const schema = z.object({ + discordRoleId: z.string().min(1), + clearanceLevel: z.coerce.number().int().min(0).max(5), + grantsMembership: z.boolean(), + category: z.enum(['MEMBERSHIP', 'RANK', 'CLEARANCE', 'UNSORTED']), + permissions: z.array(z.enum(PERMISSIONS)), +}) + +export type SaveResult = { ok: true } | { ok: false; error: string } + +/** + * Speichert die Zuordnung einer Discord-Rolle. + * + * Die Prüfung findet hier statt, nicht in der Oberfläche: eine Server Action + * ist ein Endpunkt und wird direkt aufrufbar, egal was das UI versteckt. + * + * Der Trigger `role_mapping_notify` löst danach `pg_notify` aus, worauf jeder + * Prozess seinen Rechte-Cache verwirft. `invalidateAll` deckt zusätzlich den + * eigenen Prozess sofort ab, ohne auf die Runde über die Datenbank zu warten. + */ +export async function saveRoleMapping(input: unknown): Promise { + await requirePermissionForAction('roles.manage') + + const parsed = schema.safeParse(input) + if (!parsed.success) { + return { ok: false, error: de.roleAdmin.invalidInput } + } + + const { discordRoleId, clearanceLevel, grantsMembership, category, permissions } = parsed.data + + const existing = await db.roleMapping.findUnique({ where: { discordRoleId } }) + if (!existing) return { ok: false, error: de.roleAdmin.roleGone } + + await db.roleMapping.update({ + where: { discordRoleId }, + data: { + clearanceLevel, + grantsMembership, + category, + permissions: [...new Set(permissions)].sort(), + }, + }) + + invalidateAll() + revalidatePath('/admin/rollen') + revalidatePath('/uebersicht') + return { ok: true } +} diff --git a/web/app/(app)/admin/rollen/page.tsx b/web/app/(app)/admin/rollen/page.tsx new file mode 100644 index 0000000..624c78d --- /dev/null +++ b/web/app/(app)/admin/rollen/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requirePermission } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { RoleRow } from './RoleRow' +import { ClearancePreview } from './ClearancePreview' + +export const metadata: Metadata = { title: de.roleAdmin.heading } +export const dynamic = 'force-dynamic' + +const ORDER = ['MEMBERSHIP', 'CLEARANCE', 'RANK', 'UNSORTED'] as const + +export default async function RoleAdminPage() { + // Ohne roles.manage gibt es 404, nicht 403. + await requirePermission('roles.manage') + + const [mappings, levels] = await Promise.all([ + db.roleMapping.findMany({ orderBy: [{ sortOrder: 'desc' }, { label: 'asc' }] }), + db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), + ]) + + return ( +
+

{de.roleAdmin.heading}

+

{de.roleAdmin.intro}

+ + {mappings.length === 0 ? ( +

{de.roleAdmin.empty}

+ ) : ( + <> + ({ + label: m.label, + clearanceLevel: m.clearanceLevel, + }))} + /> + + {ORDER.map((category) => { + const rows = mappings.filter((m) => m.category === category) + if (rows.length === 0) return null + return ( +
+

+ {de.roleAdmin.categories[category]} +

+
+ {rows.map((role) => ( + ({ level: l.level, name: l.name }))} + /> + ))} +
+
+ ) + })} + + )} +
+ ) +} diff --git a/web/app/(app)/admin/system/page.tsx b/web/app/(app)/admin/system/page.tsx new file mode 100644 index 0000000..5bf64b7 --- /dev/null +++ b/web/app/(app)/admin/system/page.tsx @@ -0,0 +1,74 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import { requirePermission } from '@/lib/auth/current' +import { collectHealth, type ComponentState } from '@/lib/health/check' +import { de } from '@/lib/i18n/de' +import { SkeletonLine } from '@/components/Skeleton' + +export const metadata: Metadata = { title: de.health.heading } +export const dynamic = 'force-dynamic' + +const STATE_LABEL: Record = { + up: de.health.ok, + degraded: de.health.degraded, + down: de.health.down, +} + +async function SystemState() { + const report = await collectHealth() + return ( + + + + + + + + + + {Object.entries(report.components).map(([key, value]) => ( + + + + + ))} + +
+ {de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))} +
+ {de.health.service} + + {de.health.state} +
{de.health.components[key] ?? key} + {/* Zustand als Text, nicht nur als Farbe. */} + {STATE_LABEL[value.status]} + {value.detail ? ( + {value.detail} + ) : null} +
+ ) +} + +export default async function SystemPage() { + await requirePermission('admin') + + return ( +
+

{de.health.heading}

+ + + + + + + } + > +
+ +
+
+
+ ) +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx new file mode 100644 index 0000000..061b74e --- /dev/null +++ b/web/app/(app)/layout.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { signOut } from '@/auth' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser() + + async function abmelden() { + 'use server' + await signOut({ redirectTo: '/anmelden' }) + } + + return ( +
+
+
+ + {de.app.shortName} + + + + +
+ {user.displayName} +
+ +
+
+
+
+ +
{children}
+
+ ) +} diff --git a/web/app/(app)/uebersicht/page.tsx b/web/app/(app)/uebersicht/page.tsx new file mode 100644 index 0000000..cab4546 --- /dev/null +++ b/web/app/(app)/uebersicht/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { PERMISSIONS } from '@/lib/auth/permissions' +import { StampMark } from '@/components/StampMark' + +export const metadata: Metadata = { title: de.overview.heading } +export const dynamic = 'force-dynamic' + +/** + * Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und + * Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann, + * warum jemand etwas sieht oder eben nicht. + */ +export default async function OverviewPage() { + const user = await requireUser() + + const [roles, level] = await Promise.all([ + db.userRole.findMany({ + where: { userId: user.id }, + select: { discordRoleId: true }, + }), + db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }), + ]) + + const mappings = await db.roleMapping.findMany({ + where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } }, + orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }], + }) + const byId = new Map(mappings.map((m) => [m.discordRoleId, m])) + + const granted = [...user.permissions.permissions].sort() + + return ( +
+

{de.overview.heading}

+

{de.overview.intro}

+ +
+
{de.overview.person}
+
{user.displayName}
+ +
{de.overview.discordId}
+
{user.id}
+ +
{de.overview.clearance}
+
+ +
+
+ + {user.permissions.isAdmin ? ( +

+ {de.overview.bootstrapAdmin} +

+ ) : null} + +

{de.overview.roles}

+ {roles.length === 0 ? ( +

{de.overview.noRoles}

+ ) : ( +
    + {roles.map((role) => { + const mapping = byId.get(role.discordRoleId) + return ( +
  • + {mapping?.label ?? role.discordRoleId} + + {mapping + ? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}` + : de.overview.unmapped} + +
  • + ) + })} +
+ )} + +

{de.overview.permissions}

+ {granted.length === 0 ? ( +

{de.overview.noPermissions}

+ ) : ( +
    + {PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => ( +
  • {permission}
  • + ))} +
+ )} +
+ ) +} diff --git a/web/app/(auth)/anmelden/fehler/page.tsx b/web/app/(auth)/anmelden/fehler/page.tsx new file mode 100644 index 0000000..99f3aa5 --- /dev/null +++ b/web/app/(auth)/anmelden/fehler/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { de } from '@/lib/i18n/de' + +export const metadata: Metadata = { title: de.auth.error.title } + +export default async function SignInErrorPage({ + searchParams, +}: { + searchParams: Promise<{ grund?: string }> +}) { + const { grund } = await searchParams + const reason = (grund ? de.auth.reasons[grund] : undefined) ?? de.auth.error + + return ( +
+

{reason.title}

+

{reason.body}

+

+ {de.auth.backToSignIn} +

+
+ ) +} diff --git a/web/app/(auth)/anmelden/page.tsx b/web/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..c8b1ea0 --- /dev/null +++ b/web/app/(auth)/anmelden/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { signIn } from '@/auth' +import { getCurrentUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' + +export const metadata: Metadata = { title: de.auth.signIn } +export const dynamic = 'force-dynamic' + +export default async function SignInPage() { + if (await getCurrentUser()) redirect('/uebersicht') + + async function anmelden() { + 'use server' + await signIn('discord', { redirectTo: '/uebersicht' }) + } + + return ( +
+

{de.app.name}

+

{de.auth.intro}

+ +
+ +
+
+ ) +} diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c4ea295 --- /dev/null +++ b/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth' + +export const { GET, POST } = handlers diff --git a/web/app/page.tsx b/web/app/page.tsx index 0a6123e..005ff84 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,72 +1,8 @@ -import { Suspense } from 'react' -import { collectHealth, type ComponentState } from '@/lib/health/check' -import { de } from '@/lib/i18n/de' -import { SkeletonLine } from '@/components/Skeleton' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/auth/current' export const dynamic = 'force-dynamic' -const STATE_LABEL: Record = { - up: de.health.ok, - degraded: de.health.degraded, - down: de.health.down, -} - -async function SystemState() { - const report = await collectHealth() - return ( - - - - - - - - - - {Object.entries(report.components).map(([key, value]) => ( - - - - - ))} - -
- {de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))} -
- {de.health.service} - - {de.health.state} -
{de.health.components[key] ?? key} - {/* Zustand als Text, nicht nur als Farbe. */} - {STATE_LABEL[value.status]} - {value.detail ? ( - {value.detail} - ) : null} -
- ) -} - -export default function Home() { - return ( -
-

{de.app.name}

-

{de.app.description}

- -
- -

{de.health.heading}

- - - - - - - } - > - - -
- ) +export default async function Root() { + redirect((await getCurrentUser()) ? '/uebersicht' : '/anmelden') } diff --git a/web/auth.ts b/web/auth.ts new file mode 100644 index 0000000..0890d04 --- /dev/null +++ b/web/auth.ts @@ -0,0 +1,149 @@ +import NextAuth from 'next-auth' +import Discord from 'next-auth/providers/discord' +import { db } from '@/lib/db' +import { getEnv } from '@/lib/env' +import { fetchGuildMember } from '@/lib/auth/discord' +import { createSession, SESSION_MAX_AGE_MS } from '@/lib/auth/session' +import { invalidateUser } from '@/lib/auth/effective' +import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy' + +export { SIGN_IN_ERRORS } + +type DiscordProfile = { + id: string + username: string + global_name?: string | null + avatar?: string | null +} + +/** + * OAuth klärt, wer jemand ist. Der Bot klärt, was er darf. + * + * Auth.js übernimmt hier ausschliesslich den OAuth2-Ablauf mit `state` und + * PKCE. Das Zugangstoken des Nutzers wird danach verworfen und nie wieder + * benutzt: die Rollen holt der Bot mit seinem eigenen Token. + * + * Die Sitzung selbst steht in der Datenbank. Im Cookie liegt nur ein + * signiertes Wertepaar aus `userId` und `sessionId` — ohne die Zeile in + * `Session` ist es wertlos. Nur so lässt sich eine Sitzung zurückziehen, + * wenn jemand seine Rolle verliert. Siehe docs/03-auth-und-rechte.md. + */ +export const { handlers, auth, signIn, signOut } = NextAuth(() => { + const env = getEnv() + + return { + trustHost: true, + secret: env.AUTH_SECRET, + + providers: [ + Discord({ + clientId: env.DISCORD_CLIENT_ID, + clientSecret: env.DISCORD_CLIENT_SECRET, + // Nur `identify`. Kein `email`, kein `guilds`: was der Nutzer auf + // anderen Servern tut, geht die Anwendung nichts an. + authorization: { params: { scope: 'identify' } }, + checks: ['pkce', 'state'], + }), + ], + + session: { strategy: 'jwt', maxAge: SESSION_MAX_AGE_MS / 1000 }, + + cookies: { + sessionToken: { + name: 'afis.sitzung', + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + }, + }, + }, + + pages: { + signIn: '/anmelden', + error: '/anmelden/fehler', + }, + + callbacks: { + async signIn({ profile }) { + const discord = profile as DiscordProfile | undefined + if (!discord?.id) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}` + + let member + try { + member = await fetchGuildMember(discord.id) + } catch (error) { + console.error('[auth] Mitgliedschaft nicht prüfbar', error) + return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}` + } + + const isBootstrapAdmin = env.BOOTSTRAP_ADMIN_IDS.includes(discord.id) + const grantingRoleCount = member + ? await db.roleMapping.count({ + where: { discordRoleId: { in: member.roleIds }, grantsMembership: true }, + }) + : 0 + + const decision = decideSignIn({ member, isBootstrapAdmin, grantingRoleCount }) + // Abgelehnt heisst: kein Konto anlegen, keine Spur hinterlassen. + if (!decision.ok) return `/anmelden/fehler?grund=${decision.reason}` + if (!member) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.notInGuild}` + + const displayName = member.nickname ?? discord.global_name ?? discord.username + + await db.$transaction([ + db.user.upsert({ + where: { id: discord.id }, + create: { + id: discord.id, + username: discord.username, + displayName, + avatarHash: discord.avatar ?? null, + isActive: true, + }, + update: { + username: discord.username, + displayName, + avatarHash: discord.avatar ?? null, + isActive: true, + lastSeenAt: new Date(), + }, + }), + db.userRole.deleteMany({ + where: { userId: discord.id, discordRoleId: { notIn: member.roleIds } }, + }), + ...member.roleIds.map((discordRoleId) => + db.userRole.upsert({ + where: { userId_discordRoleId: { userId: discord.id, discordRoleId } }, + create: { userId: discord.id, discordRoleId }, + update: {}, + }), + ), + ]) + + invalidateUser(discord.id) + return true + }, + + async jwt({ token, account, profile }) { + // Nur bei der ersten Anmeldung: eine Sitzungszeile anlegen. + if (account && profile) { + const discord = profile as DiscordProfile + token.userId = discord.id + token.sessionId = await createSession(discord.id) + } + return token + }, + + session({ session, token }) { + // In der Sitzung steht nur userId und sessionId. Rechte werden pro + // Request aufgelöst, nie im Token mitgeführt. + return Object.assign(session, { + userId: token.userId as string | undefined, + sessionId: token.sessionId as string | undefined, + }) + }, + }, + } +}) diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 96aa586..3bde778 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -1,12 +1,5 @@ import { expect, test } from '@playwright/test' -test('Startseite liefert Titel und Systemzustand', async ({ page }) => { - await page.goto('/') - await expect(page.getByRole('heading', { level: 1 })).toHaveText('AfiS-Aktensystem') - await expect(page.getByRole('heading', { name: 'Systemzustand' })).toBeVisible() - await expect(page.getByRole('cell', { name: 'Datenbank' })).toBeVisible() -}) - test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ request }) => { const response = await request.get('/api/health') expect([200, 503]).toContain(response.status()) @@ -15,10 +8,41 @@ test('Gesundheitsendpunkt antwortet mit allen drei Abhaengigkeiten', async ({ re }) test('Antwort traegt eine strikte Inhaltsrichtlinie', async ({ page }) => { - const response = await page.goto('/') + const response = await page.goto('/anmelden') const csp = response?.headers()['content-security-policy'] ?? '' expect(csp).toContain("object-src 'none'") expect(csp).toContain("frame-ancestors 'none'") expect(csp).toMatch(/script-src [^;]*'nonce-/) expect(csp).not.toMatch(/script-src [^;]*'unsafe-inline'/) }) + +test('Anmeldeseite bietet den Discord-Weg an', async ({ page }) => { + await page.goto('/anmelden') + await expect(page.getByRole('button', { name: 'Mit Discord anmelden' })).toBeVisible() +}) + +test('Startseite fuehrt Nichtangemeldete zur Anmeldung', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveURL(/\/anmelden$/) +}) + +test.describe('Geschuetzte Bereiche', () => { + for (const pfad of ['/uebersicht', '/admin/rollen', '/admin/system']) { + test(`${pfad} ist ohne Anmeldung nicht erreichbar`, async ({ page }) => { + await page.goto(pfad) + await expect(page).toHaveURL(/\/anmelden$/) + }) + } +}) + +test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => { + await page.goto('/anmelden/fehler?grund=keine-afis-rolle') + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Keine Berechtigung für das Aktensystem', + ) + + await page.goto('/anmelden/fehler?grund=keine-mitgliedschaft') + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Du bist nicht auf dem AfiS-Discord', + ) +}) diff --git a/web/lib/access/caseFilter.ts b/web/lib/access/caseFilter.ts new file mode 100644 index 0000000..cf8edfe --- /dev/null +++ b/web/lib/access/caseFilter.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@afis/db' +import type { EffectivePermissions } from '@/lib/auth/permissions' + +/** + * Where-Fragment für alle Akten, die ein Nutzer lesen darf. + * + * **Die Prüfung wird nie in JavaScript nachgelagert.** Trefferzahlen und + * Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts + * ableiten lässt. Siehe docs/03-auth-und-rechte.md. + * + * Reihenfolge nach dem Dokument, erste zutreffende Regel gewinnt: + * 1. Ausschluss → kein Zugriff, egal welche Stufe + * 2. Admin → Zugriff + * 3. Beteiligter → Zugriff + * 4. Stufe reicht → Zugriff + * 5. sonst kein Zugriff + * + * Der Ausschluss steht auch über dem Admin. Das Codebeispiel im Dokument gibt + * für Admins `{}` zurück und übergeht ihn damit — das widerspricht der + * Reihenfolge eine Zeile darüber. Hier gilt die Reihenfolge: der Ausschluss + * deckt gerade den Fall ab, dass gegen einen hochrangigen Mitarbeiter + * ermittelt wird, und der Administrator ist einer. + * Siehe docs/13-entscheidungen.md. + */ +export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput { + const notExcluded: Prisma.CaseWhereInput = { + exclusions: { none: { userId: p.userId } }, + } + + if (p.isAdmin) return notExcluded + + const conditions: Prisma.CaseWhereInput[] = [ + notExcluded, + { + OR: [ + { participants: { some: { userId: p.userId } } }, + { classification: { lte: p.clearanceLevel } }, + ], + }, + ] + + // Versiegelte Akten sind nur mit Recht 'admin' oder Stufe 5 lesbar. + // Siehe docs/05-akten-und-vorlagen.md, Lebenszyklus. + if (p.clearanceLevel < 5) { + conditions.push({ status: { not: 'SEALED' } }) + } + + return { AND: conditions } +} + +/** + * Dasselbe für Verlaufseinträge: die Akte muss lesbar sein **und** die + * Feldstufe des Eintrags darf die Stufe des Lesers nicht übersteigen. + */ +export function visibleEntryWhere(p: EffectivePermissions): Prisma.CaseEntryWhereInput { + if (p.isAdmin) { + return { case: visibleCaseWhere(p) } + } + return { + AND: [{ case: visibleCaseWhere(p) }, { minClearance: { lte: p.clearanceLevel } }], + } +} diff --git a/web/lib/auth/cache.ts b/web/lib/auth/cache.ts new file mode 100644 index 0000000..8147346 --- /dev/null +++ b/web/lib/auth/cache.ts @@ -0,0 +1,51 @@ +/** + * Prozesslokaler Cache mit Verfallszeit und Obergrenze. + * + * Die Rechteauflösung läuft einmal pro Request. Ohne Cache wären das zwei + * Abfragen auf jeder Seite, mit Cache praktisch keine. Die Verfallszeit ist + * nur das Sicherheitsnetz: der eigentliche Weg ist die sofortige Verwerfung + * über `pg_notify`, siehe lib/realtime/listener.ts und docs/03-auth-und-rechte.md. + */ +export class TtlCache { + readonly #entries = new Map() + + constructor( + private readonly ttlMs: number, + private readonly maxEntries: number, + ) {} + + get(key: K): V | undefined { + const entry = this.#entries.get(key) + if (!entry) return undefined + if (entry.expiresAt <= Date.now()) { + this.#entries.delete(key) + return undefined + } + // Auffrischen, damit die Reihenfolge der Map die Nutzung widerspiegelt. + this.#entries.delete(key) + this.#entries.set(key, entry) + return entry.value + } + + set(key: K, value: V): void { + this.#entries.delete(key) + this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs }) + while (this.#entries.size > this.maxEntries) { + const oldest = this.#entries.keys().next() + if (oldest.done) break + this.#entries.delete(oldest.value) + } + } + + delete(key: K): void { + this.#entries.delete(key) + } + + clear(): void { + this.#entries.clear() + } + + get size(): number { + return this.#entries.size + } +} diff --git a/web/lib/auth/current.ts b/web/lib/auth/current.ts new file mode 100644 index 0000000..5149656 --- /dev/null +++ b/web/lib/auth/current.ts @@ -0,0 +1,94 @@ +import { notFound, redirect } from 'next/navigation' +import { auth } from '@/auth' +import { db } from '@/lib/db' +import { loadSession } from '@/lib/auth/session' +import { resolvePermissions } from '@/lib/auth/effective' +import { ensureListening } from '@/lib/realtime/listener' +import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' + +export type CurrentUser = { + id: string + displayName: string + username: string + avatarHash: string | null + sessionId: string + permissions: EffectivePermissions +} + +/** + * Der angemeldete Nutzer für genau diesen Request, oder `null`. + * + * Drei Prüfungen, alle serverseitig: + * 1. Das Cookie trägt eine gültige Signatur (Auth.js) + * 2. Die Sitzungszeile existiert noch und ist nicht abgelaufen + * 3. Der Nutzer trägt weiterhin eine Rolle, die zur Anmeldung berechtigt + * + * Schritt 3 ist doppelt gemoppelt — der Worker beendet Sitzungen beim + * Rollenverlust bereits von sich aus — aber genau das ist der Sinn: fällt der + * Worker aus, verliert trotzdem niemand unbemerkt seinen Ausschluss. + */ +export async function getCurrentUser(): Promise { + const token = await auth() + const sessionId = (token as { sessionId?: string } | null)?.sessionId + if (!sessionId) return null + + // Der LISTEN-Client wird beim ersten Request des Prozesses aufgebaut. + await ensureListening() + + const session = await loadSession(sessionId) + if (!session) return null + + const user = await db.user.findUnique({ + where: { id: session.userId }, + select: { id: true, displayName: true, username: true, avatarHash: true, isActive: true }, + }) + if (!user || !user.isActive) return null + + const permissions = await resolvePermissions(session.userId) + if (!permissions.hasMembership && !permissions.isAdmin) return null + + return { + id: user.id, + displayName: user.displayName, + username: user.username, + avatarHash: user.avatarHash, + sessionId: session.id, + permissions, + } +} + +/** Für Seiten, die eine Anmeldung voraussetzen. */ +export async function requireUser(): Promise { + const user = await getCurrentUser() + if (!user) redirect('/anmelden') + return user +} + +/** + * Für Seiten und Aktionen, die ein Recht voraussetzen. + * + * Fehlt das Recht, gibt es 404 statt 403. Existenz ist auch eine Information: + * aus einem 403 liesse sich ablesen, dass es die Seite gibt. + * Siehe docs/03-auth-und-rechte.md. + */ +export async function requirePermission(permission: Permission): Promise { + const user = await requireUser() + if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) { + notFound() + } + return user +} + +/** + * Für Server Actions: wirft, statt umzuleiten. Jede Server Action prüft die + * Berechtigung erneut, egal was die Oberfläche vorher versteckt hat. + * Siehe docs/07-dateien-und-sicherheit.md. + */ +export async function requirePermissionForAction(permission: Permission): Promise { + const user = await getCurrentUser() + if (!user) throw new Error('Nicht angemeldet') + if (!user.permissions.isAdmin && !user.permissions.permissions.has(permission)) { + throw new Error('Keine Berechtigung') + } + return user +} diff --git a/web/lib/auth/discord.ts b/web/lib/auth/discord.ts new file mode 100644 index 0000000..1f8f916 --- /dev/null +++ b/web/lib/auth/discord.ts @@ -0,0 +1,40 @@ +import { getEnv } from '@/lib/env' + +const DISCORD_API = 'https://discord.com/api/v10' + +export type GuildMember = { + userId: string + roleIds: string[] + nickname: string | null +} + +/** + * Fragt die Mitgliedschaft **serverseitig mit dem Bot-Token** ab. + * + * Das Zugangstoken des Nutzers aus dem OAuth-Ablauf wird dafür bewusst nicht + * benutzt und nach dem Callback verworfen: OAuth klärt, wer jemand ist, der Bot + * klärt, was er darf. Siehe docs/03-auth-und-rechte.md. + * + * `null` bedeutet: kein Mitglied der Guild. Ein Fehler der Discord-API wird + * geworfen, damit ein Ausfall nicht wie eine abgelehnte Anmeldung aussieht. + */ +export async function fetchGuildMember(userId: string): Promise { + const env = getEnv() + const response = await fetch(`${DISCORD_API}/guilds/${env.DISCORD_GUILD_ID}/members/${userId}`, { + headers: { authorization: `Bot ${env.DISCORD_BOT_TOKEN}` }, + cache: 'no-store', + }) + + if (response.status === 404) return null + + if (!response.ok) { + throw new Error(`Discord-API antwortete mit ${response.status}`) + } + + const body = (await response.json()) as { roles?: string[]; nick?: string | null } + return { + userId, + roleIds: body.roles ?? [], + nickname: body.nick ?? null, + } +} diff --git a/web/lib/auth/effective.ts b/web/lib/auth/effective.ts new file mode 100644 index 0000000..81ca77b --- /dev/null +++ b/web/lib/auth/effective.ts @@ -0,0 +1,101 @@ +import { db } from '@/lib/db' +import { getEnv } from '@/lib/env' +import { TtlCache } from '@/lib/auth/cache' +import { + PERMISSIONS, + isPermission, + type EffectivePermissions, + type Permission, +} from '@/lib/auth/permissions' + +/** Höchste Freigabestufe. */ +const MAX_CLEARANCE = 5 + +/** + * 60 Sekunden Verfallszeit als Sicherheitsnetz. Der reguläre Weg ist die + * sofortige Verwerfung bei `member_changed` oder `role_mapping_changed`. + */ +const cache = new TtlCache(60_000, 200) + +/** Wird vom LISTEN-Client aufgerufen, siehe lib/realtime/listener.ts. */ +export function invalidateUser(userId: string): void { + cache.delete(userId) +} + +/** Eine Änderung am Rollen-Mapping betrifft potenziell jeden. */ +export function invalidateAll(): void { + cache.clear() +} + +function bootstrapAdmin(userId: string): EffectivePermissions { + return { + userId, + clearanceLevel: MAX_CLEARANCE, + permissions: new Set(PERMISSIONS), + isAdmin: true, + hasMembership: true, + } +} + +/** + * Löst die Rechte eines Nutzers aus `UserRole` × `RoleMapping` auf: + * höchste Stufe aller Rollen, Vereinigung aller Rechte. + * + * `hasMembership` ist eine Ergänzung gegenüber docs/03-auth-und-rechte.md und + * bildet den Türsteher ab: auf dem AfiS-Discord sind auch Gäste, die keine + * Akte sehen dürfen. Siehe docs/13-entscheidungen.md. + * + * Notfall-Admins aus `BOOTSTRAP_ADMIN_IDS` bekommen immer Stufe 5 und alle + * Rechte, ohne Datenbankabfrage. Ohne das sperrt man sich beim ersten Fehler + * in der Rollenverwaltung selbst aus. + */ +export async function resolvePermissions(userId: string): Promise { + const cached = cache.get(userId) + if (cached) return cached + + if (getEnv().BOOTSTRAP_ADMIN_IDS.includes(userId)) { + const resolved = bootstrapAdmin(userId) + cache.set(userId, resolved) + return resolved + } + + // Eine Abfrage, kein Nachfiltern in JavaScript. + const rows = await db.userRole.findMany({ + where: { userId }, + select: { + discordRoleId: true, + }, + }) + + const roleIds = rows.map((r) => r.discordRoleId) + const mappings = + roleIds.length === 0 + ? [] + : await db.roleMapping.findMany({ + where: { discordRoleId: { in: roleIds } }, + select: { clearanceLevel: true, permissions: true, grantsMembership: true }, + }) + + let clearanceLevel = 0 + let hasMembership = false + const permissions = new Set() + + for (const mapping of mappings) { + if (mapping.clearanceLevel > clearanceLevel) clearanceLevel = mapping.clearanceLevel + if (mapping.grantsMembership) hasMembership = true + for (const value of mapping.permissions) { + if (isPermission(value)) permissions.add(value) + } + } + + const resolved: EffectivePermissions = { + userId, + clearanceLevel: Math.min(clearanceLevel, MAX_CLEARANCE), + permissions, + isAdmin: permissions.has('admin'), + hasMembership, + } + + cache.set(userId, resolved) + return resolved +} diff --git a/web/lib/auth/permissions.ts b/web/lib/auth/permissions.ts index 942513d..c96b56e 100644 --- a/web/lib/auth/permissions.ts +++ b/web/lib/auth/permissions.ts @@ -50,6 +50,12 @@ export type EffectivePermissions = { permissions: Set /** Aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'. */ isAdmin: boolean + /** + * Trägt der Nutzer eine Rolle, die zur Anmeldung berechtigt? Ergänzung + * gegenüber docs/03-auth-und-rechte.md: auf dem AfiS-Discord sind auch + * Gäste. Siehe docs/13-entscheidungen.md. + */ + hasMembership: boolean } export function can(p: EffectivePermissions, permission: Permission): boolean { diff --git a/web/lib/auth/session.ts b/web/lib/auth/session.ts new file mode 100644 index 0000000..4726574 --- /dev/null +++ b/web/lib/auth/session.ts @@ -0,0 +1,79 @@ +import { db } from '@/lib/db' + +/** Laufzeit einer Sitzung, gleitend verlängert. */ +export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 + +/** + * Erst nachschreiben, wenn eine Stunde vergangen ist. Sonst erzeugt jeder + * Seitenaufruf einen Schreibvorgang, nur um den Ablauf um Sekunden zu schieben. + */ +const SLIDE_THRESHOLD_MS = 60 * 60 * 1000 + +export type ActiveSession = { + id: string + userId: string + expiresAt: Date +} + +export async function createSession(userId: string, userAgent?: string): Promise { + const session = await db.session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS), + userAgent: userAgent?.slice(0, 500) ?? null, + }, + select: { id: true }, + }) + return session.id +} + +/** + * Lädt eine Sitzung und verlängert sie gleitend. Gibt `null` zurück, wenn sie + * nicht mehr existiert oder abgelaufen ist. + * + * Die Zeile in der Datenbank ist der eigentliche Sitzungsnachweis: das Cookie + * allein lässt sich nicht zurückziehen. Verliert jemand seine Rolle, löscht der + * Worker die Zeile, und der nächste Request ist abgemeldet. + * Siehe docs/03-auth-und-rechte.md. + */ +export async function loadSession(sessionId: string): Promise { + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { id: true, userId: true, expiresAt: true, lastSeenAt: true }, + }) + + if (!session) return null + + if (session.expiresAt.getTime() <= Date.now()) { + await db.session.delete({ where: { id: sessionId } }).catch(() => undefined) + return null + } + + if (Date.now() - session.lastSeenAt.getTime() > SLIDE_THRESHOLD_MS) { + const now = new Date() + await db.session + .update({ + where: { id: sessionId }, + data: { lastSeenAt: now, expiresAt: new Date(now.getTime() + SESSION_MAX_AGE_MS) }, + }) + .catch(() => undefined) + } + + return { id: session.id, userId: session.userId, expiresAt: session.expiresAt } +} + +export async function endSession(sessionId: string): Promise { + await db.session.delete({ where: { id: sessionId } }).catch(() => undefined) +} + +/** Wird beim Rollenverlust und beim Serveraustritt benutzt. */ +export async function endAllSessionsFor(userId: string): Promise { + const { count } = await db.session.deleteMany({ where: { userId } }) + return count +} + +/** Tägliche Aufräumaufgabe, siehe docs/11-betrieb.md. */ +export async function deleteExpiredSessions(): Promise { + const { count } = await db.session.deleteMany({ where: { expiresAt: { lte: new Date() } } }) + return count +} diff --git a/web/lib/auth/signInPolicy.ts b/web/lib/auth/signInPolicy.ts new file mode 100644 index 0000000..5ecf461 --- /dev/null +++ b/web/lib/auth/signInPolicy.ts @@ -0,0 +1,36 @@ +import type { GuildMember } from '@/lib/auth/discord' + +export const SIGN_IN_ERRORS = { + notInGuild: 'keine-mitgliedschaft', + notAfis: 'keine-afis-rolle', + discordUnreachable: 'discord-nicht-erreichbar', +} as const + +export type SignInReason = (typeof SIGN_IN_ERRORS)[keyof typeof SIGN_IN_ERRORS] + +export type SignInDecision = { ok: true } | { ok: false; reason: SignInReason } + +/** + * Wer darf sich anmelden. + * + * Bewusst als eigene Funktion und nicht im Auth.js-Callback vergraben: das ist + * die Regel, an der alles hängt, und sie gehört einzeln prüfbar. + * + * - Kein Mitglied der Guild → abgelehnt. Es wird kein Konto angelegt. + * - Mitglied ohne Rolle mit `grantsMembership` → abgelehnt. Auf dem + * AfiS-Discord sind auch Gäste, die keine Akte sehen dürfen. + * - Notfall-Admin → durchgelassen, aber nur solange er Mitglied der Guild + * ist. Sonst bliebe die Hintertür auch nach einem Rauswurf offen. + * + * Siehe docs/03-auth-und-rechte.md und docs/13-entscheidungen.md. + */ +export function decideSignIn(input: { + member: GuildMember | null + isBootstrapAdmin: boolean + grantingRoleCount: number +}): SignInDecision { + if (!input.member) return { ok: false, reason: SIGN_IN_ERRORS.notInGuild } + if (input.isBootstrapAdmin) return { ok: true } + if (input.grantingRoleCount === 0) return { ok: false, reason: SIGN_IN_ERRORS.notAfis } + return { ok: true } +} diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts index d0c864e..904e60b 100644 --- a/web/lib/i18n/de.ts +++ b/web/lib/i18n/de.ts @@ -29,10 +29,22 @@ export const de = { signIn: 'Mit Discord anmelden', signOut: 'Abmelden', signedInAs: 'Angemeldet als', - notInGuild: { - title: 'Du bist nicht auf dem AfiS-Discord', - body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.', - }, + intro: + 'Die Anmeldung läuft über Discord. Zugriff haben Mitglieder der Abteilung für innere Sicherheit.', + reasons: { + 'keine-mitgliedschaft': { + title: 'Du bist nicht auf dem AfiS-Discord', + body: 'Die Anmeldung setzt eine Mitgliedschaft auf dem Discord-Server voraus. Wende dich an die Abteilungsleitung.', + }, + 'keine-afis-rolle': { + title: 'Keine Berechtigung für das Aktensystem', + body: 'Du bist auf dem Server, trägst aber keine Rolle der Abteilung. Wende dich an die Abteilungsleitung.', + }, + 'discord-nicht-erreichbar': { + title: 'Discord antwortet nicht', + body: 'Die Mitgliedschaft war nicht prüfbar. In einigen Minuten erneut versuchen.', + }, + } as Record, sessionEnded: { title: 'Sitzung beendet', body: 'Deine Rollen haben sich geändert. Melde dich neu an.', @@ -41,6 +53,59 @@ export const de = { title: 'Anmeldung nicht möglich', body: 'Der Anmeldevorgang wurde abgebrochen. Erneut versuchen.', }, + backToSignIn: 'Zurück zur Anmeldung', + }, + + overview: { + heading: 'Dein Zugang', + intro: 'Was das System über dich weiss und was daraus folgt.', + person: 'Person', + discordId: 'Discord-Kennung', + roles: 'Rollen auf dem Discord', + clearance: 'Errechnete Freigabestufe', + permissions: 'Rechte', + noPermissions: 'Keine Rechte. Die Abteilungsleitung vergibt sie in der Rollenverwaltung.', + noRoles: 'Keine Rollen.', + bootstrapAdmin: 'Notfallzugang aus der Konfiguration, unabhängig vom Rollen-Mapping', + unmapped: 'nicht zugeordnet', + }, + + roleAdmin: { + heading: 'Rollenverwaltung', + intro: + 'Jede Rolle des Discord-Servers steht hier. Zugangsrollen bestimmen die Freigabestufe, Rangrollen die Rechte, und eine Zugehörigkeitsrolle entscheidet, wer sich überhaupt anmelden darf.', + empty: + 'Noch keine Rollen eingelesen. Der Worker spiegelt sie, sobald er am Discord-Gateway hängt.', + role: 'Rolle', + category: 'Sorte', + clearance: 'Freigabestufe', + grantsMembership: 'Darf sich anmelden', + permissions: 'Rechte', + missingFromGuild: 'Auf dem Server nicht mehr vorhanden', + save: 'Zuordnung speichern', + invalidInput: 'Eingabe nicht gültig', + roleGone: 'Rolle nicht gefunden, sie wurde zwischenzeitlich gelöscht', + saving: 'Wird gespeichert', + saved: 'Zuordnung gespeichert', + confirm: 'Rollen-Mapping ändern? Das wirkt sofort für alle angemeldeten Personen.', + categories: { + MEMBERSHIP: 'Zugehörigkeit', + RANK: 'Rang', + CLEARANCE: 'Zugang', + UNSORTED: 'Nicht eingeordnet', + } as Record, + optIn: + 'Dieses Recht ist im Auslieferungszustand keiner Rolle zugeordnet. Vergib es bewusst.', + preview: { + heading: 'Vorschau', + intro: 'Was jemand mit dieser Stufe sieht, wenn er keine weiteren Rollen hat.', + level: 'Stufe', + sees: 'Sieht Akten bis einschliesslich', + hidden: 'Höhere Akten sind unsichtbar', + lockedOnly: 'Höhere Akten sind sichtbar, aber gesperrt', + nobody: 'Keine Rolle führt zu dieser Stufe', + viaRoles: 'Erreichbar über', + }, }, clearance: { diff --git a/web/lib/realtime/listener.ts b/web/lib/realtime/listener.ts new file mode 100644 index 0000000..014b378 --- /dev/null +++ b/web/lib/realtime/listener.ts @@ -0,0 +1,76 @@ +import { Client } from 'pg' +import { invalidateAll, invalidateUser } from '@/lib/auth/effective' + +/** + * Ein einziger `pg`-Client hält `LISTEN` offen und verteilt im Prozess weiter. + * Kein WebSocket, kein Redis: Postgres `LISTEN/NOTIFY` reicht bei dieser + * Nutzerzahl vollständig. Siehe docs/06-chat-und-realtime.md. + * + * Phase 1 nutzt zwei Kanäle: `member_changed` verwirft die Rechte eines + * Nutzers, `role_mapping_changed` verwirft alles. Dadurch ist eine + * Rechteänderung in Discord in unter einer Sekunde wirksam, ohne dass ein + * einziger Request auf die Discord-API wartet. + * + * Die Verbindung läuft an pgbouncer vorbei: im Transaktionsmodus überlebt ein + * `LISTEN` den Pool nicht. + */ +const CHANNELS = ['member_changed', 'role_mapping_changed'] as const + +type Listener = { client: Client; ready: Promise } + +const globalForListener = globalThis as unknown as { afisListener?: Listener } + +function connect(): Listener { + const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL + if (!url) throw new Error('Konfiguration unvollständig: DIRECT_DATABASE_URL fehlt') + + const client = new Client({ connectionString: url }) + + client.on('notification', (message) => { + if (message.channel === 'member_changed' && message.payload) { + invalidateUser(message.payload) + return + } + if (message.channel === 'role_mapping_changed') { + invalidateAll() + } + }) + + client.on('error', (error: unknown) => { + console.error('[realtime] Verbindung verloren, baue neu auf', error) + globalForListener.afisListener = undefined + // Der nächste Aufruf von ensureListening baut neu auf. Bis dahin greift + // die Verfallszeit des Caches, es geht also nichts verloren, es dauert nur + // bis zu 60 Sekunden statt unter einer. + client.end().catch(() => undefined) + }) + + const ready = client + .connect() + .then(async () => { + for (const channel of CHANNELS) { + await client.query(`LISTEN ${channel}`) + } + console.log('[realtime] hört auf', CHANNELS.join(', ')) + }) + .catch((error: unknown) => { + globalForListener.afisListener = undefined + throw error + }) + + return { client, ready } +} + +/** + * Stellt sicher, dass der Prozess auf Änderungen hört. Mehrfach aufrufbar. + * Schlägt der Aufbau fehl, ist das kein Grund, den Request scheitern zu lassen: + * der Cache verfällt dann eben nach 60 Sekunden von selbst. + */ +export async function ensureListening(): Promise { + globalForListener.afisListener ??= connect() + try { + await globalForListener.afisListener.ready + } catch (error) { + console.error('[realtime] konnte nicht verbinden', error) + } +} diff --git a/web/tests/anmeldung.test.ts b/web/tests/anmeldung.test.ts new file mode 100644 index 0000000..be07e85 --- /dev/null +++ b/web/tests/anmeldung.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy' +import type { GuildMember } from '@/lib/auth/discord' + +const mitglied: GuildMember = { userId: '42', roleIds: ['r-afis'], nickname: null } + +describe('Wer sich anmelden darf', () => { + it('lehnt ab, wer nicht auf dem Discord-Server ist', () => { + const entscheidung = decideSignIn({ + member: null, + isBootstrapAdmin: false, + grantingRoleCount: 0, + }) + expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notInGuild }) + }) + + it('lehnt auch einen Notfall-Admin ab, der die Guild verlassen hat', () => { + // Sonst bliebe die Hintertür nach einem Rauswurf offen. + const entscheidung = decideSignIn({ + member: null, + isBootstrapAdmin: true, + grantingRoleCount: 0, + }) + expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notInGuild }) + }) + + it('lehnt einen Gast ohne AfiS-Rolle ab', () => { + const entscheidung = decideSignIn({ + member: { ...mitglied, roleIds: ['r-gast'] }, + isBootstrapAdmin: false, + grantingRoleCount: 0, + }) + expect(entscheidung).toEqual({ ok: false, reason: SIGN_IN_ERRORS.notAfis }) + }) + + it('laesst ein Mitglied mit AfiS-Rolle durch', () => { + expect(decideSignIn({ member: mitglied, isBootstrapAdmin: false, grantingRoleCount: 1 })).toEqual( + { ok: true }, + ) + }) + + it('laesst den Notfall-Admin ohne AfiS-Rolle durch', () => { + expect(decideSignIn({ member: mitglied, isBootstrapAdmin: true, grantingRoleCount: 0 })).toEqual( + { ok: true }, + ) + }) +}) + +describe('Mitgliedschaftsabfrage beim Bot', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + function stubEnv() { + vi.stubEnv('DATABASE_URL', 'postgresql://x/y') + vi.stubEnv('APP_URL', 'http://localhost:3000') + vi.stubEnv('AUTH_SECRET', 'x'.repeat(40)) + vi.stubEnv('DISCORD_CLIENT_ID', 'c') + vi.stubEnv('DISCORD_CLIENT_SECRET', 's') + vi.stubEnv('DISCORD_BOT_TOKEN', 'bot-token') + vi.stubEnv('DISCORD_GUILD_ID', '99') + } + + it('meldet null, wenn Discord 404 antwortet', async () => { + stubEnv() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 }))) + const { resetEnvCache } = await import('@/lib/env') + resetEnvCache() + const { fetchGuildMember } = await import('@/lib/auth/discord') + + expect(await fetchGuildMember('42')).toBeNull() + }) + + it('wirft bei einem Ausfall der Discord-API, statt Mitgliedschaft zu verneinen', async () => { + // Ein Ausfall darf nicht aussehen wie "kein Mitglied": sonst sperrt eine + // Störung bei Discord die halbe Abteilung aus und legt nebenbei Konten an. + stubEnv() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 500 }))) + const { resetEnvCache } = await import('@/lib/env') + resetEnvCache() + const { fetchGuildMember } = await import('@/lib/auth/discord') + + await expect(fetchGuildMember('42')).rejects.toThrow(/500/) + }) + + it('benutzt das Bot-Token, nicht das des Nutzers', async () => { + stubEnv() + const spy = vi.fn(async () => Response.json({ roles: ['r-afis'], nick: 'Vogel' })) + vi.stubGlobal('fetch', spy) + const { resetEnvCache } = await import('@/lib/env') + resetEnvCache() + const { fetchGuildMember } = await import('@/lib/auth/discord') + + const member = await fetchGuildMember('42') + expect(member).toEqual({ userId: '42', roleIds: ['r-afis'], nickname: 'Vogel' }) + + const call = spy.mock.calls[0] as unknown as [string, RequestInit] + const [url, init] = call + expect(url).toContain('/guilds/99/members/42') + expect((init.headers as Record).authorization).toBe('Bot bot-token') + }) +}) diff --git a/web/tests/i18n.test.ts b/web/tests/i18n.test.ts index 439ea87..99446a8 100644 --- a/web/tests/i18n.test.ts +++ b/web/tests/i18n.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { de } from '@/lib/i18n/de' +import { OPT_IN_PERMISSIONS, PERMISSIONS } from '@/lib/auth/permissions' describe('Sprachkonvention', () => { it('haelt die Stufenbezeichnungen aus den Texten heraus', () => { @@ -19,9 +20,21 @@ describe('Sprachkonvention', () => { it('haelt deutsche Oberflaechentexte aus dem JSX heraus', () => { // Umlaute und Eszett sind der billigste verlaessliche Marker fuer einen // deutschen Text. Alles Sichtbare gehoert nach lib/i18n/de.ts, siehe CLAUDE.md. + // + // Geprueft werden Komponenten und Seiten (.tsx) sowie Server Actions: + // deren Fehlermeldungen landen in der Oberflaeche. Kommentare zaehlen + // nicht, die duerfen und sollen deutsch sein. const offenders: string[] = [] const roots = ['app', 'components'] + function stripComments(source: string): string { + // Blockkommentare durch gleich viele Zeilenumbrueche ersetzen, damit die + // Zeilennummern stimmen bleiben. + return source + .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' ')) + .replace(/\/\/.*$/gm, '') + } + function walk(dir: string) { for (const name of readdirSync(dir)) { const path = join(dir, name) @@ -29,11 +42,10 @@ describe('Sprachkonvention', () => { walk(path) continue } - if (!name.endsWith('.tsx')) continue - const source = readFileSync(path, 'utf8') + if (!name.endsWith('.tsx') && name !== 'actions.ts') continue + const source = stripComments(readFileSync(path, 'utf8')) for (const [index, line] of source.split('\n').entries()) { - const withoutComments = line.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '') - if (/[äöüÄÖÜß]/.test(withoutComments)) { + if (/[äöüÄÖÜß]/.test(line)) { offenders.push(`${path}:${index + 1}`) } } @@ -44,3 +56,25 @@ describe('Sprachkonvention', () => { expect(offenders).toEqual([]) }) }) + +describe('Rechte-Enum', () => { + it('kennt die vier Rechte, die Phase 1 anlegen muss', () => { + for (const permission of [ + 'entry.edit_own', + 'entry.edit_any', + 'entry.retract', + 'case.edit_closed', + ]) { + expect(PERMISSIONS).toContain(permission) + } + }) + + it('haelt drei davon im Auslieferungszustand zurueck', () => { + // Der Leiter schaltet sie bewusst frei, siehe docs/03-auth-und-rechte.md. + expect([...OPT_IN_PERMISSIONS].sort()).toEqual([ + 'entry.edit_any', + 'entry.edit_own', + 'entry.retract', + ]) + }) +}) diff --git a/web/tests/rechte.test.ts b/web/tests/rechte.test.ts new file mode 100644 index 0000000..4ad743c --- /dev/null +++ b/web/tests/rechte.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db' + +loadEnvFromRepoRoot() +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +let db: PrismaClient +const marke = `rechte-${Date.now()}` + +const rollen = { + afis: `${marke}-rolle-afis`, + gast: `${marke}-rolle-gast`, + agent: `${marke}-rolle-agent`, + leitung: `${marke}-rolle-leitung`, + zugangHoch: `${marke}-rolle-zugang-hoch`, + zugangNiedrig: `${marke}-rolle-zugang-niedrig`, +} + +const nutzer = { + mitglied: `${marke}-mitglied`, + gast: `${marke}-gast`, + notfallAdmin: `${marke}-notfall`, +} + +suite('Rechteauflösung', () => { + beforeAll(async () => { + db = createPrismaClient(url) + + await db.roleMapping.createMany({ + data: [ + { discordRoleId: rollen.afis, label: 'AfiS', category: 'MEMBERSHIP', grantsMembership: true }, + { discordRoleId: rollen.gast, label: 'Gast', category: 'UNSORTED' }, + { + discordRoleId: rollen.agent, + label: 'Agent', + category: 'RANK', + permissions: ['case.read', 'entry.create'], + }, + { + discordRoleId: rollen.leitung, + label: 'Section Commander', + category: 'RANK', + permissions: ['case.read', 'case.close', 'export.approve'], + }, + { + discordRoleId: rollen.zugangNiedrig, + label: 'Restricted Access', + category: 'CLEARANCE', + clearanceLevel: 1, + }, + { + discordRoleId: rollen.zugangHoch, + label: 'Critical Operations', + category: 'CLEARANCE', + clearanceLevel: 4, + }, + ], + }) + + for (const id of Object.values(nutzer)) { + await db.user.create({ data: { id, username: id, displayName: id } }) + } + + await db.userRole.createMany({ + data: [ + { userId: nutzer.mitglied, discordRoleId: rollen.afis }, + { userId: nutzer.mitglied, discordRoleId: rollen.agent }, + { userId: nutzer.mitglied, discordRoleId: rollen.leitung }, + { userId: nutzer.mitglied, discordRoleId: rollen.zugangNiedrig }, + { userId: nutzer.mitglied, discordRoleId: rollen.zugangHoch }, + { userId: nutzer.gast, discordRoleId: rollen.gast }, + ], + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + beforeEach(async () => { + const { invalidateAll } = await import('@/lib/auth/effective') + invalidateAll() + }) + + it('nimmt die höchste Stufe und die Vereinigung der Rechte', async () => { + const { resolvePermissions } = await import('@/lib/auth/effective') + const p = await resolvePermissions(nutzer.mitglied) + + expect(p.clearanceLevel).toBe(4) + expect([...p.permissions].sort()).toEqual([ + 'case.close', + 'case.read', + 'entry.create', + 'export.approve', + ]) + expect(p.hasMembership).toBe(true) + expect(p.isAdmin).toBe(false) + }) + + it('erkennt einen Gast ohne AfiS-Rolle als nicht zugangsberechtigt', async () => { + const { resolvePermissions } = await import('@/lib/auth/effective') + const p = await resolvePermissions(nutzer.gast) + + expect(p.hasMembership).toBe(false) + expect(p.clearanceLevel).toBe(0) + expect(p.permissions.size).toBe(0) + }) + + it('gibt dem Notfallzugang Stufe 5 und alle Rechte ohne Rollen', async () => { + process.env.BOOTSTRAP_ADMIN_IDS = nutzer.notfallAdmin + const { resetEnvCache } = await import('@/lib/env') + resetEnvCache() + + const { resolvePermissions, invalidateAll } = await import('@/lib/auth/effective') + invalidateAll() + const p = await resolvePermissions(nutzer.notfallAdmin) + + expect(p.isAdmin).toBe(true) + expect(p.clearanceLevel).toBe(5) + expect(p.hasMembership).toBe(true) + expect(p.permissions.has('roles.manage')).toBe(true) + expect(p.permissions.has('entry.retract')).toBe(true) + }) + + it('verwirft den Cache, wenn sich das Rollen-Mapping ändert', async () => { + const { resolvePermissions, invalidateAll } = await import('@/lib/auth/effective') + + expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(4) + + await db.roleMapping.update({ + where: { discordRoleId: rollen.zugangHoch }, + data: { clearanceLevel: 2 }, + }) + + // Ohne Verwerfen liefert der Cache weiterhin die alte Stufe. + expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(4) + + invalidateAll() + expect((await resolvePermissions(nutzer.mitglied)).clearanceLevel).toBe(2) + }) +}) diff --git a/web/tests/zugriff.test.ts b/web/tests/zugriff.test.ts new file mode 100644 index 0000000..c5c01f4 --- /dev/null +++ b/web/tests/zugriff.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment node +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db' +import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter' +import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' + +/** + * Die Negativtests aus docs/03-auth-und-rechte.md. + * + * Sie laufen gegen eine echte Datenbank, weil genau das der Punkt ist: die + * Prüfung muss in der Abfrage stecken, nicht in einem Filter danach. Ein Test + * gegen eine ausgedachte Liste würde die Aussage nicht treffen. + */ +loadEnvFromRepoRoot() +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +let db: PrismaClient +const marke = `zugriff-${Date.now()}` + +function perms( + userId: string, + clearanceLevel: number, + options: { isAdmin?: boolean; permissions?: Permission[] } = {}, +): EffectivePermissions { + return { + userId, + clearanceLevel, + permissions: new Set(options.permissions ?? []), + isAdmin: options.isAdmin ?? false, + hasMembership: true, + } +} + +suite('Sichtbarkeit von Akten', () => { + const ids = { + offen: `${marke}-offen`, + geheim: `${marke}-geheim`, + versiegelt: `${marke}-versiegelt`, + } + const nutzer = { + stufe1: `${marke}-stufe1`, + stufe2: `${marke}-stufe2`, + stufe5: `${marke}-stufe5`, + beteiligt: `${marke}-beteiligt`, + admin: `${marke}-admin`, + } + + beforeAll(async () => { + db = createPrismaClient(url) + + const template = await db.caseTemplate.create({ + data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, + }) + + for (const id of Object.values(nutzer)) { + await db.user.create({ data: { id, username: id, displayName: id } }) + } + + const gemeinsam = { templateId: template.id, templateKey: template.key, createdById: nutzer.stufe5 } + + await db.case.create({ + data: { + ...gemeinsam, + id: ids.offen, + caseNumber: `AfiS-9001-${String(Date.now()).slice(-4)}`, + title: 'Offener Vorgang', + classification: 1, + status: 'OPEN', + }, + }) + + await db.case.create({ + data: { + ...gemeinsam, + id: ids.geheim, + caseNumber: `AfiS-9002-${String(Date.now()).slice(-4)}`, + title: 'Geheimer Vorgang', + classification: 4, + status: 'OPEN', + }, + }) + + await db.case.create({ + data: { + ...gemeinsam, + id: ids.versiegelt, + caseNumber: `AfiS-9003-${String(Date.now()).slice(-4)}`, + title: 'Versiegelter Vorgang', + classification: 1, + status: 'SEALED', + }, + }) + + // Beteiligter an der geheimen Akte, obwohl seine Stufe nicht reicht. + await db.caseParticipant.create({ + data: { caseId: ids.geheim, userId: nutzer.beteiligt, role: 'WITNESS' }, + }) + + // Ausgeschlossen von der offenen Akte, obwohl Stufe 5 und Admin. + await db.caseExclusion.createMany({ + data: [ + { caseId: ids.offen, userId: nutzer.stufe5, reason: 'Betroffener' }, + { caseId: ids.offen, userId: nutzer.admin, reason: 'Betroffener' }, + ], + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + async function sichtbar(p: EffectivePermissions): Promise { + const rows = await db.case.findMany({ + where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] }, + select: { id: true }, + }) + return rows.map((r) => r.id).sort() + } + + it('zeigt einer Stufe 2 die Stufe-4-Akte nicht, auch nicht als Treffer', async () => { + const p = perms(nutzer.stufe2, 2) + const treffer = await sichtbar(p) + expect(treffer).not.toContain(ids.geheim) + expect(treffer).toContain(ids.offen) + + // Auch die Zählung darf sie nicht enthalten, sonst verrät die Lücke sie. + const anzahl = await db.case.count({ + where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] }, + }) + expect(anzahl).toBe(treffer.length) + }) + + it('gibt einem ausgeschlossenen Nutzer mit Stufe 5 keinen Zugriff', async () => { + const treffer = await sichtbar(perms(nutzer.stufe5, 5)) + expect(treffer).not.toContain(ids.offen) + }) + + it('schlägt selbst den Admin, wenn er ausgeschlossen ist', async () => { + // Der Ausschluss deckt den Fall ab, dass gegen einen hochrangigen + // Mitarbeiter ermittelt wird. Der Administrator ist einer. + const treffer = await sichtbar(perms(nutzer.admin, 5, { isAdmin: true })) + expect(treffer).not.toContain(ids.offen) + expect(treffer).toContain(ids.geheim) + }) + + it('lässt einen Beteiligten auch über seiner Stufe lesen', async () => { + const treffer = await sichtbar(perms(nutzer.beteiligt, 1)) + expect(treffer).toContain(ids.geheim) + }) + + it('verbirgt versiegelte Akten unterhalb von Stufe 5', async () => { + expect(await sichtbar(perms(nutzer.stufe2, 2))).not.toContain(ids.versiegelt) + expect(await sichtbar(perms(nutzer.stufe1, 1))).not.toContain(ids.versiegelt) + expect(await sichtbar(perms(nutzer.stufe5, 5))).toContain(ids.versiegelt) + }) + + it('verbirgt Verlaufseinträge oberhalb der Stufe des Lesers', async () => { + await db.caseEntry.createMany({ + data: [ + { + id: `${marke}-eintrag-offen`, + caseId: ids.offen, + type: 'NOTE', + body: 'Für alle lesbar', + authorId: nutzer.stufe1, + minClearance: 0, + }, + { + id: `${marke}-eintrag-hoch`, + caseId: ids.offen, + type: 'NOTE', + body: 'Nur ab Stufe 4', + authorId: nutzer.stufe1, + minClearance: 4, + }, + ], + }) + + const p = perms(nutzer.stufe1, 1) + const eintraege = await db.caseEntry.findMany({ + where: { AND: [visibleEntryWhere(p), { caseId: ids.offen }] }, + select: { id: true }, + }) + const gefunden = eintraege.map((e) => e.id) + expect(gefunden).toContain(`${marke}-eintrag-offen`) + expect(gefunden).not.toContain(`${marke}-eintrag-hoch`) + }) +}) diff --git a/worker/src/index.ts b/worker/src/index.ts index 720223b..b9e81cd 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,15 +1,18 @@ -import { Client, GatewayIntentBits, Events } from 'discord.js' +import { Client, Events, GatewayIntentBits, type Guild } from 'discord.js' import { createPrismaClient } from '@afis/db' import { loadEnv } from './env.js' import { writeHeartbeat } from './heartbeat.js' +import { enforceMembership, handleDeparture, syncMember, syncRoles } from './sync.js' /** - * Rollen-Abgleich-Worker. Haengt am Discord-Gateway und haelt die - * Guild-Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei - * Discord anfragen muss. Siehe docs/02-architektur.md. + * Rollen-Abgleich-Worker. Hängt am Discord-Gateway und hält Guild-Rollen und + * Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei Discord + * anfragen muss. Siehe docs/02-architektur.md. * - * Phase 0 baut das Geruest: Verbindung, Lebenszeichen, sauberes Herunterfahren. - * Der eigentliche Abgleich (Ereignisse, Voll-Abgleich, pg_notify) kommt in Phase 1. + * Zwei Wege, absichtlich doppelt: + * - Gateway-Ereignisse, damit eine Änderung in unter einer Sekunde ankommt + * - Voll-Abgleich alle fünf Minuten als Sicherheitsnetz, falls ein Ereignis + * verloren geht oder der Prozess eine Weile weg war */ async function main(): Promise { const env = loadEnv() @@ -23,11 +26,73 @@ async function main(): Promise { let gatewayReady = false + async function fullSync(reason: string): Promise { + const guild = await client.guilds.fetch(env.discordGuildId) + const roleCount = await syncRoles(db, await guild.fetch()) + const members = await guild.members.fetch() + + let changed = 0 + for (const member of members.values()) { + if (member.user.bot) continue + if (await syncMember(db, member)) changed++ + await enforceMembership(db, member.id) + } + + // Wer nicht mehr auf dem Server ist, hat auch keine Sitzung mehr. + const known = await db.user.findMany({ where: { isActive: true }, select: { id: true } }) + for (const user of known) { + if (!members.has(user.id)) await handleDeparture(db, user.id) + } + + console.log( + `[worker] Voll-Abgleich (${reason}): ${roleCount} Rollen, ${members.size} Mitglieder, ${changed} geändert`, + ) + } + client.once(Events.ClientReady, (ready) => { gatewayReady = true console.log(`[worker] am Gateway als ${ready.user.tag}`) + void fullSync('Start').catch((error: unknown) => + console.error('[worker] erster Abgleich fehlgeschlagen', error), + ) }) + client.on(Events.GuildMemberAdd, (member) => { + if (member.guild.id !== env.discordGuildId || member.user.bot) return + void (async () => { + await syncMember(db, member) + await enforceMembership(db, member.id) + })().catch((error: unknown) => console.error('[worker] guildMemberAdd', error)) + }) + + client.on(Events.GuildMemberUpdate, (_before, member) => { + if (member.guild.id !== env.discordGuildId || member.user.bot) return + void (async () => { + await syncMember(db, member) + await enforceMembership(db, member.id) + })().catch((error: unknown) => console.error('[worker] guildMemberUpdate', error)) + }) + + client.on(Events.GuildMemberRemove, (member) => { + if (member.guild.id !== env.discordGuildId) return + void handleDeparture(db, member.id).catch((error: unknown) => + console.error('[worker] guildMemberRemove', error), + ) + }) + + // Wird eine Rolle umbenannt, verschoben oder gelöscht, muss der Adminbereich + // das sehen. Der Trigger auf RoleMapping löst danach pg_notify aus. + const onRoleChange = (guild: Guild): void => { + if (guild.id !== env.discordGuildId) return + void syncRoles(db, guild).catch((error: unknown) => + console.error('[worker] Rollenabgleich', error), + ) + } + + client.on(Events.GuildRoleCreate, (role) => onRoleChange(role.guild)) + client.on(Events.GuildRoleUpdate, (_before, role) => onRoleChange(role.guild)) + client.on(Events.GuildRoleDelete, (role) => onRoleChange(role.guild)) + client.on(Events.ShardDisconnect, (_event, shardId) => { gatewayReady = false console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`) @@ -36,11 +101,12 @@ async function main(): Promise { client.on(Events.ShardResume, () => { gatewayReady = true console.log('[worker] Verbindung wiederhergestellt') + void fullSync('nach Wiederverbindung').catch((error: unknown) => + console.error('[worker] Abgleich nach Wiederverbindung fehlgeschlagen', error), + ) }) - client.on(Events.Error, (error) => { - console.error('[worker] Gateway-Fehler', error) - }) + client.on(Events.Error, (error) => console.error('[worker] Gateway-Fehler', error)) const heartbeat = setInterval(() => { void writeHeartbeat( @@ -50,9 +116,17 @@ async function main(): Promise { ).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error)) }, env.heartbeatIntervalMs) + const periodicSync = setInterval(() => { + if (!gatewayReady) return + void fullSync('planmäßig').catch((error: unknown) => + console.error('[worker] Voll-Abgleich fehlgeschlagen', error), + ) + }, env.fullSyncIntervalMs) + async function shutdown(signal: string): Promise { console.log(`[worker] ${signal}, fahre herunter`) clearInterval(heartbeat) + clearInterval(periodicSync) await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined) await client.destroy() await db.$disconnect() diff --git a/worker/src/sync.ts b/worker/src/sync.ts new file mode 100644 index 0000000..8b311ff --- /dev/null +++ b/worker/src/sync.ts @@ -0,0 +1,161 @@ +import type { Guild, GuildMember, Role } from 'discord.js' +import type { PrismaClient } from '@afis/db' + +/** + * Spiegelt Guild und Mitglieder nach Postgres. + * + * Die Web-App fragt nie live bei Discord an. Dadurch ist eine Rechteänderung + * in Discord in unter einer Sekunde in der App wirksam, ohne dass ein + * einziger Request auf die Discord-API wartet. Siehe docs/02-architektur.md. + */ + +/** Rollen, die Discord selbst verwaltet und die niemandem etwas nützen. */ +function isRelevantRole(role: Role): boolean { + return !role.managed && role.id !== role.guild.id // @everyone hat die Guild-ID +} + +/** + * Legt für jede Rolle der Guild eine Zeile in `RoleMapping` an, damit im + * Adminbereich nichts von Hand eingetragen werden muss. Vorhandene + * Zuordnungen bleiben unangetastet: gespiegelt werden nur Name und Position. + * + * Rollen, die es nicht mehr gibt, werden nicht gelöscht, sondern auf + * `isPresent = false` gesetzt. Beim Umzug auf einen anderen Discord-Server + * bleiben sie damit als Vorlage stehen. Siehe docs/13-entscheidungen.md. + */ +export async function syncRoles(db: PrismaClient, guild: Guild): Promise { + const roles = [...guild.roles.cache.values()].filter(isRelevantRole) + + for (const role of roles) { + await db.roleMapping.upsert({ + where: { discordRoleId: role.id }, + create: { + discordRoleId: role.id, + label: role.name, + sortOrder: role.position, + isPresent: true, + }, + update: { + label: role.name, + sortOrder: role.position, + isPresent: true, + }, + }) + } + + await db.roleMapping.updateMany({ + where: { discordRoleId: { notIn: roles.map((r) => r.id) }, isPresent: true }, + data: { isPresent: false }, + }) + + return roles.length +} + +/** + * Schreibt Rollen eines Mitglieds. Gibt zurück, ob sich etwas geändert hat, + * damit nur dann `pg_notify` ausgelöst wird — der Voll-Abgleich läuft alle + * fünf Minuten und soll nicht jedes Mal alle Caches leeren. + * + * `pg_notify` selbst kommt aus dem Trigger `user_role_notify` auf `UserRole`, + * es muss hier nichts von Hand gesendet werden. + */ +export async function syncMember(db: PrismaClient, member: GuildMember): Promise { + const roleIds = [...member.roles.cache.keys()].filter((id) => id !== member.guild.id) + const displayName = member.nickname ?? member.user.globalName ?? member.user.username + + const [existingUser, existingRoles] = await Promise.all([ + db.user.findUnique({ + where: { id: member.id }, + select: { displayName: true, username: true, avatarHash: true, isActive: true }, + }), + db.userRole.findMany({ where: { userId: member.id }, select: { discordRoleId: true } }), + ]) + + const before = new Set(existingRoles.map((r) => r.discordRoleId)) + const after = new Set(roleIds) + const rolesChanged = + before.size !== after.size || [...after].some((id) => !before.has(id)) + + const profileChanged = + !existingUser || + existingUser.displayName !== displayName || + existingUser.username !== member.user.username || + existingUser.avatarHash !== member.user.avatar || + !existingUser.isActive + + if (!rolesChanged && !profileChanged) return false + + await db.$transaction([ + db.user.upsert({ + where: { id: member.id }, + create: { + id: member.id, + username: member.user.username, + displayName, + avatarHash: member.user.avatar, + isActive: true, + }, + update: { + username: member.user.username, + displayName, + avatarHash: member.user.avatar, + isActive: true, + }, + }), + db.userRole.deleteMany({ where: { userId: member.id, discordRoleId: { notIn: roleIds } } }), + ...roleIds.map((discordRoleId) => + db.userRole.upsert({ + where: { userId_discordRoleId: { userId: member.id, discordRoleId } }, + create: { userId: member.id, discordRoleId }, + update: {}, + }), + ), + ]) + + return true +} + +/** + * Beendet alle Sitzungen eines Nutzers, wenn er keine Rolle mehr trägt, die + * zur Anmeldung berechtigt. Der Datensatz bleibt stehen, damit Autorenangaben + * in alten Akten gültig bleiben. Siehe docs/03-auth-und-rechte.md. + * + * Das passiert hier und nicht in der Web-App, weil es sonst erst beim nächsten + * Request des Betroffenen griffe — und genau darauf kann man sich nicht + * verlassen, wenn jemandem gerade der Zugriff entzogen wurde. + */ +export async function enforceMembership(db: PrismaClient, userId: string): Promise { + const roles = await db.userRole.findMany({ + where: { userId }, + select: { discordRoleId: true }, + }) + + const granting = await db.roleMapping.count({ + where: { + discordRoleId: { in: roles.map((r) => r.discordRoleId) }, + grantsMembership: true, + }, + }) + + if (granting > 0) return false + + const [{ count }] = await Promise.all([ + db.session.deleteMany({ where: { userId } }), + db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined), + ]) + + if (count > 0) { + console.log(`[worker] ${count} Sitzung(en) von ${userId} beendet, keine AfiS-Rolle mehr`) + } + return true +} + +/** Serveraustritt: Sitzungen beenden, Datensatz behalten. */ +export async function handleDeparture(db: PrismaClient, userId: string): Promise { + await db.$transaction([ + db.session.deleteMany({ where: { userId } }), + db.userRole.deleteMany({ where: { userId } }), + ]) + await db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined) + console.log(`[worker] ${userId} hat den Server verlassen, Sitzungen beendet`) +} diff --git a/worker/tests/mitgliedschaft.test.ts b/worker/tests/mitgliedschaft.test.ts new file mode 100644 index 0000000..04ef069 --- /dev/null +++ b/worker/tests/mitgliedschaft.test.ts @@ -0,0 +1,96 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createPrismaClient, type PrismaClient } from '@afis/db' +import { enforceMembership, handleDeparture } from '../src/sync.js' + +/** + * „Rollenentzug in Discord beendet laufende Sitzungen" aus + * docs/03-auth-und-rechte.md. + * + * Das Zeitbudget von fünf Sekunden ist im Betrieb die Summe aus + * Gateway-Ereignis und diesem Schritt. Geprüft wird hier der Schritt, der in + * unserer Hand liegt: die Sitzungen müssen weg sein, und der Datensatz muss + * stehen bleiben, damit Autorenangaben in alten Akten gültig bleiben. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +let db: PrismaClient +const marke = `worker-${Date.now()}` + +const rolleAfis = `${marke}-afis` +const rolleGast = `${marke}-gast` + +suite('Sitzungen beim Rollenentzug', () => { + beforeAll(async () => { + db = createPrismaClient(url) + await db.roleMapping.createMany({ + data: [ + { discordRoleId: rolleAfis, label: 'AfiS', category: 'MEMBERSHIP', grantsMembership: true }, + { discordRoleId: rolleGast, label: 'Gast', category: 'UNSORTED' }, + ], + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + async function nutzerMitSitzung(id: string, rollen: string[]): Promise { + await db.user.create({ data: { id, username: id, displayName: id } }) + if (rollen.length > 0) { + await db.userRole.createMany({ + data: rollen.map((discordRoleId) => ({ userId: id, discordRoleId })), + }) + } + await db.session.create({ + data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) }, + }) + } + + it('lässt eine Sitzung stehen, solange die AfiS-Rolle vorliegt', async () => { + const id = `${marke}-bleibt` + await nutzerMitSitzung(id, [rolleAfis]) + + const beendet = await enforceMembership(db, id) + + expect(beendet).toBe(false) + expect(await db.session.count({ where: { userId: id } })).toBe(1) + }) + + it('beendet alle Sitzungen, sobald die AfiS-Rolle fehlt', async () => { + const id = `${marke}-verliert` + await nutzerMitSitzung(id, [rolleAfis, rolleGast]) + await db.session.create({ + data: { userId: id, expiresAt: new Date(Date.now() + 86_400_000) }, + }) + expect(await db.session.count({ where: { userId: id } })).toBe(2) + + // Rollenentzug in Discord, hier direkt nachgestellt. + await db.userRole.deleteMany({ where: { userId: id, discordRoleId: rolleAfis } }) + + const start = Date.now() + const beendet = await enforceMembership(db, id) + const dauer = Date.now() - start + + expect(beendet).toBe(true) + expect(await db.session.count({ where: { userId: id } })).toBe(0) + expect(dauer).toBeLessThan(5000) + + // Der Datensatz bleibt, nur inaktiv. + const nutzer = await db.user.findUnique({ where: { id } }) + expect(nutzer).not.toBeNull() + expect(nutzer?.isActive).toBe(false) + }) + + it('beendet Sitzungen beim Serveraustritt und behält den Nutzer', async () => { + const id = `${marke}-austritt` + await nutzerMitSitzung(id, [rolleAfis]) + + await handleDeparture(db, id) + + expect(await db.session.count({ where: { userId: id } })).toBe(0) + expect(await db.userRole.count({ where: { userId: id } })).toBe(0) + const nutzer = await db.user.findUnique({ where: { id } }) + expect(nutzer?.isActive).toBe(false) + }) +}) diff --git a/worker/tests/setup.ts b/worker/tests/setup.ts new file mode 100644 index 0000000..c7fd661 --- /dev/null +++ b/worker/tests/setup.ts @@ -0,0 +1,5 @@ +import { loadEnvFromRepoRoot } from '@afis/db' + +// Damit die Tests die lokale Entwicklungsdatenbank finden. In der CI stehen +// die Werte bereits in der Umgebung. +loadEnvFromRepoRoot() diff --git a/worker/vitest.config.mts b/worker/vitest.config.mts new file mode 100644 index 0000000..b265e42 --- /dev/null +++ b/worker/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + setupFiles: ['./tests/setup.ts'], + fileParallelism: false, + testTimeout: 20_000, + }, +})