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 | |
| 12 | import { randomBytes } from 'node:crypto'; |
| 13 | |
| 14 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 15 | |
| 16 | const IMPERSONATION_EXPIRY_MINUTES = 30; |
| 17 | |
| 18 | export 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 | |
| 54 | export 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 | |
| 95 | export 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 | } |