← back to Norma

app/api/admin/impersonate/route.ts

125 lines

/**
 * /api/admin/impersonate
 *
 *   GET  — current impersonation state (so the banner can render on load)
 *   POST { username } — start impersonating that user (admin-only)
 *   DELETE — exit impersonation, restore the real admin's session
 *
 * Mechanism:
 *   - Admin's REAL session lives in cookie `norma-auth` (same as always)
 *   - When admin impersonates, we KEEP the real cookie in `norma-imp-by` and
 *     overwrite `norma-auth` with a fresh session token for the target user.
 *   - On exit, we move `norma-imp-by` back into `norma-auth` and clear
 *     `norma-imp-by`.
 *   - Hard rule: only an `admin`-role session can START impersonation.
 *     Anyone in possession of the imp-by cookie can EXIT (so an exit
 *     mid-impersonation always works even if the cookie order is weird).
 */

import { NextRequest, NextResponse } from 'next/server';
import {
  createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth,
} from '@/lib/auth';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';

const IMP_BY_COOKIE = 'norma-imp-by';
const MAX_AGE = 60 * 60 * 24; // 24h, same as norma-auth

function parseCookies(request: NextRequest): Record<string, string> {
  const header = request.headers.get('cookie') || '';
  return Object.fromEntries(
    header.split(';').map((c) => {
      const [k, ...rest] = c.trim().split('=');
      return [k, rest.join('=')];
    }),
  );
}

function buildImpByCookie(token: string): string {
  return `${IMP_BY_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MAX_AGE}`;
}
function clearImpByCookie(): string {
  return `${IMP_BY_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}

export async function GET(request: NextRequest) {
  const session = verifyAuth(request);
  if (!session) return NextResponse.json({ authenticated: false });

  const cookies = parseCookies(request);
  const impByToken = cookies[IMP_BY_COOKIE];

  return NextResponse.json({
    currentUser:        session.username,
    currentRole:        session.role,
    impersonating:      !!impByToken,
  });
}

export async function POST(request: NextRequest) {
  // Only the REAL admin can start impersonation.
  // (If they're already impersonating, the current cookie says role=admin only
  // if their target was also admin — which is fine; we still check.)
  const adminSession = requireRole(request, 'admin');
  if (adminSession instanceof NextResponse) return adminSession;

  try {
    const body = await request.json();
    const targetUsername = String(body.username || '').trim();
    if (!targetUsername) {
      return NextResponse.json({ error: 'username is required' }, { status: 400 });
    }
    if (targetUsername === adminSession.username) {
      return NextResponse.json({ error: 'Already that user' }, { status: 400 });
    }

    const result = await query<{ username: string; role: string; org_id: string | null }>(
      'SELECT username, role, org_id FROM tier_credentials WHERE username = $1',
      [targetUsername],
    );
    if (result.rows.length === 0) {
      return NextResponse.json({ error: 'User not found' }, { status: 404 });
    }
    const target = result.rows[0];

    // Preserve admin's existing session in the imp-by cookie
    const cookies = parseCookies(request);
    const adminToken = cookies[AUTH_COOKIE_NAME];
    if (!adminToken) {
      return NextResponse.json({ error: 'No active session to preserve' }, { status: 400 });
    }

    // Mint a fresh session for the target
    const targetToken = createSession(target.username, target.role, target.org_id);

    const response = NextResponse.json({
      success:        true,
      impersonating:  target.username,
      role:           target.role,
      orgId:          target.org_id,
    });
    response.headers.append('Set-Cookie', buildAuthCookie(targetToken));
    response.headers.append('Set-Cookie', buildImpByCookie(adminToken));
    return response;
  } catch (err) {
    console.error('[admin/impersonate] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to impersonate' }, { status: 500 });
  }
}

export async function DELETE(request: NextRequest) {
  // No role check — anyone holding the imp-by cookie should be allowed to
  // exit cleanly. (Without imp-by we have nothing to restore anyway.)
  const cookies = parseCookies(request);
  const restoreToken = cookies[IMP_BY_COOKIE];
  if (!restoreToken) {
    return NextResponse.json({ error: 'Not currently impersonating' }, { status: 400 });
  }

  const response = NextResponse.json({ success: true, restored: true });
  response.headers.append('Set-Cookie', buildAuthCookie(restoreToken));
  response.headers.append('Set-Cookie', clearImpByCookie());
  return response;
}