Skip to content

Commit 60a20dd

Browse files
committed
fix(webapp): keep the region pickers on the raw admin check, not the display one
Four of the hasAdminDisplayAccess sites feed a region picker that submits, and an option list a form posts from is not display. Shrinking it changes what the submitted form is able to do, so an admin who flipped "view as user" on quietly lost the ability to send a run to a hidden or restricted region from the replay dialog, the test form, the playground and the bulk-action override. All four are back on raw user.admin || user.isImpersonating. The replay dialog is the only one where the list and the current value can disagree, because the run's own region is returned separately from the list. It degrades better than it looks: the Select's hidden native select synthesises an option for a value that is not among the registered items, so an out-of-list region is displayed and posted unchanged rather than falling back to the first option — and when the list drops to a single entry the field is not rendered at all, at which point the replay service falls back to the run's own region. Nothing was silently re-routed; the cost was the narrowed choice. The regions page listing stays on hasAdminDisplayAccess. It is a read-only table, and each row's "set as default" form posts that row's own id, so a shorter list can only offer fewer rows, never a wrong value — and the action behind it already re-checks raw impersonation. The helper's comment now draws the sharper line, since "display" was reading as broader than intended: display is rendering and read-only listings, while anything a request handler reads, or that feeds a control which submits, stays raw. The run-GET guard test's session mock drops the hasAdminDisplayAccess stub it needed only for the replay loader. No module in that test's graph reaches the export now, and the file still fails only on the container runtime it cannot reach in a sandbox, with no missing-export error. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6807b68 commit 60a20dd

6 files changed

Lines changed: 38 additions & 29 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { findProjectBySlug } from "~/models/project.server";
2222
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
2323
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
2424
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
25-
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
25+
import { requireUser } from "~/services/session.server";
2626
import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
2727

2828
export const meta: MetaFunction = () => {
@@ -54,10 +54,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5454
orderBy: { createdAt: "desc" },
5555
take: 20,
5656
}),
57+
// Raw impersonation, not `hasAdminDisplayAccess`: this list is the
58+
// playground's region picker, so it decides which region a submitted run
59+
// can be sent to. "View as user" only changes what is shown.
5760
new RegionsPresenter().call({
5861
userId: user.id,
5962
projectSlug: projectParam,
60-
isAdmin: hasAdminDisplayAccess(user),
63+
isAdmin: user.admin || user.isImpersonating,
6164
}),
6265
]);
6366

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import {
6767
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
6868
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
6969
import { logger } from "~/services/logger.server";
70-
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
70+
import { requireUser } from "~/services/session.server";
7171
import { cn } from "~/utils/cn";
7272
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
7373
import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server";
@@ -117,10 +117,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
117117
taskIdentifier: taskParam,
118118
environment: environment,
119119
}),
120+
// Raw impersonation, not `hasAdminDisplayAccess`: this list is the test
121+
// form's region picker, so it decides which region a submitted test run
122+
// can be sent to. "View as user" only changes what is shown.
120123
new RegionsPresenter().call({
121124
userId: user.id,
122125
projectSlug: projectParam,
123-
isAdmin: hasAdminDisplayAccess(user),
126+
isAdmin: user.admin || user.isImpersonating,
124127
}),
125128
]);
126129

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,7 @@ import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPrese
5656
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5757
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
5858
import { logger } from "~/services/logger.server";
59-
import { getImpersonationState } from "~/services/impersonation.server";
6059
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
61-
import { hasAdminDisplayAccess } from "~/services/session.server";
6260
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
6361
import { cn } from "~/utils/cn";
6462
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
@@ -86,12 +84,11 @@ export const loader = dashboardLoader(
8684
throw new Response("Not Found", { status: 404 });
8785
}
8886

89-
// Display only: hidden regions stay listed for admins, unless they've asked
90-
// to see the dashboard the way the impersonated user sees it. The route
91-
// builder's `user` doesn't carry the flag, so read it off the session with
92-
// the same strict condition every other consumer uses.
93-
const { isViewingAsUser } = await getImpersonationState(request, user.id);
94-
const isAdmin = hasAdminDisplayAccess({ ...user, isViewingAsUser });
87+
// Raw impersonation, not `hasAdminDisplayAccess`: this list is the bulk
88+
// action's "override region" picker, so it decides which region a submitted
89+
// bulk replay can re-route runs to. "View as user" only changes what is
90+
// shown.
91+
const isAdmin = user.admin || user.isImpersonating;
9592

9693
const presenter = new CreateBulkActionPresenter();
9794
const [data, regionsResult] = await Promise.all([

apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { $replica, prisma } from "~/db.server";
88
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
99
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
1010
import { logger } from "~/services/logger.server";
11-
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
11+
import { requireUser } from "~/services/session.server";
1212
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
1313
import { sortEnvironments } from "~/utils/environmentSort";
1414
import { v3RunSpanPath } from "~/utils/pathBuilder";
@@ -166,10 +166,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
166166

167167
const [payload, regionsResult] = await Promise.all([
168168
prettyPrintPacket(run.payload, run.payloadType),
169+
// Raw impersonation, not `hasAdminDisplayAccess`: this list is the replay
170+
// dialog's region picker, so it decides what the submitted form can select
171+
// — and the run's own region is returned separately, so dropping entries
172+
// can leave the current value off the list. "View as user" only changes
173+
// what is shown.
169174
new RegionsPresenter().call({
170175
userId,
171176
projectSlug,
172-
isAdmin: hasAdminDisplayAccess(user),
177+
isAdmin: user.admin || user.isImpersonating,
173178
}),
174179
]);
175180

apps/webapp/app/services/session.server.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,18 @@ export async function requireUser(request: Request) {
162162
* narrow a real security boundary — authorization stays on `user.admin`, the
163163
* route builder's `authorization` block and the per-feature access checks.
164164
*
165-
* The rule: the toggle changes what is *shown*, never what is *permitted*. So
166-
* call this from loaders and rendering only. Anything deciding what a request
167-
* may do — an action, a service call that enforces something — stays on raw
168-
* `user.admin || user.isImpersonating`, or the admin's own form submissions
169-
* start failing the moment they flip the toggle on.
165+
* The rule: the toggle changes what is *shown*, never what is *permitted or
166+
* what happens*. "Shown" is narrow — rendering and read-only listings. A badge,
167+
* a debug tooltip, an extra table column, a control that is merely hidden while
168+
* the handler behind it re-checks the raw flags: all fine.
169+
*
170+
* It does NOT extend to a value a request handler reads, nor to the option set
171+
* of a control that submits. An option list feeding a mutation is not display:
172+
* shrinking it changes what a submitted form is able to do, and where the
173+
* current value is not among the remaining options it can change which value
174+
* the form carries. Those stay on raw `user.admin || user.isImpersonating`, or
175+
* the admin's own submissions start behaving differently — or failing — the
176+
* moment they flip the toggle on.
170177
*/
171178
export function hasAdminDisplayAccess(user: {
172179
admin: boolean;

apps/webapp/test/runGetRoutes.replicaLag.guard.test.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,14 @@ vi.mock("~/v3/runStore.server", () => ({
109109
),
110110
}));
111111

112-
// Auth/session (orthogonal): fixed user id. `hasAdminDisplayAccess` mirrors the real predicate
113-
// rather than importing it: a factory mock replaces the whole module, and pulling the original in
114-
// would evaluate session.server's server-only import graph, which this test deliberately keeps out.
115-
// Only the replay loader's region-picker flag reads it, and RegionsPresenter is mocked below, so the
116-
// value is orthogonal to every proof here — it just has to exist, or the mock throws on access.
112+
// Auth/session (orthogonal): fixed user id. This factory replaces the whole module, so it has to
113+
// return every export the route graph under test actually reaches — accessing one it does not
114+
// define throws. Keep it to that set; importing the original would evaluate session.server's
115+
// server-only import graph, which this test deliberately keeps out.
117116
vi.mock("~/services/session.server", () => ({
118117
requireUserId: async () => authUser.id,
119118
requireUser: async () => authUser,
120119
getUserId: async () => authUser.id,
121-
hasAdminDisplayAccess: (user: {
122-
admin: boolean;
123-
isImpersonating: boolean;
124-
isViewingAsUser: boolean;
125-
}) => (user.admin || user.isImpersonating) && !user.isViewingAsUser,
126120
}));
127121

128122
// Control-plane env resolution (a downstream cross-DB lookup, orthogonal to the run-store read):

0 commit comments

Comments
 (0)