Skip to content

Commit 733894b

Browse files
D-K-Pmatt-aitken
andauthored
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>
1 parent b696bbb commit 733894b

File tree

6 files changed

+117
-10
lines changed

6 files changed

+117
-10
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ We use the `<root>/references/hello-world` subdirectory as a staging ground for
9292
9393
First, make sure you are running the webapp according to the instructions above. Then:
9494
95-
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
95+
1. Visit http://localhost:3030 in your browser and create a new project called "hello-world".
9696
9797
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
9898
@@ -127,7 +127,7 @@ pnpm exec trigger deploy --profile local
127127

128128
### Running
129129

130-
The following steps should be followed any time you start working on a new feature you want to test in v3:
130+
The following steps should be followed any time you start working on a new feature you want to test:
131131

132132
1. Make sure the webapp is running on localhost:3030
133133

apps/webapp/app/models/admin.server.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import { redirect } from "@remix-run/server-runtime";
22
import { prisma } from "~/db.server";
3+
import { logger } from "~/services/logger.server";
34
import { SearchParams } from "~/routes/admin._index";
45
import {
56
clearImpersonationId,
67
commitImpersonationSession,
8+
getImpersonationId,
79
setImpersonationId,
810
} from "~/services/impersonation.server";
911
import { requireUser } from "~/services/session.server";
12+
import { extractClientIp } from "~/utils/extractClientIp.server";
1013

1114
const pageSize = 20;
1215

13-
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
16+
export async function adminGetUsers(
17+
userId: string,
18+
{ page, search }: SearchParams,
19+
) {
1420
page = page || 1;
1521

1622
search = search ? decodeURIComponent(search) : undefined;
@@ -212,6 +218,22 @@ export async function redirectWithImpersonation(request: Request, userId: string
212218
throw new Error("Unauthorized");
213219
}
214220

221+
const xff = request.headers.get("x-forwarded-for");
222+
const ipAddress = extractClientIp(xff);
223+
224+
try {
225+
await prisma.impersonationAuditLog.create({
226+
data: {
227+
action: "START",
228+
adminId: user.id,
229+
targetId: userId,
230+
ipAddress,
231+
},
232+
});
233+
} catch (error) {
234+
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId: userId });
235+
}
236+
215237
const session = await setImpersonationId(userId, request);
216238

217239
return redirect(path, {
@@ -220,6 +242,27 @@ export async function redirectWithImpersonation(request: Request, userId: string
220242
}
221243

222244
export async function clearImpersonation(request: Request, path: string) {
245+
const user = await requireUser(request);
246+
const targetId = await getImpersonationId(request);
247+
248+
if (targetId) {
249+
const xff = request.headers.get("x-forwarded-for");
250+
const ipAddress = extractClientIp(xff);
251+
252+
try {
253+
await prisma.impersonationAuditLog.create({
254+
data: {
255+
action: "STOP",
256+
adminId: user.id,
257+
targetId,
258+
ipAddress,
259+
},
260+
});
261+
} catch (error) {
262+
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId });
263+
}
264+
}
265+
223266
const session = await clearImpersonationId(request);
224267

225268
return redirect(path, {

apps/webapp/app/routes/login.magic/route.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "~/services/magicLinkRateLimiter.server";
3131
import { logger, tryCatch } from "@trigger.dev/core/v3";
3232
import { env } from "~/env.server";
33+
import { extractClientIp } from "~/utils/extractClientIp.server";
3334

3435
export const meta: MetaFunction = ({ matches }) => {
3536
const parentMeta = matches
@@ -169,13 +170,6 @@ export async function action({ request }: ActionFunctionArgs) {
169170
}
170171
}
171172

172-
const extractClientIp = (xff: string | null) => {
173-
if (!xff) return null;
174-
175-
const parts = xff.split(",").map((p) => p.trim());
176-
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
177-
};
178-
179173
export default function LoginMagicLinkPage() {
180174
const { magicLinkSent, magicLinkError } = useTypedLoaderData<typeof loader>();
181175
const navigate = useNavigation();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Extracts the client IP address from the X-Forwarded-For header.
3+
* Takes the last item in the header since ALB appends the real client IP by default.
4+
*/
5+
export function extractClientIp(xff: string | null): string | null {
6+
if (!xff) return null;
7+
8+
const parts = xff.split(",").map((p) => p.trim());
9+
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
10+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- CreateEnum
2+
CREATE TYPE "public"."ImpersonationAuditLogAction" AS ENUM ('START', 'STOP');
3+
4+
-- CreateTable
5+
CREATE TABLE "public"."ImpersonationAuditLog" (
6+
"id" TEXT NOT NULL,
7+
"action" "public"."ImpersonationAuditLogAction" NOT NULL,
8+
"adminId" TEXT NOT NULL,
9+
"targetId" TEXT NOT NULL,
10+
"ipAddress" TEXT,
11+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
13+
CONSTRAINT "ImpersonationAuditLog_pkey" PRIMARY KEY ("id")
14+
);
15+
16+
-- CreateIndex
17+
CREATE INDEX "ImpersonationAuditLog_adminId_idx" ON "public"."ImpersonationAuditLog"("adminId");
18+
19+
-- CreateIndex
20+
CREATE INDEX "ImpersonationAuditLog_targetId_idx" ON "public"."ImpersonationAuditLog"("targetId");
21+
22+
-- CreateIndex
23+
CREATE INDEX "ImpersonationAuditLog_createdAt_idx" ON "public"."ImpersonationAuditLog"("createdAt");
24+
25+
-- AddForeignKey
26+
ALTER TABLE "public"."ImpersonationAuditLog" ADD CONSTRAINT "ImpersonationAuditLog_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
27+
28+
-- AddForeignKey
29+
ALTER TABLE "public"."ImpersonationAuditLog" ADD CONSTRAINT "ImpersonationAuditLog_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

internal-packages/database/prisma/schema.prisma

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ model User {
5959
deployments WorkerDeployment[]
6060
backupCodes MfaBackupCode[]
6161
bulkActions BulkActionGroup[]
62+
63+
impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
64+
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
6265
customerQueries CustomerQuery[]
6366
}
6467

@@ -2396,6 +2399,34 @@ model ConnectedGithubRepository {
23962399
@@index([repositoryId])
23972400
}
23982401

2402+
enum ImpersonationAuditLogAction {
2403+
START
2404+
STOP
2405+
}
2406+
2407+
model ImpersonationAuditLog {
2408+
id String @id @default(cuid())
2409+
2410+
action ImpersonationAuditLogAction
2411+
2412+
/// The admin user who initiated/ended the impersonation
2413+
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade, onUpdate: Cascade)
2414+
adminId String
2415+
2416+
/// The user being impersonated
2417+
target User @relation("ImpersonationTarget", fields: [targetId], references: [id], onDelete: Cascade, onUpdate: Cascade)
2418+
targetId String
2419+
2420+
ipAddress String?
2421+
2422+
createdAt DateTime @default(now())
2423+
2424+
@@index([adminId])
2425+
@@index([targetId])
2426+
@@index([createdAt])
2427+
2428+
}
2429+
23992430
enum CustomerQuerySource {
24002431
DASHBOARD
24012432
API

0 commit comments

Comments
 (0)