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
6 changes: 6 additions & 0 deletions .server-changes/admin-dashboard-enabled-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Self-hosted instances can now disable the admin dashboard and user impersonation entirely. See the self-hosting docs for the new setting.
2 changes: 2 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +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 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.
Expand Down
6 changes: 6 additions & 0 deletions apps/webapp/app/hooks/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loader>({
id: "root",
matches,
});

if (routeMatch?.adminDashboardEnabled === false) return false;

return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser;
}
16 changes: 14 additions & 2 deletions apps/webapp/app/models/admin.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@ import type { SearchParams } from "~/routes/admin._index";
import {
clearImpersonationId,
commitImpersonationSession,
getImpersonationId,
getRawImpersonationId,
setImpersonationId,
} from "~/services/impersonation.server";
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;

// 404, not 403, so a disabled instance doesn't advertise the feature.
// Stopping an impersonation is deliberately never gated.
export function requireAdminDashboardEnabled(): void {
if (!env.ADMIN_DASHBOARD_ENABLED) {
throw new Response("Not Found", { status: 404 });
}
}

export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
page = page || 1;

Expand Down Expand Up @@ -217,6 +226,8 @@ export async function redirectWithImpersonation(
currentUser?: { id: string; admin: boolean },
prismaClient: PrismaClientOrTransaction = prisma
) {
requireAdminDashboardEnabled();

const user = currentUser ?? (await requireUser(request));
if (!user.admin) {
throw new Error("Unauthorized");
Expand Down Expand Up @@ -332,7 +343,8 @@ 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: stops must audit and clear even with ADMIN_DASHBOARD_ENABLED off.
const targetId = await getRawImpersonationId(request);

if (targetId && authUser?.userId) {
const xff = request.headers.get("x-forwarded-for");
Expand Down
11 changes: 10 additions & 1 deletion apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,6 +118,13 @@ 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: terminate lingering impersonation sessions (audit + clear)
// rather than leaving a cookie that would resurrect on a later re-enable.
if (!env.ADMIN_DASHBOARD_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();
Expand All @@ -126,6 +134,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
{
user,
isViewingAsUser,
adminDashboardEnabled: env.ADMIN_DASHBOARD_ENABLED,
toastMessage,
posthogProjectKey,
posthogUiHost,
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/routes/@.runs.$runParam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand All @@ -13,6 +14,8 @@ const ParamsSchema = z.object({
});

export async function loader({ params, request }: LoaderFunctionArgs) {
requireAdminDashboardEnabled();

const user = await requireUser(request);

const { runParam } = ParamsSchema.parse(params);
Expand Down
5 changes: 5 additions & 0 deletions apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { env } from "~/env.server";
import {
clearImpersonation,
findImpersonationTarget,
requireAdminDashboardEnabled,
startImpersonation,
} from "~/models/admin.server";
import { logger } from "~/services/logger.server";
Expand All @@ -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) {
requireAdminDashboardEnabled();

const user = await requireUser(request);

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

export async function action({ request, params }: ActionFunctionArgs) {
requireAdminDashboardEnabled();

if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
Expand Down
42 changes: 23 additions & 19 deletions apps/webapp/app/routes/admin._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.ADMIN_DASHBOARD_ENABLED });
}
);

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

export default function AdminDashboardRoute() {
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
const { users, filters, page, pageCount, impersonationEnabled } =
useTypedLoaderData<typeof loader>();

return (
<main
Expand Down Expand Up @@ -134,23 +136,25 @@ export default function AdminDashboardRoute() {
</TableCell>
<TableCell>{user.admin ? "✅" : ""}</TableCell>
<TableCell isSticky={true}>
<Form method="post" action="/admin/impersonate" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="tertiary/small"
shortcut={
users.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</Button>
</Form>
{impersonationEnabled && (
<Form method="post" action="/admin/impersonate" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="tertiary/small"
shortcut={
users.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</Button>
</Form>
)}
</TableCell>
</TableRow>
);
Expand Down
5 changes: 3 additions & 2 deletions apps/webapp/app/routes/admin.data-stores.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" },
Expand Down Expand Up @@ -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();

Expand Down
6 changes: 5 additions & 1 deletion apps/webapp/app/routes/admin.impersonate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import { z } from "zod";
import { redirectWithImpersonation } 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";
Expand All @@ -20,6 +20,8 @@ async function handleImpersonationRequest(request: Request, userId: string): Pro
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
requireAdminDashboardEnabled();

const url = new URL(request.url);
const impersonateUserId = url.searchParams.get("impersonate");
const impersonationToken = url.searchParams.get("impersonationToken");
Expand Down Expand Up @@ -50,6 +52,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};

export async function action({ request }: ActionFunctionArgs) {
requireAdminDashboardEnabled();

if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
Expand Down
30 changes: 17 additions & 13 deletions apps/webapp/app/routes/admin.orgs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.ADMIN_DASHBOARD_ENABLED });
}
);

export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
const { organizations, filters, page, pageCount, impersonationEnabled } =
useTypedLoaderData<typeof loader>();

const [flagsOrgId, setFlagsOrgId] = useState<string | null>(null);
const [flagsOpen, setFlagsOpen] = useState(false);
Expand Down Expand Up @@ -127,17 +129,19 @@ export default function AdminDashboardRoute() {
<Button variant="tertiary/small" onClick={() => openFlagsDialog(org.id)}>
Flags
</Button>
<LinkButton
to={`/@/orgs/${org.slug}`}
variant="tertiary/small"
shortcut={
organizations.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</LinkButton>
{impersonationEnabled && (
<LinkButton
to={`/@/orgs/${org.slug}`}
variant="tertiary/small"
shortcut={
organizations.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</LinkButton>
)}
</div>
</TableCell>
</TableRow>
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/routes/api.v1.plain.customer-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.ADMIN_DASHBOARD_ENABLED;

// No matching user: still answer every requested key, with no data so Plain hides the cards.
if (!user) {
Expand Down
16 changes: 16 additions & 0 deletions apps/webapp/app/services/impersonation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function commitImpersonationSession(session: Session) {
}

export async function getImpersonationId(request: Request) {
if (!env.ADMIN_DASHBOARD_ENABLED) return undefined;

return getRawImpersonationId(request);
}

// 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);

return session.get(IMPERSONATED_USER_ID_KEY) as string | undefined;
Expand Down Expand Up @@ -74,6 +82,14 @@ export async function getImpersonationState(
request: Request,
resolvedUserId: string | undefined
): Promise<ImpersonationState> {
if (!env.ADMIN_DASHBOARD_ENABLED) {
return resolveImpersonationState({
impersonatedUserId: undefined,
viewingAsUser: undefined,
resolvedUserId,
});
}

const session = await getImpersonationSession(request);

return resolveImpersonationState({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
}
Expand Down
Loading