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:
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user