feat: add super-admin role with admin impersonation support
Add a new super-admin role that can impersonate other admins. Regular admins retain all existing permissions but cannot impersonate other admins or promote users to super-admin. Backend changes: - Add isSuperAdmin field to users table with default false - Add isSuperAdmin() check function to auth service - Update JWT tokens to include isSuperAdmin claim - Allow super-admins to impersonate other admins - Add security rules for super-admin role changes Frontend changes: - Display "Super Admin" badge with gradient styling - Add "Super Admin" option to role change modal - Enable impersonate button for super-admins targeting admins - Add "Super Admins Only" filter option Security rules: - Only super-admins can promote/demote super-admins - Regular admins cannot promote users to super-admin - Super-admins cannot demote themselves - Cannot demote the last super-admin Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { eq, sql, desc } from 'drizzle-orm';
|
||||
import { db, sqlite, logger } from '../config.js';
|
||||
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
||||
import { getUserByIdFull, isAdmin } from './auth.service.js';
|
||||
import { getUserByIdFull, isAdmin, isSuperAdmin } from './auth.service.js';
|
||||
|
||||
/**
|
||||
* Log an admin action for audit trail
|
||||
@@ -160,7 +160,7 @@ export async function getUserStats() {
|
||||
* @param {number} adminId - Admin user ID
|
||||
* @param {number} targetUserId - Target user ID to impersonate
|
||||
* @returns {Promise<Object>} Target user object
|
||||
* @throws {Error} If not admin or trying to impersonate another admin
|
||||
* @throws {Error} If not admin or trying to impersonate another admin (without super-admin)
|
||||
*/
|
||||
export async function impersonateUser(adminId, targetUserId) {
|
||||
// Verify the requester is an admin
|
||||
@@ -175,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// Check if target is also an admin (prevent admin impersonation)
|
||||
// Check if target is also an admin
|
||||
if (targetUser.isAdmin) {
|
||||
throw new Error('Cannot impersonate another admin user');
|
||||
// Only super-admins can impersonate other admins
|
||||
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
|
||||
if (!requesterIsSuperAdmin) {
|
||||
throw new Error('Cannot impersonate another admin user (super-admin required)');
|
||||
}
|
||||
// Prevent self-impersonation (edge case)
|
||||
if (adminId === targetUserId) {
|
||||
throw new Error('Cannot impersonate yourself');
|
||||
}
|
||||
}
|
||||
|
||||
// Log impersonation action
|
||||
@@ -271,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||
* Update user admin status (admin operation)
|
||||
* @param {number} adminId - Admin user ID making the change
|
||||
* @param {number} targetUserId - User ID to update
|
||||
* @param {boolean} newIsAdmin - New admin flag
|
||||
* @param {string} newRole - New role: 'user', 'admin', or 'super-admin'
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If not admin or would remove last admin
|
||||
* @throws {Error} If not admin or violates security rules
|
||||
*/
|
||||
export async function changeUserRole(adminId, targetUserId, newIsAdmin) {
|
||||
export async function changeUserRole(adminId, targetUserId, newRole) {
|
||||
// Validate role
|
||||
const validRoles = ['user', 'admin', 'super-admin'];
|
||||
if (!validRoles.includes(newRole)) {
|
||||
throw new Error('Invalid role. Must be one of: user, admin, super-admin');
|
||||
}
|
||||
|
||||
// Verify the requester is an admin
|
||||
const requesterIsAdmin = await isAdmin(adminId);
|
||||
if (!requesterIsAdmin) {
|
||||
throw new Error('Only admins can change user admin status');
|
||||
throw new Error('Only admins can change user roles');
|
||||
}
|
||||
|
||||
// Get requester super-admin status
|
||||
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
|
||||
|
||||
// Get target user
|
||||
const targetUser = await getUserByIdFull(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// If demoting from admin, check if this would remove the last admin
|
||||
if (targetUser.isAdmin && !newIsAdmin) {
|
||||
const adminCount = await db
|
||||
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, 1));
|
||||
// Security rules for super-admin role changes
|
||||
const targetWillBeSuperAdmin = newRole === 'super-admin';
|
||||
const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
|
||||
|
||||
if (adminCount[0].count === 1) {
|
||||
throw new Error('Cannot demote the last admin user');
|
||||
// Only super-admins can promote/demote super-admins
|
||||
if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) {
|
||||
if (!requesterIsSuperAdmin) {
|
||||
throw new Error('Only super-admins can promote or demote super-admins');
|
||||
}
|
||||
}
|
||||
|
||||
// Update admin status
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
isAdmin: newIsAdmin ? 1 : 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, targetUserId));
|
||||
// Prevent self-demotion (super-admins cannot demote themselves)
|
||||
if (adminId === targetUserId) {
|
||||
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
|
||||
throw new Error('Cannot demote yourself from super-admin');
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot demote the last super-admin
|
||||
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
|
||||
const superAdminCount = await db
|
||||
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||
.from(users)
|
||||
.where(eq(users.isSuperAdmin, 1));
|
||||
|
||||
if (superAdminCount[0].count === 1) {
|
||||
throw new Error('Cannot demote the last super-admin');
|
||||
}
|
||||
}
|
||||
|
||||
// Update role (use the auth service function)
|
||||
await updateUserRole(targetUserId, newRole);
|
||||
|
||||
// Log action
|
||||
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||
oldIsAdmin: targetUser.isAdmin,
|
||||
newIsAdmin: newIsAdmin,
|
||||
oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
|
||||
newRole: newRole,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,21 @@ export async function isAdmin(userId) {
|
||||
return user?.isAdmin === true || user?.isAdmin === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is super-admin
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<boolean>} True if user is super-admin
|
||||
*/
|
||||
export async function isSuperAdmin(userId) {
|
||||
const [user] = await db
|
||||
.select({ isSuperAdmin: users.isSuperAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return user?.isSuperAdmin === true || user?.isSuperAdmin === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin users
|
||||
* @returns {Promise<Array>} Array of admin users (without passwords)
|
||||
@@ -178,16 +193,20 @@ export async function getAdminUsers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user admin status
|
||||
* Update user role
|
||||
* @param {number} userId - User ID
|
||||
* @param {boolean} isAdmin - Admin flag
|
||||
* @param {string} role - Role: 'user', 'admin', or 'super-admin'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateUserRole(userId, isAdmin) {
|
||||
export async function updateUserRole(userId, role) {
|
||||
const isAdmin = role === 'admin' || role === 'super-admin';
|
||||
const isSuperAdmin = role === 'super-admin';
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
isAdmin: isAdmin ? 1 : 0,
|
||||
isSuperAdmin: isSuperAdmin ? 1 : 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
@@ -204,6 +223,7 @@ export async function getAllUsers() {
|
||||
email: users.email,
|
||||
callsign: users.callsign,
|
||||
isAdmin: users.isAdmin,
|
||||
isSuperAdmin: users.isSuperAdmin,
|
||||
lastSeen: users.lastSeen,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
@@ -226,6 +246,7 @@ export async function getUserByIdFull(userId) {
|
||||
email: users.email,
|
||||
callsign: users.callsign,
|
||||
isAdmin: users.isAdmin,
|
||||
isSuperAdmin: users.isSuperAdmin,
|
||||
lotwUsername: users.lotwUsername,
|
||||
dclApiKey: users.dclApiKey,
|
||||
createdAt: users.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user