diff --git a/worker/src/env.ts b/worker/src/env.ts index 8e0e91f..be6e846 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -7,6 +7,14 @@ export type WorkerEnv = { databaseUrl: string discordBotToken: string discordGuildId: string + /** + * Notfallzugang. Diese Nutzer behalten ihre Sitzung, auch wenn keine ihrer + * Rollen zur Anmeldung berechtigt. Ohne das wirft der Voll-Abgleich den + * Administrator alle fünf Minuten hinaus — also genau dann, wenn er das + * Rollen-Mapping erst noch einrichten will. + * Siehe docs/03-auth-und-rechte.md. + */ + bootstrapAdminIds: string[] /** Voll-Abgleich als Sicherheitsnetz, siehe docs/02-architektur.md. */ fullSyncIntervalMs: number heartbeatIntervalMs: number @@ -24,6 +32,10 @@ export function loadEnv(): WorkerEnv { databaseUrl: required('DATABASE_URL'), discordBotToken: required('DISCORD_BOT_TOKEN'), discordGuildId: required('DISCORD_GUILD_ID'), + bootstrapAdminIds: (process.env.BOOTSTRAP_ADMIN_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), fullSyncIntervalMs: Number(process.env.WORKER_FULL_SYNC_MS ?? 5 * 60_000), heartbeatIntervalMs: Number(process.env.WORKER_HEARTBEAT_MS ?? 30_000), adminChannelId: process.env.DISCORD_ADMIN_CHANNEL_ID, diff --git a/worker/src/index.ts b/worker/src/index.ts index b9e81cd..6955913 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -35,7 +35,7 @@ async function main(): Promise { for (const member of members.values()) { if (member.user.bot) continue if (await syncMember(db, member)) changed++ - await enforceMembership(db, member.id) + await enforceMembership(db, member.id, env.bootstrapAdminIds) } // Wer nicht mehr auf dem Server ist, hat auch keine Sitzung mehr. @@ -61,7 +61,7 @@ async function main(): Promise { if (member.guild.id !== env.discordGuildId || member.user.bot) return void (async () => { await syncMember(db, member) - await enforceMembership(db, member.id) + await enforceMembership(db, member.id, env.bootstrapAdminIds) })().catch((error: unknown) => console.error('[worker] guildMemberAdd', error)) }) @@ -69,7 +69,7 @@ async function main(): Promise { if (member.guild.id !== env.discordGuildId || member.user.bot) return void (async () => { await syncMember(db, member) - await enforceMembership(db, member.id) + await enforceMembership(db, member.id, env.bootstrapAdminIds) })().catch((error: unknown) => console.error('[worker] guildMemberUpdate', error)) }) diff --git a/worker/src/sync.ts b/worker/src/sync.ts index 8b311ff..7888170 100644 --- a/worker/src/sync.ts +++ b/worker/src/sync.ts @@ -124,7 +124,16 @@ export async function syncMember(db: PrismaClient, member: GuildMember): Promise * 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 { +export async function enforceMembership( + db: PrismaClient, + userId: string, + bootstrapAdminIds: readonly string[] = [], +): Promise { + // Der Notfallzugang gilt auch hier. Sonst wirft der Voll-Abgleich den + // Administrator alle fünf Minuten hinaus, solange noch keine Rolle zur + // Anmeldung berechtigt — und einrichten kann er es dann nie. + if (bootstrapAdminIds.includes(userId)) return false + const roles = await db.userRole.findMany({ where: { userId }, select: { discordRoleId: true }, diff --git a/worker/tests/mitgliedschaft.test.ts b/worker/tests/mitgliedschaft.test.ts index 04ef069..ac39a13 100644 --- a/worker/tests/mitgliedschaft.test.ts +++ b/worker/tests/mitgliedschaft.test.ts @@ -82,6 +82,20 @@ suite('Sitzungen beim Rollenentzug', () => { expect(nutzer?.isActive).toBe(false) }) + it('wirft den Notfallzugang nicht hinaus, auch ohne AfiS-Rolle', async () => { + // Sonst wirft der Voll-Abgleich den Administrator alle fünf Minuten + // hinaus, solange noch keine Rolle zur Anmeldung berechtigt — und + // einrichten kann er das Rollen-Mapping dann nie. + const id = `${marke}-notfall` + await nutzerMitSitzung(id, [rolleGast]) + + const beendet = await enforceMembership(db, id, [id]) + + expect(beendet).toBe(false) + expect(await db.session.count({ where: { userId: id } })).toBe(1) + expect((await db.user.findUnique({ where: { id } }))?.isActive).toBe(true) + }) + it('beendet Sitzungen beim Serveraustritt und behält den Nutzer', async () => { const id = `${marke}-austritt` await nutzerMitSitzung(id, [rolleAfis])