Add View As (impersonation) feature for Admin users

Allow Admin users to temporarily view the app as another user to verify
permissions and team scoping without switching accounts.

Backend:
- Migration: add impersonate_user_id column to sessions table
- requireAuth(): when impersonation is active, override req.user with
  target user's identity; store real admin identity in req.realUser
- POST /api/auth/impersonate: start impersonation (Admin only, cannot
  impersonate self or other Admins)
- POST /api/auth/stop-impersonate: end impersonation, revert to real user
- GET /api/auth/me: returns impersonating flag and realUser when active
- Audit logging on impersonate start/stop

Frontend:
- AuthContext: add impersonating, realUser state; startImpersonation()
  and stopImpersonation() helpers
- ImpersonationBanner: fixed amber banner showing target user identity
  with Exit button
- UserManagement: Eye icon button on each non-Admin user row to start
  View As (visible only to Admin, hidden for self and other Admins)
- App.js: mount ImpersonationBanner at top of authenticated view
This commit is contained in:
Jordan Ramos
2026-06-24 12:53:05 -06:00
parent 11d9fec3ec
commit 8c789ce765
8 changed files with 360 additions and 8 deletions

View File

@@ -13,7 +13,8 @@ function requireAuth() {
try {
const { rows } = await pool.query(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
`SELECT s.*, s.impersonate_user_id,
u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
@@ -30,8 +31,8 @@ function requireAuth() {
return res.status(401).json({ error: 'Account is disabled' });
}
// Attach user to request
req.user = {
// Store the real admin identity (always the session owner)
req.realUser = {
id: session.user_id,
username: session.username,
email: session.email,
@@ -40,6 +41,35 @@ function requireAuth() {
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
};
// If impersonating, load the target user's identity
if (session.impersonate_user_id) {
const { rows: targetRows } = await pool.query(
`SELECT id, username, email, role, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[session.impersonate_user_id]
);
const target = targetRows[0];
if (target && target.is_active) {
req.user = {
id: target.id,
username: target.username,
email: target.email,
role: target.role,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
};
req.impersonating = true;
} else {
// Target user no longer valid — clear impersonation and use real user
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
req.user = req.realUser;
req.impersonating = false;
}
} else {
req.user = req.realUser;
req.impersonating = false;
}
next();
} catch (err) {
console.error('Auth middleware error:', err);

View File

@@ -0,0 +1,26 @@
// Migration: Add impersonate_user_id column to sessions table
// Allows Admin users to temporarily view the app as another user.
// When set, requireAuth() overrides req.user with the target user's identity.
const pool = require('../db');
async function run() {
console.log('[Migration] add_session_impersonation: starting...');
// Add impersonate_user_id column (nullable FK to users)
await pool.query(`
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
`);
console.log('[Migration] add_session_impersonation: column added.');
console.log('[Migration] add_session_impersonation: done.');
await pool.end();
}
// Run directly if invoked as a script
if (require.main === module) {
run().catch(err => { console.error(err); process.exit(1); });
}
module.exports = run;

View File

@@ -33,6 +33,7 @@ const POSTGRES_MIGRATIONS = [
'add_ivanti_findings_ipv6_columns.js',
'add_user_ivanti_identity.js',
'add_atlas_known_column.js',
'add_session_impersonation.js',
];
async function runAll() {

View File

@@ -195,9 +195,10 @@ function createAuthRouter(logAudit) {
* GET /api/auth/me
*
* Returns the currently authenticated user based on the session cookie.
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
* If impersonating, returns the impersonated user's identity with an
* `impersonating` flag and the real admin user's info.
*
* @returns {object} 200 - { user: { id, username, email, group } }
* @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } }
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
* @returns {object} 500 - { error: 'Failed to get user' }
*/
@@ -210,7 +211,8 @@ function createAuthRouter(logAudit) {
try {
const { rows } = await pool.query(
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
`SELECT s.*, s.impersonate_user_id,
u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
@@ -229,6 +231,36 @@ function createAuthRouter(logAudit) {
return res.status(401).json({ error: 'Account is disabled' });
}
// If impersonating, return target user's identity
if (session.impersonate_user_id) {
const { rows: targetRows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[session.impersonate_user_id]
);
const target = targetRows[0];
if (target && target.is_active) {
return res.json({
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
},
impersonating: true,
realUser: {
id: session.user_id,
username: session.username,
group: session.user_group
}
});
} else {
// Target invalid — clear impersonation
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
}
}
res.json({
user: {
id: session.user_id,
@@ -244,6 +276,133 @@ function createAuthRouter(logAudit) {
}
});
/**
* POST /api/auth/impersonate
*
* Start impersonating another user. Only Admin group can impersonate.
* Cannot impersonate another Admin user.
*
* @body {number} userId - The ID of the user to impersonate
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 400 - { error } — cannot impersonate Admin or self
* @returns {object} 403 - { error } — not Admin
* @returns {object} 404 - { error } — target user not found
* @returns {object} 500 - { error }
*/
router.post('/impersonate', requireAuth(), async (req, res) => {
// Only the real user (not an impersonated identity) can start impersonation
const realUser = req.realUser || req.user;
if (realUser.group !== 'Admin') {
return res.status(403).json({ error: 'Only Admin users can impersonate.' });
}
const { userId } = req.body;
if (!userId || typeof userId !== 'number') {
return res.status(400).json({ error: 'userId is required and must be a number.' });
}
if (userId === realUser.id) {
return res.status(400).json({ error: 'Cannot impersonate yourself.' });
}
try {
const { rows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[userId]
);
const target = rows[0];
if (!target) {
return res.status(404).json({ error: 'User not found.' });
}
if (!target.is_active) {
return res.status(400).json({ error: 'Cannot impersonate a disabled account.' });
}
if (target.user_group === 'Admin') {
return res.status(400).json({ error: 'Cannot impersonate another Admin user.' });
}
// Set impersonation on the session
const sessionId = req.cookies?.session_id;
await pool.query(
`UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`,
[userId, sessionId]
);
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_start',
entityType: 'user',
entityId: String(userId),
details: { target_username: target.username, target_group: target.user_group },
ipAddress: req.ip
});
res.json({
message: `Now viewing as ${target.username}`,
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
}
});
} catch (err) {
console.error('Impersonate error:', err);
res.status(500).json({ error: 'Failed to start impersonation.' });
}
});
/**
* POST /api/auth/stop-impersonate
*
* Stop impersonating and revert to the real Admin identity.
*
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 500 - { error }
*/
router.post('/stop-impersonate', requireAuth(), async (req, res) => {
const sessionId = req.cookies?.session_id;
try {
await pool.query(
`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`,
[sessionId]
);
const realUser = req.realUser || req.user;
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_stop',
entityType: 'user',
entityId: null,
details: null,
ipAddress: req.ip
});
res.json({
message: 'Impersonation ended',
user: {
id: realUser.id,
username: realUser.username,
email: realUser.email,
group: realUser.group,
teams: realUser.teams
}
});
} catch (err) {
console.error('Stop impersonate error:', err);
res.status(500).json({ error: 'Failed to stop impersonation.' });
}
});
/**
* GET /api/auth/profile
*