auth-impersonate.ts113 lines · main
1/**
2 * Admin impersonation — create a session for a target user in a project's
3 * tenant so an admin can act on their behalf. The session is real
4 * (signed cookie + DB row) and works with all normal tenant APIs.
5 *
6 * Phase 6.2 adds:
7 * - Tenant audit log entry on start
8 * - `_briven_auth_impersonation_sessions` tracking
9 * - Stop-impersonation support
10 */
11
12import { randomBytes } from 'node:crypto';
13
14import { runInProjectDatabase } from '../db/data-plane.js';
15
16const IMPERSONATION_EXPIRY_MINUTES = 30;
17
18export async function createImpersonationSession(
19 projectId: string,
20 targetUserId: string,
21 impersonatedBy: string,
22): Promise<{ sessionToken: string; expiresAt: Date }> {
23 const token = `imp_${randomBytes(32).toString('hex')}`;
24 const sessionId = `imp_sess_${randomBytes(8).toString('hex')}`;
25 const expiresAt = new Date(Date.now() + IMPERSONATION_EXPIRY_MINUTES * 60 * 1000);
26
27 await runInProjectDatabase(projectId, async (tx) => {
28 await tx.unsafe(
29 `INSERT INTO "_briven_auth_sessions" (id, user_id, token, expires_at, ip_address, user_agent, created_at, updated_at)
30 VALUES ($1, $2, $3, $4, NULL, 'briven-admin-impersonation', now(), now())`,
31 [sessionId, targetUserId, token, expiresAt] as never[],
32 );
33
34 await tx.unsafe(
35 `INSERT INTO "_briven_auth_impersonation_sessions" (id, session_id, impersonated_by, target_user_id, created_at, updated_at)
36 VALUES ($1, $2, $3, $4, now(), now())`,
37 [`imp_is_${randomBytes(8).toString('hex')}`, sessionId, impersonatedBy, targetUserId] as never[],
38 );
39
40 await tx.unsafe(
41 `INSERT INTO "_briven_auth_audit_log" (id, user_id, action, ip_address_hash, user_agent, metadata, occurred_at)
42 VALUES ($1, $2, 'impersonation.start', NULL, 'briven-admin-impersonation', $3, now())`,
43 [
44 `imp_audit_${randomBytes(8).toString('hex')}`,
45 targetUserId,
46 JSON.stringify({ impersonatedBy, targetUserId }),
47 ] as never[],
48 );
49 });
50
51 return { sessionToken: token, expiresAt };
52}
53
54export async function stopImpersonationSession(
55 projectId: string,
56 sessionToken: string,
57 stoppedBy: string,
58): Promise<void> {
59 await runInProjectDatabase(projectId, async (tx) => {
60 // Find the session
61 const sessionRows = await tx.unsafe(
62 `SELECT id, user_id FROM "_briven_auth_sessions" WHERE token = $1 LIMIT 1`,
63 [sessionToken] as never[],
64 ) as Array<{ id: string; user_id: string }>;
65
66 const session = sessionRows[0];
67 if (!session) return;
68
69 // Mark impersonation as stopped
70 await tx.unsafe(
71 `UPDATE "_briven_auth_impersonation_sessions" SET stopped_at = now(), updated_at = now()
72 WHERE session_id = $1 AND stopped_at IS NULL`,
73 [session.id] as never[],
74 );
75
76 // Revoke the session
77 await tx.unsafe(
78 `DELETE FROM "_briven_auth_sessions" WHERE id = $1`,
79 [session.id] as never[],
80 );
81
82 // Audit log
83 await tx.unsafe(
84 `INSERT INTO "_briven_auth_audit_log" (id, user_id, action, ip_address_hash, user_agent, metadata, occurred_at)
85 VALUES ($1, $2, 'impersonation.stop', NULL, 'briven-admin-impersonation', $3, now())`,
86 [
87 `imp_audit_${randomBytes(8).toString('hex')}`,
88 session.user_id,
89 JSON.stringify({ stoppedBy, targetUserId: session.user_id }),
90 ] as never[],
91 );
92 });
93}
94
95export async function getActiveImpersonation(
96 projectId: string,
97 sessionToken: string,
98): Promise<{ impersonatedBy: string; targetUserId: string } | null> {
99 const rows = await runInProjectDatabase(projectId, async (tx) => {
100 return (await tx.unsafe(
101 `SELECT i.impersonated_by, i.target_user_id
102 FROM "_briven_auth_impersonation_sessions" i
103 INNER JOIN "_briven_auth_sessions" s ON s.id = i.session_id
104 WHERE s.token = $1 AND i.stopped_at IS NULL AND s.expires_at > now()
105 LIMIT 1`,
106 [sessionToken] as never[],
107 )) as Array<{ impersonated_by: string; target_user_id: string }>;
108 });
109
110 const row = rows[0];
111 if (!row) return null;
112 return { impersonatedBy: row.impersonated_by, targetUserId: row.target_user_id };
113}