Skip to content

Commit fb4ad5a

Browse files
committed
fix(webapp): enforce write:runs on bulk action create and abort
Migrate the bulk-action create/replay route and the bulk-action abort route to dashboardLoader/dashboardAction with a write:runs authorization block, resolving the org for the auth scope from the URL slug. Surface canCreateBulkAction and canAbort display flags via checkPermissions and gate the inspector's Cancel/Replay trigger and the Abort button. Tenancy queries (findProjectBySlug/findEnvironmentBySlug) are unchanged.
1 parent 10172bb commit fb4ad5a

2 files changed

Lines changed: 210 additions & 142 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx

Lines changed: 101 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ArrowPathIcon } from "@heroicons/react/20/solid";
22
import { Form } from "@remix-run/react";
3-
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
43
import { tryCatch } from "@trigger.dev/core";
54
import type { BulkActionType } from "@trigger.dev/database";
65
import { motion } from "framer-motion";
@@ -10,6 +9,7 @@ import { ExitIcon } from "~/assets/icons/ExitIcon";
109
import { RunsIcon } from "~/assets/icons/RunsIcon";
1110
import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary";
1211
import { Button, LinkButton } from "~/components/primitives/Buttons";
12+
import { PermissionButton } from "~/components/primitives/PermissionButton";
1313
import { CopyableText } from "~/components/primitives/CopyableText";
1414
import { DateTime } from "~/components/primitives/DateTime";
1515
import { Header2 } from "~/components/primitives/Headers";
@@ -26,8 +26,10 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
2626
import { findProjectBySlug } from "~/models/project.server";
2727
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
2828
import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server";
29+
import { $replica } from "~/db.server";
2930
import { logger } from "~/services/logger.server";
30-
import { requireUserId } from "~/services/session.server";
31+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
32+
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
3133
import { cn } from "~/utils/cn";
3234
import { formatNumber } from "~/utils/numberFormatter";
3335
import {
@@ -43,96 +45,121 @@ const BulkActionParamSchema = EnvironmentParamSchema.extend({
4345
bulkActionParam: z.string(),
4446
});
4547

46-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
47-
const userId = await requireUserId(request);
48+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
49+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
50+
return org?.id ?? null;
51+
}
4852

49-
const { organizationSlug, projectParam, envParam, bulkActionParam } =
50-
BulkActionParamSchema.parse(params);
53+
export const loader = dashboardLoader(
54+
{
55+
params: BulkActionParamSchema,
56+
context: async (params) => {
57+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
58+
return organizationId ? { organizationId } : {};
59+
},
60+
authorization: { action: "read", resource: { type: "runs" } },
61+
},
62+
async ({ params, user, ability }) => {
63+
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
5164

52-
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
53-
if (!project) {
54-
throw new Response("Not Found", { status: 404 });
55-
}
65+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
66+
if (!project) {
67+
throw new Response("Not Found", { status: 404 });
68+
}
5669

57-
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
58-
if (!environment) {
59-
throw new Response("Not Found", { status: 404 });
60-
}
70+
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
71+
if (!environment) {
72+
throw new Response("Not Found", { status: 404 });
73+
}
6174

62-
try {
63-
const presenter = new BulkActionPresenter();
64-
const [error, data] = await tryCatch(
65-
presenter.call({
66-
environmentId: environment.id,
67-
bulkActionId: bulkActionParam,
68-
})
69-
);
75+
try {
76+
const presenter = new BulkActionPresenter();
77+
const [error, data] = await tryCatch(
78+
presenter.call({
79+
environmentId: environment.id,
80+
bulkActionId: bulkActionParam,
81+
})
82+
);
7083

71-
if (error) {
72-
throw new Error(error.message);
73-
}
84+
if (error) {
85+
throw new Error(error.message);
86+
}
87+
88+
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
7489

75-
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
90+
// Display flag for the Abort button — the action enforces write:runs.
91+
const { canAbort } = checkPermissions(ability, {
92+
canAbort: { action: "write", resource: { type: "runs" } },
93+
});
7694

77-
return typedjson({ bulkAction: data, autoReloadPollIntervalMs });
78-
} catch (error) {
79-
console.error(error);
80-
throw new Response(undefined, {
81-
status: 400,
82-
statusText: "Something went wrong, if this problem persists please contact support.",
83-
});
95+
return typedjson({ bulkAction: data, autoReloadPollIntervalMs, canAbort });
96+
} catch (error) {
97+
console.error(error);
98+
throw new Response(undefined, {
99+
status: 400,
100+
statusText: "Something went wrong, if this problem persists please contact support.",
101+
});
102+
}
84103
}
85-
};
104+
);
86105

87-
export const action = async ({ request, params }: ActionFunctionArgs) => {
88-
const userId = await requireUserId(request);
89-
const { organizationSlug, projectParam, envParam, bulkActionParam } =
90-
BulkActionParamSchema.parse(params);
106+
export const action = dashboardAction(
107+
{
108+
params: BulkActionParamSchema,
109+
context: async (params) => {
110+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
111+
return organizationId ? { organizationId } : {};
112+
},
113+
authorization: { action: "write", resource: { type: "runs" } },
114+
},
115+
async ({ request, params, user }) => {
116+
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
91117

92-
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
93-
if (!project) {
94-
throw new Response("Not Found", { status: 404 });
95-
}
118+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
119+
if (!project) {
120+
throw new Response("Not Found", { status: 404 });
121+
}
96122

97-
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
98-
if (!environment) {
99-
throw new Response("Not Found", { status: 404 });
100-
}
123+
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
124+
if (!environment) {
125+
throw new Response("Not Found", { status: 404 });
126+
}
127+
128+
const service = new BulkActionService();
129+
const [error, result] = await tryCatch(service.abort(bulkActionParam, environment.id));
101130

102-
const service = new BulkActionService();
103-
const [error, result] = await tryCatch(service.abort(bulkActionParam, environment.id));
131+
if (error) {
132+
logger.error("Failed to abort bulk action", {
133+
error,
134+
});
104135

105-
if (error) {
106-
logger.error("Failed to abort bulk action", {
107-
error,
108-
});
136+
return redirectWithErrorMessage(
137+
v3BulkActionPath(
138+
{ slug: organizationSlug },
139+
{ slug: projectParam },
140+
{ slug: envParam },
141+
{ friendlyId: bulkActionParam }
142+
),
143+
request,
144+
`Failed to abort bulk action: ${error.message}`
145+
);
146+
}
109147

110-
return redirectWithErrorMessage(
148+
return redirectWithSuccessMessage(
111149
v3BulkActionPath(
112150
{ slug: organizationSlug },
113151
{ slug: projectParam },
114152
{ slug: envParam },
115153
{ friendlyId: bulkActionParam }
116154
),
117155
request,
118-
`Failed to abort bulk action: ${error.message}`
156+
"Bulk action aborted"
119157
);
120158
}
121-
122-
return redirectWithSuccessMessage(
123-
v3BulkActionPath(
124-
{ slug: organizationSlug },
125-
{ slug: projectParam },
126-
{ slug: envParam },
127-
{ friendlyId: bulkActionParam }
128-
),
129-
request,
130-
"Bulk action aborted"
131-
);
132-
};
159+
);
133160

134161
export default function Page() {
135-
const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData<typeof loader>();
162+
const { bulkAction, autoReloadPollIntervalMs, canAbort } = useTypedLoaderData<typeof loader>();
136163
const organization = useOrganization();
137164
const project = useProject();
138165
const environment = useEnvironment();
@@ -162,9 +189,14 @@ export default function Page() {
162189
<BulkActionStatusCombo status={bulkAction.status} />
163190
{bulkAction.status === "PENDING" ? (
164191
<Form method="post">
165-
<Button type="submit" variant="danger/small">
192+
<PermissionButton
193+
type="submit"
194+
variant="danger/small"
195+
hasPermission={canAbort}
196+
noPermissionTooltip="You don't have permission to abort bulk actions"
197+
>
166198
Abort bulk action
167-
</Button>
199+
</PermissionButton>
168200
</Form>
169201
) : null}
170202
</div>

0 commit comments

Comments
 (0)