Impersonation log (#2896)

Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
This commit is contained in:
Dan
2026-01-15 16:54:45 +00:00
committed by GitHub
parent b696bbb1df
commit 733894bb4f
6 changed files with 117 additions and 10 deletions
+2 -2
View File
@@ -92,7 +92,7 @@ We use the `<root>/references/hello-world` subdirectory as a staging ground for
First, make sure you are running the webapp according to the instructions above. Then:
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
1. Visit http://localhost:3030 in your browser and create a new project called "hello-world".
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
@@ -127,7 +127,7 @@ pnpm exec trigger deploy --profile local
### Running
The following steps should be followed any time you start working on a new feature you want to test in v3:
The following steps should be followed any time you start working on a new feature you want to test:
1. Make sure the webapp is running on localhost:3030
+44 -1
View File
@@ -1,16 +1,22 @@
import { redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { SearchParams } from "~/routes/admin._index";
import {
clearImpersonationId,
commitImpersonationSession,
getImpersonationId,
setImpersonationId,
} from "~/services/impersonation.server";
import { requireUser } from "~/services/session.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
const pageSize = 20;
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
export async function adminGetUsers(
userId: string,
{ page, search }: SearchParams,
) {
page = page || 1;
search = search ? decodeURIComponent(search) : undefined;
@@ -212,6 +218,22 @@ export async function redirectWithImpersonation(request: Request, userId: string
throw new Error("Unauthorized");
}
const xff = request.headers.get("x-forwarded-for");
const ipAddress = extractClientIp(xff);
try {
await prisma.impersonationAuditLog.create({
data: {
action: "START",
adminId: user.id,
targetId: userId,
ipAddress,
},
});
} catch (error) {
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId: userId });
}
const session = await setImpersonationId(userId, request);
return redirect(path, {
@@ -220,6 +242,27 @@ export async function redirectWithImpersonation(request: Request, userId: string
}
export async function clearImpersonation(request: Request, path: string) {
const user = await requireUser(request);
const targetId = await getImpersonationId(request);
if (targetId) {
const xff = request.headers.get("x-forwarded-for");
const ipAddress = extractClientIp(xff);
try {
await prisma.impersonationAuditLog.create({
data: {
action: "STOP",
adminId: user.id,
targetId,
ipAddress,
},
});
} catch (error) {
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId });
}
}
const session = await clearImpersonationId(request);
return redirect(path, {
+1 -7
View File
@@ -30,6 +30,7 @@ import {
} from "~/services/magicLinkRateLimiter.server";
import { logger, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
@@ -169,13 +170,6 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
const extractClientIp = (xff: string | null) => {
if (!xff) return null;
const parts = xff.split(",").map((p) => p.trim());
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
};
export default function LoginMagicLinkPage() {
const { magicLinkSent, magicLinkError } = useTypedLoaderData<typeof loader>();
const navigate = useNavigation();
@@ -0,0 +1,10 @@
/**
* Extracts the client IP address from the X-Forwarded-For header.
* Takes the last item in the header since ALB appends the real client IP by default.
*/
export function extractClientIp(xff: string | null): string | null {
if (!xff) return null;
const parts = xff.split(",").map((p) => p.trim());
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
}
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "public"."ImpersonationAuditLogAction" AS ENUM ('START', 'STOP');
-- CreateTable
CREATE TABLE "public"."ImpersonationAuditLog" (
"id" TEXT NOT NULL,
"action" "public"."ImpersonationAuditLogAction" NOT NULL,
"adminId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationAuditLog_adminId_idx" ON "public"."ImpersonationAuditLog"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationAuditLog_targetId_idx" ON "public"."ImpersonationAuditLog"("targetId");
-- CreateIndex
CREATE INDEX "ImpersonationAuditLog_createdAt_idx" ON "public"."ImpersonationAuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "public"."ImpersonationAuditLog" ADD CONSTRAINT "ImpersonationAuditLog_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ImpersonationAuditLog" ADD CONSTRAINT "ImpersonationAuditLog_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -59,6 +59,9 @@ model User {
deployments WorkerDeployment[]
backupCodes MfaBackupCode[]
bulkActions BulkActionGroup[]
impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
}
@@ -2396,6 +2399,34 @@ model ConnectedGithubRepository {
@@index([repositoryId])
}
enum ImpersonationAuditLogAction {
START
STOP
}
model ImpersonationAuditLog {
id String @id @default(cuid())
action ImpersonationAuditLogAction
/// The admin user who initiated/ended the impersonation
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade, onUpdate: Cascade)
adminId String
/// The user being impersonated
target User @relation("ImpersonationTarget", fields: [targetId], references: [id], onDelete: Cascade, onUpdate: Cascade)
targetId String
ipAddress String?
createdAt DateTime @default(now())
@@index([adminId])
@@index([targetId])
@@index([createdAt])
}
enum CustomerQuerySource {
DASHBOARD
API