99 setImpersonationId ,
1010} from "~/services/impersonation.server" ;
1111import { authenticator } from "~/services/auth.server" ;
12- import { requireUser } from "~/services/session.server" ;
12+ import { getRealUser , requireUser } from "~/services/session.server" ;
1313import { extractClientIp } from "~/utils/extractClientIp.server" ;
1414import { impersonationDestinationPath } from "~/utils/pathBuilder" ;
1515
@@ -210,35 +210,62 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
210210 } ;
211211}
212212
213+ /**
214+ * Starts (or switches) impersonation.
215+ *
216+ * The admin gate resolves the *real* authenticated user itself. `requireUser` returns the
217+ * impersonation target while impersonating, so callers that gated on it refused an admin who was
218+ * already impersonating someone — they had to stop first — and would have attributed the audit row
219+ * to the target rather than the admin.
220+ *
221+ * `verifiedAdmin` exists only so tests can supply an admin without a session cookie. Production
222+ * callers must not pass it: passing a `requireUser` result is exactly the bug described above.
223+ */
213224export async function redirectWithImpersonation (
214225 request : Request ,
215226 userId : string ,
216227 path : string ,
217- currentUser ?: { id : string ; admin : boolean } ,
228+ verifiedAdmin ?: { id : string ; admin : boolean } ,
218229 prismaClient : PrismaClientOrTransaction = prisma
219230) {
220- const user = currentUser ?? ( await requireUser ( request ) ) ;
221- if ( ! user . admin ) {
231+ const admin = verifiedAdmin ?? ( await getRealUser ( request , prismaClient ) ) ;
232+ if ( ! admin ? .admin ) {
222233 throw new Error ( "Unauthorized" ) ;
223234 }
224235
225236 const xff = request . headers . get ( "x-forwarded-for" ) ;
226237 const ipAddress = extractClientIp ( xff ) ;
238+ const previousTargetId = await getImpersonationId ( request ) ;
227239
228240 try {
229- await prismaClient . impersonationAuditLog . create ( {
230- data : {
231- action : "START" ,
232- adminId : user . id ,
233- targetId : userId ,
234- ipAddress,
235- } ,
241+ await prismaClient . impersonationAuditLog . createMany ( {
242+ data : [
243+ // Switching straight from one target to another never passes through `clearImpersonation`,
244+ // so close the previous session here or the trail shows two overlapping STARTs.
245+ ...( previousTargetId && previousTargetId !== userId
246+ ? [
247+ {
248+ action : "STOP" as const ,
249+ adminId : admin . id ,
250+ targetId : previousTargetId ,
251+ ipAddress,
252+ } ,
253+ ]
254+ : [ ] ) ,
255+ {
256+ action : "START" as const ,
257+ adminId : admin . id ,
258+ targetId : userId ,
259+ ipAddress,
260+ } ,
261+ ] ,
236262 } ) ;
237263 } catch ( error ) {
238264 logger . error ( "Failed to create impersonation audit log" , {
239265 error,
240- adminId : user . id ,
266+ adminId : admin . id ,
241267 targetId : userId ,
268+ previousTargetId,
242269 } ) ;
243270 }
244271
@@ -308,7 +335,8 @@ export async function startImpersonation(
308335 request : Request ,
309336 organizationSlug : string ,
310337 path : string ,
311- currentUser : { id : string ; admin : boolean } ,
338+ // Test-only, forwarded to `redirectWithImpersonation` — see its docstring.
339+ verifiedAdmin ?: { id : string ; admin : boolean } ,
312340 clients : { read : PrismaClientOrTransaction ; write : PrismaClientOrTransaction } = {
313341 read : $replica ,
314342 write : prisma ,
@@ -325,7 +353,7 @@ export async function startImpersonation(
325353 request ,
326354 target . userId ,
327355 impersonationDestinationPath ( organizationSlug , path , new URL ( request . url ) . search ) ,
328- currentUser ,
356+ verifiedAdmin ,
329357 clients . write
330358 ) ;
331359}
0 commit comments