Skip to content

Commit 1eda438

Browse files
authored
feat(webapp): put the admin dashboard behind an env var flag (#4774)
Adds an `ADMIN_DASHBOARD_ENABLED` env var (default: enabled) that turns the admin dashboard and user impersonation off for an entire instance. When disabled: - every admin dashboard page redirects away, and the admin navigation isn't rendered - existing impersonation cookies are ignored, and any lingering session is actively terminated with an audit record - every flow that could start an impersonation responds 404, and no impersonation tokens are minted Stopping an impersonation always works regardless of the flag, so nothing gets stuck. Machine-to-machine admin API endpoints are not affected. The variable is documented for self-hosters; instances that don't set it are unaffected.
1 parent 45eaaa7 commit 1eda438

17 files changed

Lines changed: 210 additions & 41 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted instances can now disable the admin dashboard and user impersonation entirely. See the self-hosting docs for the new setting.

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ const EnvironmentSchema = z
332332
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
333333
.optional(),
334334
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
335+
// Instance-level kill switch for the admin dashboard and user impersonation.
336+
ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true),
335337
REMIX_APP_PORT: z.string().optional(),
336338
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
337339
// Read directly from process.env in server.ts (before this schema loads); declared here for discoverability.

apps/webapp/app/hooks/useUser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export function useHasAdminAccess(matches?: UIMatch[]): boolean {
4848
const user = useOptionalUser(matches);
4949
const isImpersonating = useIsImpersonating(matches);
5050
const isViewingAsUser = useIsViewingAsUser(matches);
51+
const routeMatch = useTypedMatchesData<typeof loader>({
52+
id: "root",
53+
matches,
54+
});
55+
56+
if (routeMatch?.adminDashboardEnabled === false) return false;
5157

5258
return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser;
5359
}

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,25 @@ import type { SearchParams } from "~/routes/admin._index";
55
import {
66
clearImpersonationId,
77
commitImpersonationSession,
8-
getImpersonationId,
8+
getRawImpersonationId,
99
setImpersonationId,
1010
} from "~/services/impersonation.server";
1111
import { authenticator } from "~/services/auth.server";
1212
import { requireUser } from "~/services/session.server";
1313
import { extractClientIp } from "~/utils/extractClientIp.server";
1414
import { impersonationDestinationPath } from "~/utils/pathBuilder";
15+
import { env } from "~/env.server";
1516

1617
const pageSize = 20;
1718

19+
// 404, not 403, so a disabled instance doesn't advertise the feature.
20+
// Stopping an impersonation is deliberately never gated.
21+
export function requireAdminDashboardEnabled(): void {
22+
if (!env.ADMIN_DASHBOARD_ENABLED) {
23+
throw new Response("Not Found", { status: 404 });
24+
}
25+
}
26+
1827
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
1928
page = page || 1;
2029

@@ -217,6 +226,8 @@ export async function redirectWithImpersonation(
217226
currentUser?: { id: string; admin: boolean },
218227
prismaClient: PrismaClientOrTransaction = prisma
219228
) {
229+
requireAdminDashboardEnabled();
230+
220231
const user = currentUser ?? (await requireUser(request));
221232
if (!user.admin) {
222233
throw new Error("Unauthorized");
@@ -332,7 +343,8 @@ export async function startImpersonation(
332343

333344
export async function clearImpersonation(request: Request, path: string) {
334345
const authUser = await authenticator.isAuthenticated(request);
335-
const targetId = await getImpersonationId(request);
346+
// Raw read: stops must audit and clear even with ADMIN_DASHBOARD_ENABLED off.
347+
const targetId = await getRawImpersonationId(request);
336348

337349
if (targetId && authUser?.userId) {
338350
const xff = request.headers.get("x-forwarded-for");

apps/webapp/app/root.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { env } from "./env.server";
2121
import { featuresForRequest } from "./features.server";
2222
import { usePostHog } from "./hooks/usePostHog";
2323
import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
24-
import { getImpersonationState } from "./services/impersonation.server";
24+
import { clearImpersonation } from "./models/admin.server";
25+
import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server";
2526
import { getUser } from "./services/session.server";
2627
import {
2728
normalizeIconContrast,
@@ -117,6 +118,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
117118
// the `user.isViewingAsUser` the server computes could disagree, and the
118119
// client-side admin UI would hide itself on a session that is not
119120
// impersonating.
121+
// Flag off: terminate lingering impersonation sessions (audit + clear)
122+
// rather than leaving a cookie that would resurrect on a later re-enable.
123+
if (!env.ADMIN_DASHBOARD_ENABLED && (await getRawImpersonationId(request))) {
124+
const url = new URL(request.url);
125+
throw await clearImpersonation(request, `${url.pathname}${url.search}`);
126+
}
127+
120128
const { isViewingAsUser } = await getImpersonationState(request, user?.id);
121129

122130
const headers = new Headers();
@@ -126,6 +134,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
126134
{
127135
user,
128136
isViewingAsUser,
137+
adminDashboardEnabled: env.ADMIN_DASHBOARD_ENABLED,
129138
toastMessage,
130139
posthogProjectKey,
131140
posthogUiHost,

apps/webapp/app/routes/@.runs.$runParam.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod";
33
import { prisma } from "~/db.server";
44
import { runStore } from "~/v3/runStore.server";
55
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
6+
import { requireAdminDashboardEnabled } from "~/models/admin.server";
67
import { redirectWithErrorMessage } from "~/models/message.server";
78
import { requireUser } from "~/services/session.server";
89
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
@@ -13,6 +14,8 @@ const ParamsSchema = z.object({
1314
});
1415

1516
export async function loader({ params, request }: LoaderFunctionArgs) {
17+
requireAdminDashboardEnabled();
18+
1619
const user = await requireUser(request);
1720

1821
const { runParam } = ParamsSchema.parse(params);

apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { env } from "~/env.server";
1010
import {
1111
clearImpersonation,
1212
findImpersonationTarget,
13+
requireAdminDashboardEnabled,
1314
startImpersonation,
1415
} from "~/models/admin.server";
1516
import { logger } from "~/services/logger.server";
@@ -26,6 +27,8 @@ import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
2627
// here would drag server-only modules into the client build.
2728

2829
export async function loader({ request, params }: LoaderFunctionArgs) {
30+
requireAdminDashboardEnabled();
31+
2932
const user = await requireUser(request);
3033

3134
// If already impersonating, we need to clear the impersonation. Redirects are
@@ -101,6 +104,8 @@ function refererOrigin(request: Request): string | undefined {
101104
}
102105

103106
export async function action({ request, params }: ActionFunctionArgs) {
107+
requireAdminDashboardEnabled();
108+
104109
if (request.method.toLowerCase() !== "post") {
105110
return new Response("Method not allowed", { status: 405 });
106111
}

apps/webapp/app/routes/admin._index.tsx

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
22
import { Form } from "@remix-run/react";
33
import { typedjson, useTypedLoaderData } from "remix-typedjson";
44
import { z } from "zod";
5+
import { env } from "~/env.server";
56
import { Button, LinkButton } from "~/components/primitives/Buttons";
67
import { CopyableText } from "~/components/primitives/CopyableText";
78
import { Input } from "~/components/primitives/Input";
@@ -36,7 +37,7 @@ export const loader = dashboardLoader(
3637
}
3738
const result = await adminGetUsers(user.id, searchParams.params.getAll());
3839

39-
return typedjson(result);
40+
return typedjson({ ...result, impersonationEnabled: env.ADMIN_DASHBOARD_ENABLED });
4041
}
4142
);
4243

@@ -57,7 +58,8 @@ export const action = dashboardAction(
5758
);
5859

5960
export default function AdminDashboardRoute() {
60-
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
61+
const { users, filters, page, pageCount, impersonationEnabled } =
62+
useTypedLoaderData<typeof loader>();
6163

6264
return (
6365
<main
@@ -134,23 +136,25 @@ export default function AdminDashboardRoute() {
134136
</TableCell>
135137
<TableCell>{user.admin ? "✅" : ""}</TableCell>
136138
<TableCell isSticky={true}>
137-
<Form method="post" action="/admin/impersonate" reloadDocument>
138-
<input type="hidden" name="id" value={user.id} />
139-
<Button
140-
type="submit"
141-
name="action"
142-
value="impersonate"
143-
className="mr-2"
144-
variant="tertiary/small"
145-
shortcut={
146-
users.length === 1
147-
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
148-
: undefined
149-
}
150-
>
151-
Impersonate
152-
</Button>
153-
</Form>
139+
{impersonationEnabled && (
140+
<Form method="post" action="/admin/impersonate" reloadDocument>
141+
<input type="hidden" name="id" value={user.id} />
142+
<Button
143+
type="submit"
144+
name="action"
145+
value="impersonate"
146+
className="mr-2"
147+
variant="tertiary/small"
148+
shortcut={
149+
users.length === 1
150+
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
151+
: undefined
152+
}
153+
>
154+
Impersonate
155+
</Button>
156+
</Form>
157+
)}
154158
</TableCell>
155159
</TableRow>
156160
);

apps/webapp/app/routes/admin.data-stores.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
TableRow,
2626
} from "~/components/primitives/Table";
2727
import { prisma } from "~/db.server";
28+
import { env } from "~/env.server";
2829
import { requireUser } from "~/services/session.server";
2930
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
3031
import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server";
@@ -36,7 +37,7 @@ import { tryCatch } from "@trigger.dev/core/utils";
3637

3738
export const loader = async ({ request }: LoaderFunctionArgs) => {
3839
const user = await requireUser(request);
39-
if (!user.admin) throw redirect("/");
40+
if (!user.admin || !env.ADMIN_DASHBOARD_ENABLED) throw redirect("/");
4041

4142
const dataStores = await prisma.organizationDataStore.findMany({
4243
orderBy: { createdAt: "desc" },
@@ -72,7 +73,7 @@ const FormSchema = z.discriminatedUnion("_action", [AddSchema, UpdateSchema, Del
7273

7374
export async function action({ request }: ActionFunctionArgs) {
7475
const user = await requireUser(request);
75-
if (!user.admin) throw redirect("/");
76+
if (!user.admin || !env.ADMIN_DASHBOARD_ENABLED) throw redirect("/");
7677

7778
const formData = await request.formData();
7879

apps/webapp/app/routes/admin.impersonate.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type LoaderFunctionArgs,
55
} from "@remix-run/server-runtime";
66
import { z } from "zod";
7-
import { redirectWithImpersonation } from "~/models/admin.server";
7+
import { redirectWithImpersonation, requireAdminDashboardEnabled } from "~/models/admin.server";
88
import { requireUser } from "~/services/session.server";
99
import { validateAndConsumeImpersonationToken } from "~/services/impersonation.server";
1010
import { logger } from "~/services/logger.server";
@@ -20,6 +20,8 @@ async function handleImpersonationRequest(request: Request, userId: string): Pro
2020
}
2121

2222
export const loader = async ({ request }: LoaderFunctionArgs) => {
23+
requireAdminDashboardEnabled();
24+
2325
const url = new URL(request.url);
2426
const impersonateUserId = url.searchParams.get("impersonate");
2527
const impersonationToken = url.searchParams.get("impersonationToken");
@@ -50,6 +52,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5052
};
5153

5254
export async function action({ request }: ActionFunctionArgs) {
55+
requireAdminDashboardEnabled();
56+
5357
if (request.method.toLowerCase() !== "post") {
5458
return new Response("Method not allowed", { status: 405 });
5559
}

0 commit comments

Comments
 (0)