Entzug greift auch im offenen Ereignisstrom
Nachgesehen, was bei Herabstufung, Rollenentzug, Rauswurf und Austritt passiert. Für Requests war alles abgedeckt: der Worker löscht die Sitzungen, ein Trigger verwirft die zwischengespeicherten Rechte, getCurrentUser prüft bei jedem Request Sitzungszeile, isActive und Mitgliedschaft nach. Die Zeile in der Datenbank ist der Nachweis, nicht das Cookie - und eine Zeile lässt sich zurückziehen. Eine Stelle fiel dabei durch: der Ereignisstrom stellt keine neuen Requests. Er prüfte einmal beim Verbinden und lief danach mit einer eingefrorenen Rechtekopie weiter. Wer mit offenem Tab hinausgeworfen wurde, bekam weiter Chatnachrichten zugestellt, mit vollem Text - jeder Seitenaufruf wäre abgemeldet gewesen, aber es machte ja keiner mehr einen. /api/stream löst Sitzung und Rechte jetzt bei jedem Ereignis und bei jedem Herzschlag neu auf. Ändern sich die Rechte, werden auch die erlaubten Themen neu bestimmt: ein Ereignis zu einem Verlaufseintrag trägt nur eine Kennung, das Thema ist dort die einzige Schranke. Ist die Sitzung weg, geht ein revoked hinaus und die Verbindung wird geschlossen - Schliessen allein genügt nicht, EventSource baut sofort wieder auf. Fünf Tests für die Web-Hälfte der Kette; die Discord-Hälfte deckte worker/tests/mitgliedschaft.test.ts schon ab.
This commit is contained in:
+92
-11
@@ -1,4 +1,7 @@
|
|||||||
import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
|
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 { db } from '@/lib/db'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||||
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
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
|
* durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder
|
||||||
* auf. Siehe docs/06-chat-und-realtime.md.
|
* 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
|
* 1. **Beim Verbinden**: welche der angefragten Themen darf dieser Nutzer
|
||||||
* überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht —
|
* überhaupt abonnieren. Wer ein fremdes Thema nennt, bekommt es nicht —
|
||||||
* und erfährt auch nicht, ob es existiert.
|
* und erfährt auch nicht, ob es existiert.
|
||||||
* 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen.
|
* 2. **Je Ereignis**: der Inhalt wird mit den Rechten des Empfängers geladen.
|
||||||
* Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch
|
* Eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch
|
||||||
* sofort, ohne dass jemand neu laden muss.
|
* 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<Response> {
|
export async function GET(request: Request): Promise<Response> {
|
||||||
const user = await getCurrentUser()
|
const angemeldet = await getCurrentUser()
|
||||||
if (!user) return new Response(null, { status: 404 })
|
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 url = new URL(request.url)
|
||||||
const gewuenscht = (url.searchParams.get('topics') ?? '')
|
const gewuenscht = (url.searchParams.get('topics') ?? '')
|
||||||
@@ -44,9 +55,38 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
|
|
||||||
const erlaubt = await erlaubteThemen(user, gewuenscht)
|
let rechte = user.permissions
|
||||||
|
let erlaubt = await erlaubteThemen(user, gewuenscht)
|
||||||
await ensureListening()
|
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<EffectivePermissions | null> {
|
||||||
|
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()
|
const encoder = new TextEncoder()
|
||||||
let heartbeat: ReturnType<typeof setInterval> | null = null
|
let heartbeat: ReturnType<typeof setInterval> | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
@@ -78,18 +118,59 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
|
|
||||||
send('ready', { topics: erlaubt })
|
send('ready', { topics: erlaubt })
|
||||||
|
|
||||||
|
const schliessen = () => {
|
||||||
|
offen = false
|
||||||
|
if (heartbeat) clearInterval(heartbeat)
|
||||||
|
unsubscribe?.()
|
||||||
|
try {
|
||||||
|
controller.close()
|
||||||
|
} catch {
|
||||||
|
// schon zu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
if (!offen) return
|
if (!offen) return
|
||||||
try {
|
void (async () => {
|
||||||
controller.enqueue(encoder.encode(': ping\n\n'))
|
// Der Herzschlag ist zugleich die regelmässige Rückfrage: wer
|
||||||
} catch {
|
// hinausgeworfen wurde, verliert spätestens hier die Verbindung.
|
||||||
offen = false
|
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)
|
}, HEARTBEAT_MS)
|
||||||
|
|
||||||
unsubscribe = subscribe((event) => {
|
unsubscribe = subscribe((event) => {
|
||||||
if (!erlaubt.includes(event.t)) return
|
void (async () => {
|
||||||
void zustellen(user, event, send)
|
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', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ export function useStream(topics: string[], handlers: StreamHandlers): void {
|
|||||||
angemeldet.push([name, listener])
|
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 () => {
|
return () => {
|
||||||
for (const [name, listener] of angemeldet) quelle.removeEventListener(name, listener)
|
for (const [name, listener] of angemeldet) quelle.removeEventListener(name, listener)
|
||||||
quelle.close()
|
quelle.close()
|
||||||
|
|||||||
@@ -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<string>((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<never>((_, 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user