diff --git a/web/app/api/stream/route.ts b/web/app/api/stream/route.ts index 6ca961f..641798d 100644 --- a/web/app/api/stream/route.ts +++ b/web/app/api/stream/route.ts @@ -1,4 +1,7 @@ import { getCurrentUser, type CurrentUser } from '@/lib/auth/current' +import { loadSession } from '@/lib/auth/session' +import { resolvePermissions } from '@/lib/auth/effective' +import type { EffectivePermissions } from '@/lib/auth/permissions' import { db } from '@/lib/db' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleMessageWhere } from '@/lib/access/chatFilter' @@ -25,17 +28,25 @@ const HEARTBEAT_MS = 25_000 * durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder * auf. Siehe docs/06-chat-und-realtime.md. * - * Zwei Prüfungen, beide nötig: + * Drei Prüfungen, alle nötig: * 1. **Beim Verbinden**: welche der angefragten Themen darf dieser Nutzer * überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht — * und erfährt auch nicht, ob es existiert. * 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen. * Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch * sofort, ohne dass jemand neu laden muss. + * 3. **Laufend**: Sitzung und Rechte werden neu aufgelöst, nicht einmal beim + * Verbinden eingefroren. Eine offene Verbindung ist sonst der eine Ort im + * System, an dem ein Entzug nicht ankommt: der Worker löscht die Sitzung, + * jeder Seitenaufruf ist abgemeldet — und der Strom läuft weiter, weil + * niemand mehr fragt. Siehe docs/03-auth-und-rechte.md. */ export async function GET(request: Request): Promise { - const user = await getCurrentUser() - if (!user) return new Response(null, { status: 404 }) + const angemeldet = await getCurrentUser() + if (!angemeldet) return new Response(null, { status: 404 }) + // Eigene Konstante: die Verengung auf „nicht null" überlebt den Sprung in + // eine Closure sonst nicht. + const user = angemeldet const url = new URL(request.url) const gewuenscht = (url.searchParams.get('topics') ?? '') @@ -44,9 +55,38 @@ export async function GET(request: Request): Promise { .filter(Boolean) .slice(0, 20) - const erlaubt = await erlaubteThemen(user, gewuenscht) + let rechte = user.permissions + let erlaubt = await erlaubteThemen(user, gewuenscht) await ensureListening() + /** + * Ist der Nutzer noch der, der er beim Verbinden war? + * + * Gibt die **aktuellen** Rechte zurück oder `null`, wenn die Sitzung weg + * ist, das Konto stillgelegt wurde oder die Mitgliedschaft fehlt. Die + * Auflösung ist zwischengespeichert und wird bei jeder Rollenänderung über + * `member_changed` verworfen, kostet also im Normalfall nichts. + */ + async function nochBerechtigt(): Promise { + const sitzung = await loadSession(user.sessionId) + if (!sitzung) return null + + const konto = await db.user.findUnique({ + where: { id: user.id }, + select: { isActive: true }, + }) + if (!konto?.isActive) return null + + const frisch = await resolvePermissions(user.id) + if (!frisch.hasMembership && !frisch.isAdmin) return null + return frisch + } + + /** Ob sich an den Rechten etwas geändert hat, das die Themen betrifft. */ + function abdruck(p: EffectivePermissions): string { + return `${p.clearanceLevel}|${p.isAdmin}|${[...p.permissions].sort().join(',')}` + } + const encoder = new TextEncoder() let heartbeat: ReturnType | null = null let unsubscribe: (() => void) | null = null @@ -78,18 +118,59 @@ export async function GET(request: Request): Promise { send('ready', { topics: erlaubt }) + const schliessen = () => { + offen = false + if (heartbeat) clearInterval(heartbeat) + unsubscribe?.() + try { + controller.close() + } catch { + // schon zu + } + } + heartbeat = setInterval(() => { if (!offen) return - try { - controller.enqueue(encoder.encode(': ping\n\n')) - } catch { - offen = false - } + void (async () => { + // Der Herzschlag ist zugleich die regelmässige Rückfrage: wer + // hinausgeworfen wurde, verliert spätestens hier die Verbindung. + const frisch = await nochBerechtigt() + if (!frisch) { + send('revoked', {}) + schliessen() + return + } + if (abdruck(frisch) !== abdruck(rechte)) { + rechte = frisch + erlaubt = await erlaubteThemen({ ...user, permissions: frisch }, gewuenscht) + } + try { + controller.enqueue(encoder.encode(': ping\n\n')) + } catch { + offen = false + } + })() }, HEARTBEAT_MS) unsubscribe = subscribe((event) => { - if (!erlaubt.includes(event.t)) return - void zustellen(user, event, send) + void (async () => { + const frisch = await nochBerechtigt() + if (!frisch) { + send('revoked', {}) + schliessen() + return + } + // Rechte geändert: die erlaubten Themen gelten nicht mehr. Sie neu + // zu bestimmen ist wichtiger als die eine Zustellung — ein Ereignis + // trägt bei Verlaufseinträgen nur eine Kennung, das Thema ist dort + // die **einzige** Schranke. + if (abdruck(frisch) !== abdruck(rechte)) { + rechte = frisch + erlaubt = await erlaubteThemen({ ...user, permissions: frisch }, gewuenscht) + } + if (!erlaubt.includes(event.t)) return + await zustellen({ ...user, permissions: rechte }, event, send) + })() }) request.signal.addEventListener('abort', () => { diff --git a/web/components/realtime/useStream.ts b/web/components/realtime/useStream.ts index 6da109d..7685d4e 100644 --- a/web/components/realtime/useStream.ts +++ b/web/components/realtime/useStream.ts @@ -43,6 +43,17 @@ export function useStream(topics: string[], handlers: StreamHandlers): void { angemeldet.push([name, listener]) } + // Der Server meldet einen Entzug ausdrücklich, statt die Verbindung nur + // fallen zu lassen: `EventSource` baut sonst sofort wieder auf, und man + // arbeitet auf einer Seite weiter, für die man keine Rechte mehr hat. + // Neu laden führt in die Anmeldung — dorthin, wo man jetzt hingehört. + const entzogen: EventListener = () => { + quelle.close() + window.location.reload() + } + quelle.addEventListener('revoked', entzogen) + angemeldet.push(['revoked', entzogen]) + return () => { for (const [name, listener] of angemeldet) quelle.removeEventListener(name, listener) quelle.close() diff --git a/web/tests/entzug.test.ts b/web/tests/entzug.test.ts new file mode 100644 index 0000000..ffee4d7 --- /dev/null +++ b/web/tests/entzug.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Client } from 'pg' +import { db } from '@/lib/db' +import { loadSession, createSession } from '@/lib/auth/session' +import { invalidateUser, resolvePermissions } from '@/lib/auth/effective' + +/** + * Was passiert, wenn jemand seinen Zugriff verliert. + * + * Vier Wege führen dorthin: Herabstufung, Entzug der AfiS-Rolle, Rauswurf und + * selbst gehen. Der Worker deckt die Discord-Seite ab (siehe + * worker/tests/mitgliedschaft.test.ts) — hier steht die andere Hälfte: dass + * die Web-App den Entzug auch bemerkt. + * + * Der Kern ist, dass die **Zeile in der Datenbank** der Sitzungsnachweis ist + * und nicht das Cookie. Ein Cookie lässt sich nicht zurückziehen, eine Zeile + * schon. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +const marke = `entzug-${Date.now()}` + +suite('Entzug des Zugriffs', () => { + const nutzer = `${marke}-nutzer` + const rolleHoch = `${marke}-rolle-hoch` + const rolleGast = `${marke}-rolle-gast` + + beforeAll(async () => { + await db.user.create({ data: { id: nutzer, username: nutzer, displayName: nutzer } }) + await db.roleMapping.create({ + data: { + discordRoleId: rolleHoch, + label: 'Ermittler', + clearanceLevel: 4, + permissions: ['case.read'], + grantsMembership: true, + }, + }) + await db.roleMapping.create({ + data: { + discordRoleId: rolleGast, + label: 'Gast', + clearanceLevel: 0, + permissions: [], + grantsMembership: false, + }, + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + it('nimmt einer Herabstufung sofort die Stufe, sobald der Zwischenspeicher fällt', async () => { + await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleHoch } }) + invalidateUser(nutzer) + expect((await resolvePermissions(nutzer)).clearanceLevel).toBe(4) + + // Herabgestuft: die Rolle mit Stufe 4 weg, die Gastrolle bleibt. + await db.userRole.deleteMany({ where: { userId: nutzer, discordRoleId: rolleHoch } }) + await db.userRole.create({ data: { userId: nutzer, discordRoleId: rolleGast } }) + invalidateUser(nutzer) + + const danach = await resolvePermissions(nutzer) + expect(danach.clearanceLevel).toBe(0) + expect(danach.permissions.has('case.read')).toBe(false) + // Und ohne berechtigende Rolle auch keine Mitgliedschaft mehr. + expect(danach.hasMembership).toBe(false) + }) + + it('meldet jede Rollenänderung über den Kanal, der den Zwischenspeicher leert', async () => { + // Das ist der Grund, warum eine Herabstufung in unter einer Sekunde wirkt + // und nicht erst nach der Verfallszeit von 60 Sekunden. Ohne diesen + // Auslöser bliebe die alte Stufe stehen, obwohl sie in der Datenbank + // längst weg ist. + const horcher = new Client({ connectionString: url }) + await horcher.connect() + try { + const gemeldet = new Promise((resolve) => { + horcher.on('notification', (nachricht) => { + if (nachricht.channel === 'member_changed' && nachricht.payload === nutzer) { + resolve(nachricht.payload) + } + }) + }) + await horcher.query('LISTEN member_changed') + + await db.userRole.deleteMany({ where: { userId: nutzer, discordRoleId: rolleGast } }) + + const abgelaufen = new Promise((_, ab) => + setTimeout(() => ab(new Error('keine Meldung binnen 5 s')), 5000), + ) + expect(await Promise.race([gemeldet, abgelaufen])).toBe(nutzer) + } finally { + await horcher.end() + } + }) + + it('macht eine gelöschte Sitzung sofort ungültig', async () => { + // Rauswurf und Austritt laufen beide hierüber: der Worker löscht die + // Zeile, und der nächste Request findet nichts mehr. + const sitzung = await createSession(nutzer) + expect(await loadSession(sitzung)).not.toBeNull() + + await db.session.deleteMany({ where: { userId: nutzer } }) + expect(await loadSession(sitzung)).toBeNull() + }) + + it('lässt eine abgelaufene Sitzung nicht gelten und räumt sie weg', async () => { + const sitzung = await db.session.create({ + data: { + userId: nutzer, + expiresAt: new Date(Date.now() - 1000), + }, + select: { id: true }, + }) + + expect(await loadSession(sitzung.id)).toBeNull() + // Und sie ist danach weg, nicht nur abgelehnt. + expect(await db.session.findUnique({ where: { id: sitzung.id } })).toBeNull() + }) + + it('hält einen stillgelegten Nutzer draussen, auch mit gültiger Sitzung', async () => { + // handleDeparture setzt isActive auf false. getCurrentUser prüft das bei + // jedem Request zusätzlich zur Sitzung. + await db.user.update({ where: { id: nutzer }, data: { isActive: false } }) + const konto = await db.user.findUniqueOrThrow({ where: { id: nutzer } }) + expect(konto.isActive).toBe(false) + }) +})