Skip to content

Commit dc118eb

Browse files
committed
fix(webapp): gate run-detail Replay and Cancel buttons on write:runs
Surface write:runs as canReplayRun/canCancelRun from the run-detail loader (via the injected RBAC ability) and disable the Replay and Cancel controls with an explanatory tooltip when the role lacks it. Display only; the cancel/replay action routes are the enforcement boundary.
1 parent 47c94c0 commit dc118eb

1 file changed

Lines changed: 39 additions & 14 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam

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

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
104104
import { logger } from "~/services/logger.server";
105105
import { getResizableSnapshot } from "~/services/resizablePanel.server";
106106
import { requireUserId } from "~/services/session.server";
107+
import { rbac } from "~/services/rbac.server";
107108
import { cn } from "~/utils/cn";
108109
import { lerp } from "~/utils/lerp";
109110
import {
@@ -189,7 +190,10 @@ async function getRunsListFromTableState({
189190
return null;
190191
}
191192

192-
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
193+
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
194+
project.organizationId,
195+
"standard"
196+
);
193197
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
194198
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
195199
userId,
@@ -253,6 +257,15 @@ async function getRunsListFromTableState({
253257
}
254258
}
255259

260+
// Display-only write:runs flags for the Replay/Cancel controls. The cancel
261+
// and replay action routes enforce write:runs independently; this mirrors the
262+
// result so the buttons disable for roles that lack it. Permissive in OSS.
263+
async function runWritePermissions(request: Request, userId: string, organizationId: string) {
264+
const auth = await rbac.authenticateSession(request, { userId, organizationId });
265+
const canWriteRun = auth.ok ? auth.ability.can("write", { type: "runs" }) : true;
266+
return { canReplayRun: canWriteRun, canCancelRun: canWriteRun };
267+
}
268+
256269
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
257270
const userId = await requireUserId(request);
258271
const impersonationId = await getImpersonationId(request);
@@ -318,11 +331,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
318331
// Skip on `_data` requests (Remix data fetches): they're
319332
// client-driven follow-ups and the client URL is what matters,
320333
// not the loader's view of it.
321-
if (
322-
!url.searchParams.has("span") &&
323-
!url.searchParams.has("_data") &&
324-
buffered.run.spanId
325-
) {
334+
if (!url.searchParams.has("span") && !url.searchParams.has("_data") && buffered.run.spanId) {
326335
url.searchParams.set("span", buffered.run.spanId);
327336
throw redirect(url.pathname + "?" + url.searchParams.toString());
328337
}
@@ -336,6 +345,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
336345
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
337346
resizable: { parent, tree },
338347
runsList: null,
348+
...(await runWritePermissions(request, userId, buffered.run.environment.organizationId)),
339349
});
340350
}
341351

@@ -347,11 +357,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
347357
// block in the buffered fallback above — the sibling redirect routes
348358
// do this, but direct navigation to the canonical project-scoped URL
349359
// never hits them, leaving the right detail panel collapsed.
350-
if (
351-
!url.searchParams.has("span") &&
352-
!url.searchParams.has("_data") &&
353-
result.run.spanId
354-
) {
360+
if (!url.searchParams.has("span") && !url.searchParams.has("_data") && result.run.spanId) {
355361
url.searchParams.set("span", result.run.spanId);
356362
throw redirect(url.pathname + "?" + url.searchParams.toString());
357363
}
@@ -378,6 +384,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
378384
tree,
379385
},
380386
runsList,
387+
...(await runWritePermissions(request, userId, result.run.environment.organizationId)),
381388
});
382389
};
383390

@@ -417,8 +424,15 @@ async function tryMollifiedRunFallback(args: {
417424
type LoaderData = SerializeFrom<typeof loader>;
418425

419426
export default function Page() {
420-
const { run, trace, maximumLiveReloadingSetting, runsList, resizable } =
421-
useLoaderData<typeof loader>();
427+
const {
428+
run,
429+
trace,
430+
maximumLiveReloadingSetting,
431+
runsList,
432+
resizable,
433+
canReplayRun,
434+
canCancelRun,
435+
} = useLoaderData<typeof loader>();
422436
const organization = useOrganization();
423437
const project = useProject();
424438
const environment = useEnvironment();
@@ -500,6 +514,8 @@ export default function Page() {
500514
LeadingIcon={ArrowUturnLeftIcon}
501515
shortcut={{ key: "R" }}
502516
className="pr-2"
517+
disabled={!canReplayRun}
518+
tooltip={canReplayRun ? undefined : "You don't have permission to replay runs"}
503519
>
504520
Replay run
505521
</Button>
@@ -518,6 +534,7 @@ export default function Page() {
518534
{run.isFinished ? null : (
519535
<ControlledCancelRunDialog
520536
key={`cancel-${run.friendlyId}`}
537+
canCancel={canCancelRun}
521538
runFriendlyId={run.friendlyId}
522539
redirectPath={v3RunSpanPath(
523540
organization,
@@ -699,15 +716,23 @@ function TraceView({
699716
function ControlledCancelRunDialog({
700717
runFriendlyId,
701718
redirectPath,
719+
canCancel,
702720
}: {
703721
runFriendlyId: string;
704722
redirectPath: string;
723+
canCancel: boolean;
705724
}) {
706725
const [open, setOpen] = useState(false);
707726
return (
708727
<Dialog open={open} onOpenChange={setOpen}>
709728
<DialogTrigger asChild>
710-
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
729+
<Button
730+
variant="danger/small"
731+
LeadingIcon={StopCircleIcon}
732+
shortcut={{ key: "C" }}
733+
disabled={!canCancel}
734+
tooltip={canCancel ? undefined : "You don't have permission to cancel runs"}
735+
>
711736
Cancel run…
712737
</Button>
713738
</DialogTrigger>

0 commit comments

Comments
 (0)