From 8c789ce7654daea643614fc4f68f83600c69bdc6 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 24 Jun 2026 12:53:05 -0600 Subject: [PATCH] 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 --- backend/middleware/auth.js | 36 +++- .../migrations/add_session_impersonation.js | 26 +++ backend/migrations/run-all.js | 1 + backend/routes/auth.js | 165 +++++++++++++++++- frontend/src/App.js | 2 + .../src/components/ImpersonationBanner.js | 69 ++++++++ frontend/src/components/UserManagement.js | 18 +- frontend/src/contexts/AuthContext.js | 51 ++++++ 8 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/add_session_impersonation.js create mode 100644 frontend/src/components/ImpersonationBanner.js diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index d788365..1bc1dcc 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -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); diff --git a/backend/migrations/add_session_impersonation.js b/backend/migrations/add_session_impersonation.js new file mode 100644 index 0000000..fbd5716 --- /dev/null +++ b/backend/migrations/add_session_impersonation.js @@ -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; diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 2d8cdf5..7a6efe9 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -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() { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e243150..be75d17 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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 * diff --git a/frontend/src/App.js b/frontend/src/App.js index 95a6e99..554302a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -8,6 +8,7 @@ import AuditLog from './components/AuditLog'; import NvdSyncModal from './components/NvdSyncModal'; import NavDrawer from './components/NavDrawer'; import AdminScopeToggle from './components/AdminScopeToggle'; +import ImpersonationBanner from './components/ImpersonationBanner'; import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; @@ -75,6 +76,7 @@ export default function App() { return (
+ setNavOpen(false)} diff --git a/frontend/src/components/ImpersonationBanner.js b/frontend/src/components/ImpersonationBanner.js new file mode 100644 index 0000000..1c8c8fd --- /dev/null +++ b/frontend/src/components/ImpersonationBanner.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { Eye, X } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; + +/** + * ImpersonationBanner — renders a fixed banner at the top of the viewport + * when an Admin is viewing the app as another user. Shows who is being + * impersonated and provides a button to exit. + */ +export default function ImpersonationBanner() { + const { impersonating, user, realUser, stopImpersonation } = useAuth(); + + if (!impersonating) return null; + + const handleStop = async () => { + await stopImpersonation(); + // Force page reload to reset all state to admin's view + window.location.reload(); + }; + + return ( +
+ + + Viewing as: {user?.username} ({user?.group}, teams: {user?.teams?.join(', ') || 'none'}) + {realUser && — logged in as {realUser.username}} + + +
+ ); +} diff --git a/frontend/src/components/UserManagement.js b/frontend/src/components/UserManagement.js index c5d11a6..95680d5 100644 --- a/frontend/src/components/UserManagement.js +++ b/frontend/src/components/UserManagement.js @@ -9,7 +9,7 @@ // - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.) // - The ConfirmModal integration for delete/group-change confirmations import React, { useState, useEffect } from 'react'; -import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react'; +import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import ConfirmModal from './ConfirmModal'; @@ -170,7 +170,7 @@ const styles = { export default function UserManagement({ onClose }) { - const { user: currentUser } = useAuth(); + const { user: currentUser, startImpersonation } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -665,6 +665,20 @@ export default function UserManagement({ onClose }) {
+ {currentUser.group === 'Admin' && user.id !== currentUser.id && user.group !== 'Admin' && ( + + )}