Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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: 45 additions & 0 deletions apps/webapp/app/models/admin.server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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";

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) {
Expand Down Expand Up @@ -212,6 +220,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 +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, {
Expand Down
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;
30 changes: 30 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 @@ -2391,6 +2394,33 @@ 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])

}

enum CustomerQuerySource {
DASHBOARD
API
Expand Down
Loading