diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ade530500..0162350ffc 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 diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 434bcf8ffe..35e68f8999 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -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, { 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 +} 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; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 44c5409970..af5b9bed88 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("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