Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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

Expand Down
45 changes: 44 additions & 1 deletion apps/webapp/app/models/admin.server.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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, {
Expand All @@ -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, {
Expand Down
8 changes: 1 addition & 7 deletions apps/webapp/app/routes/login.magic/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions apps/webapp/app/utils/extractClientIp.server.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions internal-packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ model User {
deployments WorkerDeployment[]
backupCodes MfaBackupCode[]
bulkActions BulkActionGroup[]

impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
}

Expand Down Expand Up @@ -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
Expand Down