diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 7cce69c3..a3e067b3 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -141,6 +141,16 @@ export function updateUser(id: string, data: { username?: string; email?: string } const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null; + // Don't let the admin UI demote the last remaining admin — that would leave the + // instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274 + if (role && role !== 'admin') { + const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined; + if (current?.role === 'admin') { + const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count; + if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 }; + } + } + db.prepare(` UPDATE users SET username = COALESCE(?, username), diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 774faa83..558b3a12 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -385,8 +385,20 @@ export function findOrCreateUser( if (process.env.OIDC_ADMIN_VALUE) { const newRole = resolveOidcRole(userInfo, false); if (user.role !== newRole) { - db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); - user = { ...user, role: newRole } as User; + // Never let the claim-based downgrade strip the last admin. The bootstrap + // admin (first SSO user) usually doesn't carry the admin claim, so a forced + // re-login — e.g. after a JWT-secret rotation — would otherwise demote it and + // lock an OIDC-only instance out for good. #1274 + const demotingLastAdmin = + user.role === 'admin' && + newRole !== 'admin' && + (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1; + if (demotingLastAdmin) { + console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`); + } else { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); + user = { ...user, role: newRole } as User; + } } } return { user };