From edaf1fb7825e2463f32469db7214bf4c10e2421f Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:02:56 +0000 Subject: [PATCH 1/7] Add ImpersonationAuditLog schema --- .../database/prisma/schema.prisma | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 28c8332966..1a1c3ec87f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -59,6 +59,9 @@ model User { deployments WorkerDeployment[] backupCodes MfaBackupCode[] bulkActions BulkActionGroup[] + + impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationActor") + impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") } model MfaBackupCode { @@ -2383,3 +2386,30 @@ model ConnectedGithubRepository { @@unique([projectId]) @@index([repositoryId]) } + +enum ImpersonationAuditLogAction { + START + STOP +} + +model ImpersonationAuditLog { + id String @id @default(cuid()) + + action ImpersonationAuditLogAction + + /// The admin user who initiated/ended the impersonation + actor User @relation("ImpersonationActor", fields: [actorId], references: [id], onDelete: Cascade, onUpdate: Cascade) + actorId 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([actorId]) + @@index([targetId]) + @@index([createdAt]) +} From 8d91211c52cc87b8572468b08abede6639d19746 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:15:59 +0000 Subject: [PATCH 2/7] Log impersonation start --- apps/webapp/app/models/admin.server.ts | 23 +++++++++++++++++++ .../database/prisma/schema.prisma | 8 +++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 434bcf8ffe..33ebc346b3 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -1,5 +1,6 @@ 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, @@ -8,6 +9,12 @@ import { } from "~/services/impersonation.server"; import { requireUser } from "~/services/session.server"; +function extractClientIp(xff: string | null): string | null { + if (!xff) return null; + const parts = xff.split(",").map((p) => p.trim()); + return parts[parts.length - 1]; // ALB appends the real client IP +} + const pageSize = 20; export async function adminGetUsers(userId: string, { page, search }: SearchParams) { @@ -212,6 +219,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, { diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1a1c3ec87f..e0c163f40d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -60,7 +60,7 @@ model User { backupCodes MfaBackupCode[] bulkActions BulkActionGroup[] - impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationActor") + impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin") impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") } @@ -2398,8 +2398,8 @@ model ImpersonationAuditLog { action ImpersonationAuditLogAction /// The admin user who initiated/ended the impersonation - actor User @relation("ImpersonationActor", fields: [actorId], references: [id], onDelete: Cascade, onUpdate: Cascade) - actorId String + 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) @@ -2409,7 +2409,7 @@ model ImpersonationAuditLog { createdAt DateTime @default(now()) - @@index([actorId]) + @@index([adminId]) @@index([targetId]) @@index([createdAt]) } From 3176935b859b3c457363eb0c60f98434bce418f4 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:28:14 +0000 Subject: [PATCH 3/7] Log impersonation stop --- apps/webapp/app/models/admin.server.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 33ebc346b3..61a1003d78 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -5,6 +5,7 @@ import { SearchParams } from "~/routes/admin._index"; import { clearImpersonationId, commitImpersonationSession, + getImpersonationId, setImpersonationId, } from "~/services/impersonation.server"; import { requireUser } from "~/services/session.server"; @@ -243,6 +244,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, { From eb2bdb57559887bbb72af81a9d19a372d7786bf6 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:54:01 +0000 Subject: [PATCH 4/7] Add impersonation audit log migration --- .../migration.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260108164613_add_impersonation_audit_log/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260108164613_add_impersonation_audit_log/migration.sql b/internal-packages/database/prisma/migrations/20260108164613_add_impersonation_audit_log/migration.sql new file mode 100644 index 0000000000..ca89a8d9d7 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260108164613_add_impersonation_audit_log/migration.sql @@ -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; From 175558c0694f7ff36afd2703126373256dbc9da6 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:20:49 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Remove=20=E2=80=98v3=E2=80=99=20from=20CONT?= =?UTF-8?q?RIBUTING.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5924f89da3..5070419e2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ We use the `/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 From d4a06cec07d3d7b7ef4287bd193ebaa3535dfc72 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:21:40 +0000 Subject: [PATCH 6/7] Extracted extractClientIp and used it login.magic and admin.server.ts --- apps/webapp/app/models/admin.server.ts | 12 +++++------- apps/webapp/app/routes/login.magic/route.tsx | 8 +------- apps/webapp/app/utils/extractClientIp.server.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 apps/webapp/app/utils/extractClientIp.server.ts diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 61a1003d78..35e68f8999 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -9,16 +9,14 @@ import { setImpersonationId, } from "~/services/impersonation.server"; import { requireUser } from "~/services/session.server"; - -function extractClientIp(xff: string | null): string | null { - if (!xff) return null; - const parts = xff.split(",").map((p) => p.trim()); - return parts[parts.length - 1]; // ALB appends the real client IP -} +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; diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 8c2015c5e6..16550647fe 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -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(); const navigate = useNavigation(); diff --git a/apps/webapp/app/utils/extractClientIp.server.ts b/apps/webapp/app/utils/extractClientIp.server.ts new file mode 100644 index 0000000000..bbfa518071 --- /dev/null +++ b/apps/webapp/app/utils/extractClientIp.server.ts @@ -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 +} From 3994a6730bbfbf7cfe0adec0f7ad0fac28b64912 Mon Sep 17 00:00:00 2001 From: Dan <8297864+D-K-P@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:33:50 +0000 Subject: [PATCH 7/7] Add index on createdAt field in schema.prisma --- internal-packages/database/prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ac5d752a52..ee876a0e25 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2418,6 +2418,7 @@ model ImpersonationAuditLog { @@index([adminId]) @@index([targetId]) + @@index([createdAt]) }