Skip to content

Commit ce083de

Browse files
committed
feat: add comprehensive admin impersonation system
Database enhancements: - Add impersonation fields to user_sessions table (impersonated_by, timestamps) - Automatic schema migration for existing databases Impersonation core functionality: - start_impersonation() and end_impersonation() functions in candidates.py - Admin-only access with comprehensive validation - Secure session token generation and management - Full audit trail of impersonation start/end events API endpoints: - POST /api/admin/impersonate/<user_id> - Start impersonation - POST /api/admin/end-impersonation - End impersonation session - Proper error handling and admin authentication required Session management: - Updated require_candidate_auth decorator to handle impersonation - Seamless switching between normal and impersonated sessions - Proper session cleanup when ending impersonation UI enhancements: - Impersonate button in admin candidates list - Prominent impersonation banner when active - End Impersonation button always visible during impersonation - Confirmation dialogs for all impersonation actions Activity logging: - All impersonated activity logged with admin context - Appears in candidate's activity log with impersonation indicators - Comprehensive audit trail for compliance and debugging Security features: - Admin-only access (requires session.admin_user) - Impersonation sessions tracked separately from regular sessions - Cannot impersonate other admins - All actions logged with admin identity and target user This allows admins to safely test the candidate experience while maintaining full audit trails and security boundaries.
1 parent 124ee1f commit ce083de

File tree

5 files changed

+325
-5
lines changed

5 files changed

+325
-5
lines changed

app.py

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
from models.candidates import (
3434
create_candidate_invitation, validate_invitation_token, authenticate_candidate,
3535
log_candidate_activity, get_all_candidate_invitations, get_candidate_activity_log,
36-
deactivate_invitation, get_candidate_summary
36+
deactivate_invitation, get_candidate_summary, start_impersonation,
37+
end_impersonation, get_impersonation_info
3738
)
3839

3940

@@ -59,9 +60,38 @@ def decorated_function(*args, **kwargs):
5960

6061

6162
def require_candidate_auth(f):
62-
"""Decorator to require candidate authentication via unique URL"""
63+
"""Decorator to require candidate authentication via unique URL or admin impersonation"""
6364
def decorated_function(*args, **kwargs):
64-
# Check for candidate session
65+
# Check for admin impersonation session first
66+
if 'impersonation_token' in session and 'impersonated_user_id' in session:
67+
# Validate impersonation session is still active
68+
impersonation_info = get_impersonation_info(session['impersonation_token'])
69+
if impersonation_info and impersonation_info['user_id'] == session['impersonated_user_id']:
70+
# Set up candidate session variables for impersonation
71+
session['candidate_user_id'] = session['impersonated_user_id']
72+
session['candidate_name'] = impersonation_info['target_username']
73+
session['candidate_email'] = impersonation_info['target_email']
74+
session['username'] = impersonation_info['target_username']
75+
session['user_id'] = session['impersonated_user_id']
76+
77+
# Log impersonated activity
78+
log_candidate_activity(
79+
user_id=session['impersonated_user_id'],
80+
activity_type='impersonated_page_access',
81+
details=f"Admin {impersonation_info['admin_username']} accessed {request.endpoint} while impersonating user",
82+
page_url=request.url,
83+
ip_address=request.remote_addr,
84+
user_agent=request.headers.get('User-Agent')
85+
)
86+
87+
return f(*args, **kwargs)
88+
else:
89+
# Invalid impersonation session, clear it
90+
session.pop('impersonation_token', None)
91+
session.pop('impersonated_user_id', None)
92+
session.pop('impersonated_user', None)
93+
94+
# Check for regular candidate session
6595
if 'candidate_session_token' not in session or 'candidate_user_id' not in session:
6696
return redirect(url_for('candidate_login_page'))
6797

@@ -915,6 +945,80 @@ def api_admin_modify_column(table_name, column_name):
915945
return jsonify(result), 400
916946

917947

948+
# Admin impersonation routes
949+
@app.route('/api/admin/impersonate/<int:user_id>', methods=['POST'])
950+
@require_admin
951+
def api_admin_start_impersonation(user_id):
952+
"""Start impersonating a candidate user"""
953+
admin_user = session.get('admin_user', {})
954+
admin_user_id = admin_user.get('id')
955+
956+
if not admin_user_id:
957+
return jsonify({'success': False, 'error': 'Admin user not found in session'}), 401
958+
959+
log_admin_action(admin_user_id, 'start_impersonation', f'Attempting to impersonate user ID: {user_id}')
960+
961+
result = start_impersonation(admin_user_id, user_id)
962+
if result['success']:
963+
# Set impersonation session
964+
session['impersonation_token'] = result['impersonation_token']
965+
session['impersonated_user_id'] = user_id
966+
session['impersonated_user'] = result['target_user']
967+
968+
log_admin_action(admin_user_id, 'start_impersonation_success',
969+
f'Successfully started impersonating {result["target_user"]["username"]} (ID: {user_id})')
970+
971+
return jsonify({
972+
'success': True,
973+
'redirect_url': url_for('index'),
974+
'target_user': result['target_user']
975+
})
976+
else:
977+
log_admin_action(admin_user_id, 'start_impersonation_failed',
978+
f'Failed to impersonate user ID {user_id}: {result["error"]}')
979+
return jsonify(result), 400
980+
981+
982+
@app.route('/api/admin/end-impersonation', methods=['POST'])
983+
@require_admin
984+
def api_admin_end_impersonation():
985+
"""End current impersonation session"""
986+
admin_user = session.get('admin_user', {})
987+
admin_user_id = admin_user.get('id')
988+
impersonation_token = session.get('impersonation_token')
989+
990+
if not impersonation_token:
991+
return jsonify({'success': False, 'error': 'No active impersonation session found'}), 400
992+
993+
log_admin_action(admin_user_id, 'end_impersonation', 'Attempting to end impersonation session')
994+
995+
result = end_impersonation(impersonation_token)
996+
if result['success']:
997+
# Clear impersonation session data
998+
session.pop('impersonation_token', None)
999+
session.pop('impersonated_user_id', None)
1000+
session.pop('impersonated_user', None)
1001+
1002+
# Clear candidate session data that was set during impersonation
1003+
session.pop('candidate_session_token', None)
1004+
session.pop('candidate_user_id', None)
1005+
session.pop('candidate_name', None)
1006+
session.pop('candidate_email', None)
1007+
session.pop('invitation_token', None)
1008+
session.pop('username', None)
1009+
session.pop('user_id', None)
1010+
1011+
log_admin_action(admin_user_id, 'end_impersonation_success',
1012+
f'Successfully ended impersonation of {result["target_user"]["username"]}')
1013+
1014+
return jsonify({
1015+
'success': True,
1016+
'redirect_url': url_for('admin_dashboard')
1017+
})
1018+
else:
1019+
return jsonify(result), 400
1020+
1021+
9181022
# Error handlers
9191023
@app.errorhandler(404)
9201024
def not_found(error):

models/candidates.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,5 +303,129 @@ def get_candidate_summary(user_id):
303303
except Exception as e:
304304
print(f"Error getting candidate summary: {e}")
305305
return None
306+
finally:
307+
conn.close()
308+
309+
310+
def start_impersonation(admin_user_id, target_user_id):
311+
"""Start impersonating a candidate user (admin only)"""
312+
conn = get_user_db_connection()
313+
try:
314+
# Get target user info
315+
target_user = conn.execute('SELECT * FROM users WHERE id = ?', (target_user_id,)).fetchone()
316+
if not target_user:
317+
return {'success': False, 'error': 'Target user not found'}
318+
319+
# Get admin user info
320+
admin_user = conn.execute('SELECT * FROM users WHERE id = ? AND is_admin = 1', (admin_user_id,)).fetchone()
321+
if not admin_user:
322+
return {'success': False, 'error': 'Only admins can impersonate users'}
323+
324+
# Generate impersonation session token
325+
impersonation_token = generate_invitation_token()
326+
327+
# Create impersonation session record
328+
conn.execute('''
329+
INSERT INTO user_sessions
330+
(user_id, session_token, ip_address, user_agent, is_admin, is_active,
331+
impersonated_by, impersonation_start_time)
332+
VALUES (?, ?, ?, ?, 0, 1, ?, CURRENT_TIMESTAMP)
333+
''', (target_user_id, impersonation_token, request.remote_addr,
334+
request.headers.get('User-Agent'), admin_user_id))
335+
336+
# Log impersonation start
337+
log_candidate_activity(
338+
user_id=target_user_id,
339+
activity_type='impersonation_started',
340+
details=f"Admin {admin_user['username']} ({admin_user['email']}) started impersonating user",
341+
ip_address=request.remote_addr,
342+
user_agent=request.headers.get('User-Agent'),
343+
page_url=request.url
344+
)
345+
346+
conn.commit()
347+
348+
return {
349+
'success': True,
350+
'impersonation_token': impersonation_token,
351+
'target_user': dict(target_user),
352+
'admin_user': dict(admin_user)
353+
}
354+
355+
except Exception as e:
356+
conn.rollback()
357+
return {'success': False, 'error': str(e)}
358+
finally:
359+
conn.close()
360+
361+
362+
def end_impersonation(session_token):
363+
"""End impersonation session"""
364+
conn = get_user_db_connection()
365+
try:
366+
# Get impersonation session
367+
session = conn.execute('''
368+
SELECT * FROM user_sessions
369+
WHERE session_token = ? AND impersonated_by IS NOT NULL
370+
''', (session_token,)).fetchone()
371+
372+
if not session:
373+
return {'success': False, 'error': 'No active impersonation session found'}
374+
375+
# Get admin and target user info
376+
admin_user = conn.execute('SELECT * FROM users WHERE id = ?', (session['impersonated_by'],)).fetchone()
377+
target_user = conn.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],)).fetchone()
378+
379+
# Log impersonation end
380+
log_candidate_activity(
381+
user_id=session['user_id'],
382+
activity_type='impersonation_ended',
383+
details=f"Admin {admin_user['username']} ({admin_user['email']}) ended impersonation session",
384+
ip_address=request.remote_addr,
385+
user_agent=request.headers.get('User-Agent')
386+
)
387+
388+
# Deactivate impersonation session
389+
conn.execute('''
390+
UPDATE user_sessions
391+
SET is_active = 0, impersonation_end_time = CURRENT_TIMESTAMP
392+
WHERE session_token = ?
393+
''', (session_token,))
394+
395+
conn.commit()
396+
397+
return {
398+
'success': True,
399+
'admin_user': dict(admin_user),
400+
'target_user': dict(target_user)
401+
}
402+
403+
except Exception as e:
404+
conn.rollback()
405+
return {'success': False, 'error': str(e)}
406+
finally:
407+
conn.close()
408+
409+
410+
def get_impersonation_info(session_token):
411+
"""Get information about current impersonation session"""
412+
conn = get_user_db_connection()
413+
try:
414+
session = conn.execute('''
415+
SELECT us.*, u_admin.username as admin_username, u_admin.email as admin_email,
416+
u_target.username as target_username, u_target.email as target_email
417+
FROM user_sessions us
418+
LEFT JOIN users u_admin ON us.impersonated_by = u_admin.id
419+
LEFT JOIN users u_target ON us.user_id = u_target.id
420+
WHERE us.session_token = ? AND us.impersonated_by IS NOT NULL AND us.is_active = 1
421+
''', (session_token,)).fetchone()
422+
423+
if session:
424+
return dict(session)
425+
return None
426+
427+
except Exception as e:
428+
print(f"Error getting impersonation info: {e}")
429+
return None
306430
finally:
307431
conn.close()

models/database.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@ def create_user_tables(conn):
196196
user_agent TEXT,
197197
is_admin BOOLEAN DEFAULT 0,
198198
is_active BOOLEAN DEFAULT 1,
199-
FOREIGN KEY (user_id) REFERENCES users (id)
199+
impersonated_by INTEGER,
200+
impersonation_start_time TIMESTAMP,
201+
impersonation_end_time TIMESTAMP,
202+
FOREIGN KEY (user_id) REFERENCES users (id),
203+
FOREIGN KEY (impersonated_by) REFERENCES users (id)
200204
)
201205
''')
202206

@@ -393,7 +397,7 @@ def verify_user_database_schema(conn):
393397
print("Recreated user_sessions table with correct schema")
394398
else:
395399
# Check for missing columns in existing table
396-
required_sessions_columns = ['id', 'user_id', 'session_token', 'login_time', 'last_activity', 'ip_address', 'user_agent', 'is_admin', 'is_active']
400+
required_sessions_columns = ['id', 'user_id', 'session_token', 'login_time', 'last_activity', 'ip_address', 'user_agent', 'is_admin', 'is_active', 'impersonated_by', 'impersonation_start_time', 'impersonation_end_time']
397401
missing_columns = [col for col in required_sessions_columns if col not in sessions_columns]
398402

399403
if missing_columns:
@@ -414,6 +418,12 @@ def verify_user_database_schema(conn):
414418
conn.execute("ALTER TABLE user_sessions ADD COLUMN is_active BOOLEAN DEFAULT 1")
415419
elif column == 'is_admin':
416420
conn.execute("ALTER TABLE user_sessions ADD COLUMN is_admin BOOLEAN DEFAULT 0")
421+
elif column == 'impersonated_by':
422+
conn.execute("ALTER TABLE user_sessions ADD COLUMN impersonated_by INTEGER")
423+
elif column == 'impersonation_start_time':
424+
conn.execute("ALTER TABLE user_sessions ADD COLUMN impersonation_start_time TIMESTAMP")
425+
elif column == 'impersonation_end_time':
426+
conn.execute("ALTER TABLE user_sessions ADD COLUMN impersonation_end_time TIMESTAMP")
417427
print(f"Added missing column: {column}")
418428
else:
419429
print("User_sessions table schema is complete")

templates/admin/candidates.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ <h5 class="mb-0">
256256
class="btn btn-outline-primary btn-sm">
257257
<i class="fas fa-eye me-1"></i>View Details
258258
</a>
259+
<button class="btn btn-outline-warning btn-sm"
260+
onclick="impersonateCandidate(${candidate.id}, '${candidate.username}')"
261+
title="Impersonate this candidate">
262+
<i class="fas fa-user-secret me-1"></i>Impersonate
263+
</button>
259264
<button class="btn btn-outline-success btn-sm"
260265
onclick="exportCandidate('${candidate.username}')">
261266
<i class="fas fa-download me-1"></i>Export
@@ -292,5 +297,32 @@ <h5 class="mb-0">
292297
alert('Error exporting candidate data. Please try again.');
293298
});
294299
}
300+
301+
function impersonateCandidate(userId, username) {
302+
if (!confirm(`Are you sure you want to impersonate candidate "${username}"?\n\nThis will:\n• Log you in as the candidate\n• Track all activity as impersonation\n• Allow you to see their exact experience\n\nClick OK to start impersonation.`)) {
303+
return;
304+
}
305+
306+
fetch(`/api/admin/impersonate/${userId}`, {
307+
method: 'POST',
308+
headers: {
309+
'Content-Type': 'application/json',
310+
}
311+
})
312+
.then(response => response.json())
313+
.then(data => {
314+
if (data.success) {
315+
// Show success message and redirect
316+
alert(`Successfully started impersonating "${username}".\n\nYou will now see the candidate experience. Use the "End Impersonation" button to return to admin mode.`);
317+
window.location.href = data.redirect_url;
318+
} else {
319+
alert(`Failed to start impersonation: ${data.error}`);
320+
}
321+
})
322+
.catch(error => {
323+
console.error('Error starting impersonation:', error);
324+
alert('Error starting impersonation. Please try again.');
325+
});
326+
}
295327
</script>
296328
{% endblock %}

0 commit comments

Comments
 (0)