Files
hope-events/backend/src/utils/impersonation.js
T
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00

81 lines
3.7 KiB
JavaScript

/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };