Skip to content

Commit 370cde7

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 fb4ad5a commit 370cde7

1 file changed

Lines changed: 41 additions & 15 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: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
105105
import { logger } from "~/services/logger.server";
106106
import { getResizableSnapshot } from "~/services/resizablePanel.server";
107107
import { requireUserId } from "~/services/session.server";
108+
import { rbac } from "~/services/rbac.server";
108109
import { cn } from "~/utils/cn";
109110
import { lerp } from "~/utils/lerp";
110111
import {
@@ -190,7 +191,10 @@ async function getRunsListFromTableState({
190191
return null;
191192
}
192193

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

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

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

@@ -418,8 +425,15 @@ async function tryMollifiedRunFallback(args: {
418425
type LoaderData = SerializeFrom<typeof loader>;
419426

420427
export default function Page() {
421-
const { run, trace, maximumLiveReloadingSetting, runsList, resizable } =
422-
useLoaderData<typeof loader>();
428+
const {
429+
run,
430+
trace,
431+
maximumLiveReloadingSetting,
432+
runsList,
433+
resizable,
434+
canReplayRun,
435+
canCancelRun,
436+
} = useLoaderData<typeof loader>();
423437
const organization = useOrganization();
424438
const project = useProject();
425439
const environment = useEnvironment();
@@ -501,6 +515,8 @@ export default function Page() {
501515
LeadingIcon={ArrowUturnLeftIcon}
502516
shortcut={{ key: "R" }}
503517
className="pr-2"
518+
disabled={!canReplayRun}
519+
tooltip={canReplayRun ? undefined : "You don't have permission to replay runs"}
504520
>
505521
Replay run
506522
</Button>
@@ -519,6 +535,7 @@ export default function Page() {
519535
{run.isFinished ? null : (
520536
<ControlledCancelRunDialog
521537
key={`cancel-${run.friendlyId}`}
538+
canCancel={canCancelRun}
522539
runFriendlyId={run.friendlyId}
523540
redirectPath={v3RunSpanPath(
524541
organization,
@@ -617,7 +634,8 @@ function TraceView({
617634
? linkedRunIdBySpanId?.[selectedSpanId]
618635
: undefined;
619636
const frozenLinkedRunId = useFrozenValue(selectedSpanLinkedRunId);
620-
const displayLinkedRunId = (selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId) ?? undefined;
637+
const displayLinkedRunId =
638+
(selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId) ?? undefined;
621639

622640
return (
623641
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
@@ -699,15 +717,23 @@ function TraceView({
699717
function ControlledCancelRunDialog({
700718
runFriendlyId,
701719
redirectPath,
720+
canCancel,
702721
}: {
703722
runFriendlyId: string;
704723
redirectPath: string;
724+
canCancel: boolean;
705725
}) {
706726
const [open, setOpen] = useState(false);
707727
return (
708728
<Dialog open={open} onOpenChange={setOpen}>
709729
<DialogTrigger asChild>
710-
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
730+
<Button
731+
variant="danger/small"
732+
LeadingIcon={StopCircleIcon}
733+
shortcut={{ key: "C" }}
734+
disabled={!canCancel}
735+
tooltip={canCancel ? undefined : "You don't have permission to cancel runs"}
736+
>
711737
Cancel run…
712738
</Button>
713739
</DialogTrigger>

0 commit comments

Comments
 (0)