From 279885903858df3f14a785c61561701f95db8348 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:29:45 +0200 Subject: [PATCH 01/10] feat(webapp): IMPERSONATION_ENABLED flag gates impersonation resolution and start --- apps/webapp/app/env.server.ts | 4 ++++ apps/webapp/app/models/admin.server.ts | 15 +++++++++++++++ apps/webapp/app/services/impersonation.server.ts | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index c63c79d41d6..43b0a8e66e4 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -332,6 +332,10 @@ const EnvironmentSchema = z .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") .optional(), ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), + // When disabled, user impersonation is fully off for this instance: + // existing impersonation cookies are ignored, the start endpoints 404, + // and the impersonation UI isn't rendered. + IMPERSONATION_ENABLED: BoolEnv.default(true), REMIX_APP_PORT: z.string().optional(), // Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port. // Read directly from process.env in server.ts (before this schema loads); declared here for discoverability. diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index e93844dbaed..40cf247dcad 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -12,9 +12,22 @@ import { authenticator } from "~/services/auth.server"; import { requireUser } from "~/services/session.server"; import { extractClientIp } from "~/utils/extractClientIp.server"; import { impersonationDestinationPath } from "~/utils/pathBuilder"; +import { env } from "~/env.server"; const pageSize = 20; +/** + * Guard for everything that starts an impersonation (the model function and + * the routes that render or serve the flow). With IMPERSONATION_ENABLED off, + * those surfaces don't exist: 404, not 403, so the instance doesn't advertise + * the feature. Stopping an impersonation is deliberately never gated. + */ +export function requireImpersonationEnabled(): void { + if (!env.IMPERSONATION_ENABLED) { + throw new Response("Not Found", { status: 404 }); + } +} + export async function adminGetUsers(userId: string, { page, search }: SearchParams) { page = page || 1; @@ -217,6 +230,8 @@ export async function redirectWithImpersonation( currentUser?: { id: string; admin: boolean }, prismaClient: PrismaClientOrTransaction = prisma ) { + requireImpersonationEnabled(); + const user = currentUser ?? (await requireUser(request)); if (!user.admin) { throw new Error("Unauthorized"); diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index aa850ba0468..1bc4d428bf1 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -37,6 +37,9 @@ export function commitImpersonationSession(session: Session) { } export async function getImpersonationId(request: Request) { + // Flag off: any impersonation cookie is inert, however it was obtained. + if (!env.IMPERSONATION_ENABLED) return undefined; + const session = await getImpersonationSession(request); return session.get(IMPERSONATED_USER_ID_KEY) as string | undefined; @@ -74,6 +77,14 @@ export async function getImpersonationState( request: Request, resolvedUserId: string | undefined ): Promise { + if (!env.IMPERSONATION_ENABLED) { + return resolveImpersonationState({ + impersonatedUserId: undefined, + viewingAsUser: undefined, + resolvedUserId, + }); + } + const session = await getImpersonationSession(request); return resolveImpersonationState({ From 8c9a2a7b0fa4b7a26317aefd5276e15649da797c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:30:53 +0200 Subject: [PATCH 02/10] feat(webapp): impersonation routes 404 when the flag is off --- apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx | 5 +++++ apps/webapp/app/routes/admin.impersonate.tsx | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx index 2923a6fdeeb..23893ddcef6 100644 --- a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx +++ b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx @@ -10,6 +10,7 @@ import { env } from "~/env.server"; import { clearImpersonation, findImpersonationTarget, + requireImpersonationEnabled, startImpersonation, } from "~/models/admin.server"; import { logger } from "~/services/logger.server"; @@ -26,6 +27,8 @@ import { isSameOriginNavigation } from "~/utils/sameOriginNavigation"; // here would drag server-only modules into the client build. export async function loader({ request, params }: LoaderFunctionArgs) { + requireImpersonationEnabled(); + const user = await requireUser(request); // If already impersonating, we need to clear the impersonation. Redirects are @@ -101,6 +104,8 @@ function refererOrigin(request: Request): string | undefined { } export async function action({ request, params }: ActionFunctionArgs) { + requireImpersonationEnabled(); + if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); } diff --git a/apps/webapp/app/routes/admin.impersonate.tsx b/apps/webapp/app/routes/admin.impersonate.tsx index 458ed5b2a7e..f077eea28fa 100644 --- a/apps/webapp/app/routes/admin.impersonate.tsx +++ b/apps/webapp/app/routes/admin.impersonate.tsx @@ -4,7 +4,7 @@ import { type LoaderFunctionArgs, } from "@remix-run/server-runtime"; import { z } from "zod"; -import { redirectWithImpersonation } from "~/models/admin.server"; +import { redirectWithImpersonation, requireImpersonationEnabled } from "~/models/admin.server"; import { requireUser } from "~/services/session.server"; import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server"; import { logger } from "~/services/logger.server"; @@ -20,6 +20,8 @@ async function handleImpersonationRequest(request: Request, userId: string): Pro } export const loader = async ({ request }: LoaderFunctionArgs) => { + requireImpersonationEnabled(); + const url = new URL(request.url); const impersonateUserId = url.searchParams.get("impersonate"); const impersonationToken = url.searchParams.get("impersonationToken"); @@ -50,6 +52,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; export async function action({ request }: ActionFunctionArgs) { + requireImpersonationEnabled(); + if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); } From 927519c13fc3f906a2d4deb36376862647d1138e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:35:53 +0200 Subject: [PATCH 03/10] feat(webapp): hide impersonation UI when the flag is off, with flag-off test coverage --- apps/webapp/app/routes/admin._index.tsx | 42 ++++++----- apps/webapp/app/routes/admin.orgs.tsx | 30 ++++---- .../webapp/test/impersonationDisabled.test.ts | 74 +++++++++++++++++++ 3 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 apps/webapp/test/impersonationDisabled.test.ts diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 3005934d226..f82734275e2 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -2,6 +2,7 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { env } from "~/env.server"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; import { Input } from "~/components/primitives/Input"; @@ -36,7 +37,7 @@ export const loader = dashboardLoader( } const result = await adminGetUsers(user.id, searchParams.params.getAll()); - return typedjson(result); + return typedjson({ ...result, impersonationEnabled: env.IMPERSONATION_ENABLED }); } ); @@ -57,7 +58,8 @@ export const action = dashboardAction( ); export default function AdminDashboardRoute() { - const { users, filters, page, pageCount } = useTypedLoaderData(); + const { users, filters, page, pageCount, impersonationEnabled } = + useTypedLoaderData(); return (
{user.admin ? "✅" : ""} -
- - -
+ {impersonationEnabled && ( +
+ + +
+ )}
); diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 51cd9552325..da34602ac82 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -3,6 +3,7 @@ import { Form } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { useState } from "react"; import { z } from "zod"; +import { env } from "~/env.server"; import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; @@ -38,12 +39,13 @@ export const loader = dashboardLoader( } const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - return typedjson(result); + return typedjson({ ...result, impersonationEnabled: env.IMPERSONATION_ENABLED }); } ); export default function AdminDashboardRoute() { - const { organizations, filters, page, pageCount } = useTypedLoaderData(); + const { organizations, filters, page, pageCount, impersonationEnabled } = + useTypedLoaderData(); const [flagsOrgId, setFlagsOrgId] = useState(null); const [flagsOpen, setFlagsOpen] = useState(false); @@ -127,17 +129,19 @@ export default function AdminDashboardRoute() { - - Impersonate - + {impersonationEnabled && ( + + Impersonate + + )} diff --git a/apps/webapp/test/impersonationDisabled.test.ts b/apps/webapp/test/impersonationDisabled.test.ts new file mode 100644 index 00000000000..7d1821f1366 --- /dev/null +++ b/apps/webapp/test/impersonationDisabled.test.ts @@ -0,0 +1,74 @@ +import { containerTest } from "@internal/testcontainers"; +import { describe, expect, vi } from "vitest"; +import { env } from "~/env.server"; +import { redirectWithImpersonation } from "~/models/admin.server"; +import { + commitImpersonationSession, + getImpersonationId, + getImpersonationState, + setImpersonationId, +} from "~/services/impersonation.server"; + +vi.setConfig({ testTimeout: 30_000 }); + +function suffix() { + return Math.random().toString(36).slice(2, 10); +} + +// IMPERSONATION_ENABLED=false must make impersonation fully inert: starting +// one 404s, and an existing cookie resolves to nothing however it was +// obtained. Stopping is deliberately never gated, so no test pins it here. +describe("impersonation disabled", () => { + containerTest("starting impersonation 404s and cookies are inert", async ({ prisma }) => { + const admin = await prisma.user.create({ + data: { + email: `admin-${suffix()}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: true, + }, + }); + const target = await prisma.user.create({ + data: { + email: `target-${suffix()}@test.local`, + authenticationMethod: "MAGIC_LINK", + confirmedBasicDetails: true, + }, + }); + + // A cookie minted while the flag was on, e.g. carried over or replayed. + const session = await setImpersonationId(target.id, new Request("http://localhost:3030/admin")); + const cookie = await commitImpersonationSession(session); + const requestWithCookie = () => + new Request("http://localhost:3030/", { headers: { Cookie: cookie } }); + + expect(await getImpersonationId(requestWithCookie())).toBe(target.id); + + const original = env.IMPERSONATION_ENABLED; + // @ts-expect-error deliberately flipping the parsed env for the test + env.IMPERSONATION_ENABLED = false; + try { + await expect( + redirectWithImpersonation( + new Request("http://localhost:3030/admin/impersonate", { method: "POST" }), + target.id, + "/", + { id: admin.id, admin: true }, + prisma + ) + ).rejects.toMatchObject({ status: 404 }); + + // No audit log: the gate fires before anything is recorded. + expect(await prisma.impersonationAuditLog.count()).toBe(0); + + expect(await getImpersonationId(requestWithCookie())).toBeUndefined(); + const state = await getImpersonationState(requestWithCookie(), admin.id); + expect(state.isImpersonating).toBe(false); + } finally { + // @ts-expect-error restore the parsed env + env.IMPERSONATION_ENABLED = original; + } + + // Flag back on: the same cookie resolves again. + expect(await getImpersonationId(requestWithCookie())).toBe(target.id); + }); +}); From 49069e0f169c0b06fb39952f9ca53a23c4b926c9 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:45:59 +0200 Subject: [PATCH 04/10] fix(webapp): close remaining impersonation surfaces when the flag is off - Plain customer cards no longer render the impersonate button or mint tokens - /@/runs quick-nav 404s instead of running its cross-org lookup - stopping always audits and clears via an ungated cookie reader, and the root loader actively terminates lingering sessions so a later flag flip cannot resurrect them - flag documented for self-hosters; tests pin the default, the disabled state, and the stop path --- apps/webapp/app/models/admin.server.ts | 6 ++- apps/webapp/app/root.tsx | 11 ++++- apps/webapp/app/routes/@.runs.$runParam.ts | 3 ++ .../app/routes/api.v1.plain.customer-cards.ts | 2 +- .../app/services/impersonation.server.ts | 9 +++++ .../webapp/test/impersonationDisabled.test.ts | 40 ++++++++++++++----- docs/self-hosting/env/webapp.mdx | 1 + 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 40cf247dcad..41d62704db5 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -5,7 +5,7 @@ import type { SearchParams } from "~/routes/admin._index"; import { clearImpersonationId, commitImpersonationSession, - getImpersonationId, + getRawImpersonationId, setImpersonationId, } from "~/services/impersonation.server"; import { authenticator } from "~/services/auth.server"; @@ -347,7 +347,9 @@ export async function startImpersonation( export async function clearImpersonation(request: Request, path: string) { const authUser = await authenticator.isAuthenticated(request); - const targetId = await getImpersonationId(request); + // Raw read: stopping must clear and audit the session even when the gated + // reader no longer resolves it (IMPERSONATION_ENABLED off). + const targetId = await getRawImpersonationId(request); if (targetId && authUser?.userId) { const xff = request.headers.get("x-forwarded-for"); diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 3cb547db4c7..63ee236680c 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -21,7 +21,8 @@ import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync"; -import { getImpersonationState } from "./services/impersonation.server"; +import { clearImpersonation } from "./models/admin.server"; +import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { normalizeIconContrast, @@ -117,6 +118,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // the `user.isViewingAsUser` the server computes could disagree, and the // client-side admin UI would hide itself on a session that is not // impersonating. + // Flag off: actively terminate any lingering impersonation session (STOP + // audit row + cookie cleared + one self-redirect) instead of leaving an + // inert cookie that would resurrect if the flag were ever re-enabled. + if (!env.IMPERSONATION_ENABLED && (await getRawImpersonationId(request))) { + const url = new URL(request.url); + throw await clearImpersonation(request, `${url.pathname}${url.search}`); + } + const { isViewingAsUser } = await getImpersonationState(request, user?.id); const headers = new Headers(); diff --git a/apps/webapp/app/routes/@.runs.$runParam.ts b/apps/webapp/app/routes/@.runs.$runParam.ts index ed5ca156f38..06890047023 100644 --- a/apps/webapp/app/routes/@.runs.$runParam.ts +++ b/apps/webapp/app/routes/@.runs.$runParam.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +import { requireImpersonationEnabled } from "~/models/admin.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder"; @@ -13,6 +14,8 @@ const ParamsSchema = z.object({ }); export async function loader({ params, request }: LoaderFunctionArgs) { + requireImpersonationEnabled(); + const user = await requireUser(request); const { runParam } = ParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index bfb9988bee2..ca15dac50f3 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -156,7 +156,7 @@ export async function action({ request }: ActionFunctionArgs) { * Derived from which lookup actually matched, not from whether an external id was *sent* — an * id that misses and falls through to email must not unlock impersonation. */ - const canImpersonate = Boolean(byExternalId); + const canImpersonate = Boolean(byExternalId) && env.IMPERSONATION_ENABLED; // No matching user: still answer every requested key, with no data so Plain hides the cards. if (!user) { diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index 1bc4d428bf1..fde79ebf71a 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -40,6 +40,15 @@ export async function getImpersonationId(request: Request) { // Flag off: any impersonation cookie is inert, however it was obtained. if (!env.IMPERSONATION_ENABLED) return undefined; + return getRawImpersonationId(request); +} + +/** + * The raw cookie value, ignoring IMPERSONATION_ENABLED. Only for terminating + * or auditing a session the gated reader no longer resolves — never for + * authorizing anything. + */ +export async function getRawImpersonationId(request: Request) { const session = await getImpersonationSession(request); return session.get(IMPERSONATED_USER_ID_KEY) as string | undefined; diff --git a/apps/webapp/test/impersonationDisabled.test.ts b/apps/webapp/test/impersonationDisabled.test.ts index 7d1821f1366..e45135509ad 100644 --- a/apps/webapp/test/impersonationDisabled.test.ts +++ b/apps/webapp/test/impersonationDisabled.test.ts @@ -1,11 +1,12 @@ -import { containerTest } from "@internal/testcontainers"; +import { postgresTest } from "@internal/testcontainers"; import { describe, expect, vi } from "vitest"; import { env } from "~/env.server"; -import { redirectWithImpersonation } from "~/models/admin.server"; +import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server"; import { commitImpersonationSession, getImpersonationId, getImpersonationState, + getRawImpersonationId, setImpersonationId, } from "~/services/impersonation.server"; @@ -17,9 +18,16 @@ function suffix() { // IMPERSONATION_ENABLED=false must make impersonation fully inert: starting // one 404s, and an existing cookie resolves to nothing however it was -// obtained. Stopping is deliberately never gated, so no test pins it here. +// obtained. Stopping stays possible with the flag off — that's how lingering +// sessions get terminated — and must still clear the cookie. describe("impersonation disabled", () => { - containerTest("starting impersonation 404s and cookies are inert", async ({ prisma }) => { + postgresTest("the flag defaults to enabled", async () => { + // Flipping this default would kill impersonation on every existing + // deployment that never heard of the flag. + expect(env.IMPERSONATION_ENABLED).toBe(true); + }); + + postgresTest("starting impersonation 404s and cookies are inert", async ({ prisma }) => { const admin = await prisma.user.create({ data: { email: `admin-${suffix()}@test.local`, @@ -42,6 +50,10 @@ describe("impersonation disabled", () => { new Request("http://localhost:3030/", { headers: { Cookie: cookie } }); expect(await getImpersonationId(requestWithCookie())).toBe(target.id); + // resolvedUserId must match the impersonated id for the state to count as + // impersonating — that's what getUserId resolves to while the cookie works. + const enabledState = await getImpersonationState(requestWithCookie(), target.id); + expect(enabledState.isImpersonating).toBe(true); const original = env.IMPERSONATION_ENABLED; // @ts-expect-error deliberately flipping the parsed env for the test @@ -61,14 +73,24 @@ describe("impersonation disabled", () => { expect(await prisma.impersonationAuditLog.count()).toBe(0); expect(await getImpersonationId(requestWithCookie())).toBeUndefined(); - const state = await getImpersonationState(requestWithCookie(), admin.id); - expect(state.isImpersonating).toBe(false); + const disabledState = await getImpersonationState(requestWithCookie(), target.id); + expect(disabledState.isImpersonating).toBe(false); + + // The ungated reader still sees the cookie — it's what stop/scrub paths + // use to terminate a session the gated reader no longer resolves. + expect(await getRawImpersonationId(requestWithCookie())).toBe(target.id); + + // Stopping works with the flag off and clears the cookie. + const response = await clearImpersonation(requestWithCookie(), "/"); + const setCookie = response.headers.get("set-cookie"); + expect(setCookie).toContain("__impersonate="); + const clearedRequest = new Request("http://localhost:3030/", { + headers: { Cookie: setCookie!.split(";")[0] }, + }); + expect(await getRawImpersonationId(clearedRequest)).toBeUndefined(); } finally { // @ts-expect-error restore the parsed env env.IMPERSONATION_ENABLED = original; } - - // Flag back on: the same cookie resolves again. - expect(await getImpersonationId(requestWithCookie())).toBe(target.id); }); }); diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 6e89dd52aba..f2c6bef4eb7 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -184,6 +184,7 @@ mode: "wide" | `MACHINE_PRESETS_OVERRIDE_PATH` | No | — | Path to machine presets override file. See [machine overrides](/self-hosting/overview#machine-overrides). | | `APP_ENV` | No | `NODE_ENV` | App environment. Used for things like the title tag. | | `ADMIN_EMAILS` | No | — | Regex of user emails to automatically promote to admin on signup. Does not apply to existing users. | +| `IMPERSONATION_ENABLED` | No | 1 | Set to anything other than `1` or `true` to disable admin user impersonation on this instance entirely. | | `EVENT_LOOP_MONITOR_ENABLED` | No | 1 | Node.js event loop lag monitor. | ## Multi-Provider Object Storage From ea706eeb9ed968d55c9ec5aeeb330047069be6fc Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:49:31 +0200 Subject: [PATCH 05/10] chore(webapp): trim impersonation flag comments --- apps/webapp/app/env.server.ts | 4 +--- apps/webapp/app/models/admin.server.ts | 11 +++-------- apps/webapp/app/root.tsx | 5 ++--- apps/webapp/app/services/impersonation.server.ts | 8 ++------ apps/webapp/test/impersonationDisabled.test.ts | 16 ++++++---------- 5 files changed, 14 insertions(+), 30 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 43b0a8e66e4..991c946d679 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -332,9 +332,7 @@ const EnvironmentSchema = z .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") .optional(), ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), - // When disabled, user impersonation is fully off for this instance: - // existing impersonation cookies are ignored, the start endpoints 404, - // and the impersonation UI isn't rendered. + // Instance-level kill switch for user impersonation. IMPERSONATION_ENABLED: BoolEnv.default(true), REMIX_APP_PORT: z.string().optional(), // Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port. diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 41d62704db5..758d75acb59 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -16,12 +16,8 @@ import { env } from "~/env.server"; const pageSize = 20; -/** - * Guard for everything that starts an impersonation (the model function and - * the routes that render or serve the flow). With IMPERSONATION_ENABLED off, - * those surfaces don't exist: 404, not 403, so the instance doesn't advertise - * the feature. Stopping an impersonation is deliberately never gated. - */ +// 404, not 403, so a disabled instance doesn't advertise the feature. +// Stopping an impersonation is deliberately never gated. export function requireImpersonationEnabled(): void { if (!env.IMPERSONATION_ENABLED) { throw new Response("Not Found", { status: 404 }); @@ -347,8 +343,7 @@ export async function startImpersonation( export async function clearImpersonation(request: Request, path: string) { const authUser = await authenticator.isAuthenticated(request); - // Raw read: stopping must clear and audit the session even when the gated - // reader no longer resolves it (IMPERSONATION_ENABLED off). + // Raw read: stops must audit and clear even with IMPERSONATION_ENABLED off. const targetId = await getRawImpersonationId(request); if (targetId && authUser?.userId) { diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 63ee236680c..cd201263d52 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -118,9 +118,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // the `user.isViewingAsUser` the server computes could disagree, and the // client-side admin UI would hide itself on a session that is not // impersonating. - // Flag off: actively terminate any lingering impersonation session (STOP - // audit row + cookie cleared + one self-redirect) instead of leaving an - // inert cookie that would resurrect if the flag were ever re-enabled. + // Flag off: terminate lingering impersonation sessions (audit + clear) + // rather than leaving a cookie that would resurrect on a later re-enable. if (!env.IMPERSONATION_ENABLED && (await getRawImpersonationId(request))) { const url = new URL(request.url); throw await clearImpersonation(request, `${url.pathname}${url.search}`); diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index fde79ebf71a..f45d5c1f24c 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -37,17 +37,13 @@ export function commitImpersonationSession(session: Session) { } export async function getImpersonationId(request: Request) { - // Flag off: any impersonation cookie is inert, however it was obtained. if (!env.IMPERSONATION_ENABLED) return undefined; return getRawImpersonationId(request); } -/** - * The raw cookie value, ignoring IMPERSONATION_ENABLED. Only for terminating - * or auditing a session the gated reader no longer resolves — never for - * authorizing anything. - */ +// Ignores IMPERSONATION_ENABLED — only for terminating or auditing a session +// the gated reader no longer resolves, never for authorizing anything. export async function getRawImpersonationId(request: Request) { const session = await getImpersonationSession(request); diff --git a/apps/webapp/test/impersonationDisabled.test.ts b/apps/webapp/test/impersonationDisabled.test.ts index e45135509ad..6ae72a7a421 100644 --- a/apps/webapp/test/impersonationDisabled.test.ts +++ b/apps/webapp/test/impersonationDisabled.test.ts @@ -16,14 +16,11 @@ function suffix() { return Math.random().toString(36).slice(2, 10); } -// IMPERSONATION_ENABLED=false must make impersonation fully inert: starting -// one 404s, and an existing cookie resolves to nothing however it was -// obtained. Stopping stays possible with the flag off — that's how lingering -// sessions get terminated — and must still clear the cookie. +// IMPERSONATION_ENABLED=false: starting 404s, cookies resolve to nothing, +// stopping still works so lingering sessions can be terminated. describe("impersonation disabled", () => { postgresTest("the flag defaults to enabled", async () => { - // Flipping this default would kill impersonation on every existing - // deployment that never heard of the flag. + // Flipping the default would kill impersonation on every existing deployment. expect(env.IMPERSONATION_ENABLED).toBe(true); }); @@ -50,8 +47,8 @@ describe("impersonation disabled", () => { new Request("http://localhost:3030/", { headers: { Cookie: cookie } }); expect(await getImpersonationId(requestWithCookie())).toBe(target.id); - // resolvedUserId must match the impersonated id for the state to count as - // impersonating — that's what getUserId resolves to while the cookie works. + // resolvedUserId must be the impersonated id or the state is false even + // with the flag on, making the disabled assertion below vacuous. const enabledState = await getImpersonationState(requestWithCookie(), target.id); expect(enabledState.isImpersonating).toBe(true); @@ -76,8 +73,7 @@ describe("impersonation disabled", () => { const disabledState = await getImpersonationState(requestWithCookie(), target.id); expect(disabledState.isImpersonating).toBe(false); - // The ungated reader still sees the cookie — it's what stop/scrub paths - // use to terminate a session the gated reader no longer resolves. + // The ungated reader still sees the cookie (stop/scrub paths need it). expect(await getRawImpersonationId(requestWithCookie())).toBe(target.id); // Stopping works with the flag off and clears the cookie. From 9ee12f4d0df56467fe2bfcc22d28c9b9d4afc8b3 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 12:50:31 +0200 Subject: [PATCH 06/10] chore: add server-changes note for the impersonation flag --- .server-changes/impersonation-enabled-flag.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/impersonation-enabled-flag.md diff --git a/.server-changes/impersonation-enabled-flag.md b/.server-changes/impersonation-enabled-flag.md new file mode 100644 index 00000000000..dff84aeb1be --- /dev/null +++ b/.server-changes/impersonation-enabled-flag.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Admin user impersonation can now be disabled for an entire instance by setting `IMPERSONATION_ENABLED=0`. From d8272971df89af649427d0edfea122b7cab21ff8 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 13:36:16 +0200 Subject: [PATCH 07/10] temp: preview-env override to demo IMPERSONATION_ENABLED=0 (DO NOT MERGE) --- .triggerdotdev/preview-env.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .triggerdotdev/preview-env.yml diff --git a/.triggerdotdev/preview-env.yml b/.triggerdotdev/preview-env.yml new file mode 100644 index 00000000000..27c08d1dc8c --- /dev/null +++ b/.triggerdotdev/preview-env.yml @@ -0,0 +1,6 @@ +# TEMP - DO NOT MERGE: preview-only override to demo the disabled state. +# Revert this commit before merging. +web_app: + IMPERSONATION_ENABLED: "0" +api: + IMPERSONATION_ENABLED: "0" From ed856c511a82cc836734bd78f8ecda55cde7e7bf Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 13:56:29 +0200 Subject: [PATCH 08/10] feat(webapp): put the whole admin dashboard behind the flag, renamed ADMIN_DASHBOARD_ENABLED The requireSuper authorization branch now honors the flag, so every admin dashboard page redirects away when disabled; the display-permission booleans and useHasAdminAccess follow suit, hiding the admin nav affordances. The hand-rolled admin.data-stores checks get the same gate. --- .server-changes/admin-dashboard-enabled-flag.md | 6 ++++++ .server-changes/impersonation-enabled-flag.md | 6 ------ .triggerdotdev/preview-env.yml | 4 ++-- apps/webapp/app/env.server.ts | 4 ++-- apps/webapp/app/hooks/useUser.ts | 6 ++++++ apps/webapp/app/models/admin.server.ts | 8 ++++---- apps/webapp/app/root.tsx | 3 ++- apps/webapp/app/routes/@.runs.$runParam.ts | 4 ++-- .../app/routes/_app.@.orgs.$organizationSlug.$.tsx | 6 +++--- apps/webapp/app/routes/admin._index.tsx | 2 +- apps/webapp/app/routes/admin.data-stores.tsx | 5 +++-- apps/webapp/app/routes/admin.impersonate.tsx | 6 +++--- apps/webapp/app/routes/admin.orgs.tsx | 2 +- .../webapp/app/routes/api.v1.plain.customer-cards.ts | 2 +- apps/webapp/app/services/impersonation.server.ts | 6 +++--- .../routeBuilders/dashboardBuilder.server.ts | 3 ++- .../app/services/routeBuilders/permissions.server.ts | 5 ++++- apps/webapp/test/impersonationDisabled.test.ts | 12 ++++++------ docs/self-hosting/env/webapp.mdx | 2 +- 19 files changed, 52 insertions(+), 40 deletions(-) create mode 100644 .server-changes/admin-dashboard-enabled-flag.md delete mode 100644 .server-changes/impersonation-enabled-flag.md diff --git a/.server-changes/admin-dashboard-enabled-flag.md b/.server-changes/admin-dashboard-enabled-flag.md new file mode 100644 index 00000000000..653eb8bb2ba --- /dev/null +++ b/.server-changes/admin-dashboard-enabled-flag.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The admin dashboard and user impersonation can now be disabled for an entire instance by setting `ADMIN_DASHBOARD_ENABLED=0`. diff --git a/.server-changes/impersonation-enabled-flag.md b/.server-changes/impersonation-enabled-flag.md deleted file mode 100644 index dff84aeb1be..00000000000 --- a/.server-changes/impersonation-enabled-flag.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Admin user impersonation can now be disabled for an entire instance by setting `IMPERSONATION_ENABLED=0`. diff --git a/.triggerdotdev/preview-env.yml b/.triggerdotdev/preview-env.yml index 27c08d1dc8c..31b14afe1fc 100644 --- a/.triggerdotdev/preview-env.yml +++ b/.triggerdotdev/preview-env.yml @@ -1,6 +1,6 @@ # TEMP - DO NOT MERGE: preview-only override to demo the disabled state. # Revert this commit before merging. web_app: - IMPERSONATION_ENABLED: "0" + ADMIN_DASHBOARD_ENABLED: "0" api: - IMPERSONATION_ENABLED: "0" + ADMIN_DASHBOARD_ENABLED: "0" diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 991c946d679..aec9cd82927 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -332,8 +332,8 @@ const EnvironmentSchema = z .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") .optional(), ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), - // Instance-level kill switch for user impersonation. - IMPERSONATION_ENABLED: BoolEnv.default(true), + // Instance-level kill switch for the admin dashboard and user impersonation. + ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true), REMIX_APP_PORT: z.string().optional(), // Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port. // Read directly from process.env in server.ts (before this schema loads); declared here for discoverability. diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts index 2eed91b9734..08aff433192 100644 --- a/apps/webapp/app/hooks/useUser.ts +++ b/apps/webapp/app/hooks/useUser.ts @@ -48,6 +48,12 @@ export function useHasAdminAccess(matches?: UIMatch[]): boolean { const user = useOptionalUser(matches); const isImpersonating = useIsImpersonating(matches); const isViewingAsUser = useIsViewingAsUser(matches); + const routeMatch = useTypedMatchesData({ + id: "root", + matches, + }); + + if (routeMatch?.adminDashboardEnabled === false) return false; return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser; } diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index 758d75acb59..513b79c2261 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -18,8 +18,8 @@ const pageSize = 20; // 404, not 403, so a disabled instance doesn't advertise the feature. // Stopping an impersonation is deliberately never gated. -export function requireImpersonationEnabled(): void { - if (!env.IMPERSONATION_ENABLED) { +export function requireAdminDashboardEnabled(): void { + if (!env.ADMIN_DASHBOARD_ENABLED) { throw new Response("Not Found", { status: 404 }); } } @@ -226,7 +226,7 @@ export async function redirectWithImpersonation( currentUser?: { id: string; admin: boolean }, prismaClient: PrismaClientOrTransaction = prisma ) { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); const user = currentUser ?? (await requireUser(request)); if (!user.admin) { @@ -343,7 +343,7 @@ export async function startImpersonation( export async function clearImpersonation(request: Request, path: string) { const authUser = await authenticator.isAuthenticated(request); - // Raw read: stops must audit and clear even with IMPERSONATION_ENABLED off. + // Raw read: stops must audit and clear even with ADMIN_DASHBOARD_ENABLED off. const targetId = await getRawImpersonationId(request); if (targetId && authUser?.userId) { diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index cd201263d52..de230f16a67 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -120,7 +120,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // impersonating. // Flag off: terminate lingering impersonation sessions (audit + clear) // rather than leaving a cookie that would resurrect on a later re-enable. - if (!env.IMPERSONATION_ENABLED && (await getRawImpersonationId(request))) { + if (!env.ADMIN_DASHBOARD_ENABLED && (await getRawImpersonationId(request))) { const url = new URL(request.url); throw await clearImpersonation(request, `${url.pathname}${url.search}`); } @@ -134,6 +134,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { { user, isViewingAsUser, + adminDashboardEnabled: env.ADMIN_DASHBOARD_ENABLED, toastMessage, posthogProjectKey, posthogUiHost, diff --git a/apps/webapp/app/routes/@.runs.$runParam.ts b/apps/webapp/app/routes/@.runs.$runParam.ts index 06890047023..d8ff7fd49d1 100644 --- a/apps/webapp/app/routes/@.runs.$runParam.ts +++ b/apps/webapp/app/routes/@.runs.$runParam.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; -import { requireImpersonationEnabled } from "~/models/admin.server"; +import { requireAdminDashboardEnabled } from "~/models/admin.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder"; @@ -14,7 +14,7 @@ const ParamsSchema = z.object({ }); export async function loader({ params, request }: LoaderFunctionArgs) { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); const user = await requireUser(request); diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx index 23893ddcef6..0fc4f41d1a3 100644 --- a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx +++ b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx @@ -10,7 +10,7 @@ import { env } from "~/env.server"; import { clearImpersonation, findImpersonationTarget, - requireImpersonationEnabled, + requireAdminDashboardEnabled, startImpersonation, } from "~/models/admin.server"; import { logger } from "~/services/logger.server"; @@ -27,7 +27,7 @@ import { isSameOriginNavigation } from "~/utils/sameOriginNavigation"; // here would drag server-only modules into the client build. export async function loader({ request, params }: LoaderFunctionArgs) { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); const user = await requireUser(request); @@ -104,7 +104,7 @@ function refererOrigin(request: Request): string | undefined { } export async function action({ request, params }: ActionFunctionArgs) { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index f82734275e2..3741f499d38 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -37,7 +37,7 @@ export const loader = dashboardLoader( } const result = await adminGetUsers(user.id, searchParams.params.getAll()); - return typedjson({ ...result, impersonationEnabled: env.IMPERSONATION_ENABLED }); + return typedjson({ ...result, impersonationEnabled: env.ADMIN_DASHBOARD_ENABLED }); } ); diff --git a/apps/webapp/app/routes/admin.data-stores.tsx b/apps/webapp/app/routes/admin.data-stores.tsx index af4a15dfcae..acbb4675c42 100644 --- a/apps/webapp/app/routes/admin.data-stores.tsx +++ b/apps/webapp/app/routes/admin.data-stores.tsx @@ -25,6 +25,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { requireUser } from "~/services/session.server"; import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server"; import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server"; @@ -36,7 +37,7 @@ import { tryCatch } from "@trigger.dev/core/utils"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); - if (!user.admin) throw redirect("/"); + if (!user.admin || !env.ADMIN_DASHBOARD_ENABLED) throw redirect("/"); const dataStores = await prisma.organizationDataStore.findMany({ orderBy: { createdAt: "desc" }, @@ -72,7 +73,7 @@ const FormSchema = z.discriminatedUnion("_action", [AddSchema, UpdateSchema, Del export async function action({ request }: ActionFunctionArgs) { const user = await requireUser(request); - if (!user.admin) throw redirect("/"); + if (!user.admin || !env.ADMIN_DASHBOARD_ENABLED) throw redirect("/"); const formData = await request.formData(); diff --git a/apps/webapp/app/routes/admin.impersonate.tsx b/apps/webapp/app/routes/admin.impersonate.tsx index f077eea28fa..46f711b26e8 100644 --- a/apps/webapp/app/routes/admin.impersonate.tsx +++ b/apps/webapp/app/routes/admin.impersonate.tsx @@ -4,7 +4,7 @@ import { type LoaderFunctionArgs, } from "@remix-run/server-runtime"; import { z } from "zod"; -import { redirectWithImpersonation, requireImpersonationEnabled } from "~/models/admin.server"; +import { redirectWithImpersonation, requireAdminDashboardEnabled } from "~/models/admin.server"; import { requireUser } from "~/services/session.server"; import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server"; import { logger } from "~/services/logger.server"; @@ -20,7 +20,7 @@ async function handleImpersonationRequest(request: Request, userId: string): Pro } export const loader = async ({ request }: LoaderFunctionArgs) => { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); const url = new URL(request.url); const impersonateUserId = url.searchParams.get("impersonate"); @@ -52,7 +52,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; export async function action({ request }: ActionFunctionArgs) { - requireImpersonationEnabled(); + requireAdminDashboardEnabled(); if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index da34602ac82..132ad860bfd 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -39,7 +39,7 @@ export const loader = dashboardLoader( } const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - return typedjson({ ...result, impersonationEnabled: env.IMPERSONATION_ENABLED }); + return typedjson({ ...result, impersonationEnabled: env.ADMIN_DASHBOARD_ENABLED }); } ); diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index ca15dac50f3..de7f96be6c6 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -156,7 +156,7 @@ export async function action({ request }: ActionFunctionArgs) { * Derived from which lookup actually matched, not from whether an external id was *sent* — an * id that misses and falls through to email must not unlock impersonation. */ - const canImpersonate = Boolean(byExternalId) && env.IMPERSONATION_ENABLED; + const canImpersonate = Boolean(byExternalId) && env.ADMIN_DASHBOARD_ENABLED; // No matching user: still answer every requested key, with no data so Plain hides the cards. if (!user) { diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index f45d5c1f24c..bce88bfbc66 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -37,12 +37,12 @@ export function commitImpersonationSession(session: Session) { } export async function getImpersonationId(request: Request) { - if (!env.IMPERSONATION_ENABLED) return undefined; + if (!env.ADMIN_DASHBOARD_ENABLED) return undefined; return getRawImpersonationId(request); } -// Ignores IMPERSONATION_ENABLED — only for terminating or auditing a session +// Ignores ADMIN_DASHBOARD_ENABLED — only for terminating or auditing a session // the gated reader no longer resolves, never for authorizing anything. export async function getRawImpersonationId(request: Request) { const session = await getImpersonationSession(request); @@ -82,7 +82,7 @@ export async function getImpersonationState( request: Request, resolvedUserId: string | undefined ): Promise { - if (!env.IMPERSONATION_ENABLED) { + if (!env.ADMIN_DASHBOARD_ENABLED) { return resolveImpersonationState({ impersonatedUserId: undefined, viewingAsUser: undefined, diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts index 01bdb6d9b53..8698b56bcd1 100644 --- a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts @@ -5,6 +5,7 @@ import { json, redirect } from "@remix-run/server-runtime"; import type { RbacAbility } from "@trigger.dev/rbac"; +import { env } from "~/env.server"; import { rbac } from "~/services/rbac.server"; import { getUserId } from "~/services/session.server"; import { permissionDeniedResponse } from "~/utils/permissionDenied"; @@ -23,7 +24,7 @@ function loginRedirectFor(request: Request, override?: string): Response { function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean { if ("requireSuper" in authorization) { - return ability.canSuper(); + return env.ADMIN_DASHBOARD_ENABLED && ability.canSuper(); } return ability.can(authorization.action, authorization.resource); } diff --git a/apps/webapp/app/services/routeBuilders/permissions.server.ts b/apps/webapp/app/services/routeBuilders/permissions.server.ts index 37a70272c17..b94c7a25fac 100644 --- a/apps/webapp/app/services/routeBuilders/permissions.server.ts +++ b/apps/webapp/app/services/routeBuilders/permissions.server.ts @@ -1,4 +1,5 @@ import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; +import { env } from "~/env.server"; /** * A single permission check, mirroring the `authorization` option the @@ -32,7 +33,9 @@ export function checkPermissions( if (!Object.hasOwn(checks, key)) continue; const check = checks[key]; result[key] = - "requireSuper" in check ? ability.canSuper() : ability.can(check.action, check.resource); + "requireSuper" in check + ? env.ADMIN_DASHBOARD_ENABLED && ability.canSuper() + : ability.can(check.action, check.resource); } return result; } diff --git a/apps/webapp/test/impersonationDisabled.test.ts b/apps/webapp/test/impersonationDisabled.test.ts index 6ae72a7a421..233ef533f47 100644 --- a/apps/webapp/test/impersonationDisabled.test.ts +++ b/apps/webapp/test/impersonationDisabled.test.ts @@ -16,12 +16,12 @@ function suffix() { return Math.random().toString(36).slice(2, 10); } -// IMPERSONATION_ENABLED=false: starting 404s, cookies resolve to nothing, +// ADMIN_DASHBOARD_ENABLED=false: starting 404s, cookies resolve to nothing, // stopping still works so lingering sessions can be terminated. describe("impersonation disabled", () => { postgresTest("the flag defaults to enabled", async () => { - // Flipping the default would kill impersonation on every existing deployment. - expect(env.IMPERSONATION_ENABLED).toBe(true); + // Flipping the default would kill the admin dashboard on every existing deployment. + expect(env.ADMIN_DASHBOARD_ENABLED).toBe(true); }); postgresTest("starting impersonation 404s and cookies are inert", async ({ prisma }) => { @@ -52,9 +52,9 @@ describe("impersonation disabled", () => { const enabledState = await getImpersonationState(requestWithCookie(), target.id); expect(enabledState.isImpersonating).toBe(true); - const original = env.IMPERSONATION_ENABLED; + const original = env.ADMIN_DASHBOARD_ENABLED; // @ts-expect-error deliberately flipping the parsed env for the test - env.IMPERSONATION_ENABLED = false; + env.ADMIN_DASHBOARD_ENABLED = false; try { await expect( redirectWithImpersonation( @@ -86,7 +86,7 @@ describe("impersonation disabled", () => { expect(await getRawImpersonationId(clearedRequest)).toBeUndefined(); } finally { // @ts-expect-error restore the parsed env - env.IMPERSONATION_ENABLED = original; + env.ADMIN_DASHBOARD_ENABLED = original; } }); }); diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index f2c6bef4eb7..8d31694686e 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -184,7 +184,7 @@ mode: "wide" | `MACHINE_PRESETS_OVERRIDE_PATH` | No | — | Path to machine presets override file. See [machine overrides](/self-hosting/overview#machine-overrides). | | `APP_ENV` | No | `NODE_ENV` | App environment. Used for things like the title tag. | | `ADMIN_EMAILS` | No | — | Regex of user emails to automatically promote to admin on signup. Does not apply to existing users. | -| `IMPERSONATION_ENABLED` | No | 1 | Set to anything other than `1` or `true` to disable admin user impersonation on this instance entirely. | +| `ADMIN_DASHBOARD_ENABLED` | No | 1 | Set to anything other than `1` or `true` to disable the admin dashboard and user impersonation on this instance. | | `EVENT_LOOP_MONITOR_ENABLED` | No | 1 | Node.js event loop lag monitor. | ## Multi-Provider Object Storage From 5427a77926046f7510320da222f0b111ddf356c0 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 14:07:26 +0200 Subject: [PATCH 09/10] chore: remove temporary preview env override --- .triggerdotdev/preview-env.yml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .triggerdotdev/preview-env.yml diff --git a/.triggerdotdev/preview-env.yml b/.triggerdotdev/preview-env.yml deleted file mode 100644 index 31b14afe1fc..00000000000 --- a/.triggerdotdev/preview-env.yml +++ /dev/null @@ -1,6 +0,0 @@ -# TEMP - DO NOT MERGE: preview-only override to demo the disabled state. -# Revert this commit before merging. -web_app: - ADMIN_DASHBOARD_ENABLED: "0" -api: - ADMIN_DASHBOARD_ENABLED: "0" From 17fb90b25666498eddf99122ec455f6a30750f05 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 25 Aug 2026 14:09:15 +0200 Subject: [PATCH 10/10] chore: keep the server-changes note free of config internals --- .server-changes/admin-dashboard-enabled-flag.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.server-changes/admin-dashboard-enabled-flag.md b/.server-changes/admin-dashboard-enabled-flag.md index 653eb8bb2ba..7fe33ef3ec2 100644 --- a/.server-changes/admin-dashboard-enabled-flag.md +++ b/.server-changes/admin-dashboard-enabled-flag.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -The admin dashboard and user impersonation can now be disabled for an entire instance by setting `ADMIN_DASHBOARD_ENABLED=0`. +Self-hosted instances can now disable the admin dashboard and user impersonation entirely. See the self-hosting docs for the new setting.