Skip to content

Commit 8c330d1

Browse files
committed
fix(webapp): enforce write:runs on single-run cancel and replay actions
Wrap the dashboard cancel and replay resource-route actions in dashboardAction with an authorization block (write:runs), resolving the run's organization for the auth scope from Postgres with a mollifier buffer fallback. The existing org-membership queries are retained as the tenancy boundary; the RBAC check layers on top and only enforces under the enterprise plugin.
1 parent 36f8ee4 commit 8c330d1

2 files changed

Lines changed: 274 additions & 226 deletions

File tree

Lines changed: 112 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { parse } from "@conform-to/zod";
2-
import { type ActionFunction, json } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
33
import { z } from "zod";
4-
import { prisma } from "~/db.server";
4+
import { $replica, prisma } from "~/db.server";
55
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
66
import { logger } from "~/services/logger.server";
7-
import { requireUserId } from "~/services/session.server";
7+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
88
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
99
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
1010

@@ -16,104 +16,130 @@ const ParamSchema = z.object({
1616
runParam: z.string(),
1717
});
1818

19-
export const action: ActionFunction = async ({ request, params }) => {
20-
const userId = await requireUserId(request);
21-
const { runParam } = ParamSchema.parse(params);
19+
// Resolve the run's organization so the RBAC auth scope can resolve the
20+
// user's role in it. The run may not be in Postgres yet (buffered during a
21+
// burst), so fall back to the buffer entry's org.
22+
async function resolveRunOrganizationId(runParam: string): Promise<string | null> {
23+
const run = await $replica.taskRun.findFirst({
24+
where: { friendlyId: runParam },
25+
select: { project: { select: { organizationId: true } } },
26+
});
27+
if (run) {
28+
return run.project.organizationId;
29+
}
2230

23-
const formData = await request.formData();
24-
const submission = parse(formData, { schema: cancelSchema });
31+
const buffer = getMollifierBuffer();
32+
const entry = buffer ? await buffer.getEntry(runParam) : null;
33+
return entry?.orgId ?? null;
34+
}
2535

26-
if (!submission.value) {
27-
return json(submission);
28-
}
36+
export const action = dashboardAction(
37+
{
38+
params: ParamSchema,
39+
context: async (params) => {
40+
const organizationId = await resolveRunOrganizationId(params.runParam);
41+
return organizationId ? { organizationId } : {};
42+
},
43+
authorization: { action: "write", resource: { type: "runs" } },
44+
},
45+
async ({ request, params, user }) => {
46+
const { runParam } = params;
47+
48+
const formData = await request.formData();
49+
const submission = parse(formData, { schema: cancelSchema });
50+
51+
if (!submission.value) {
52+
return json(submission);
53+
}
2954

30-
try {
31-
const taskRun = await prisma.taskRun.findFirst({
32-
where: {
33-
friendlyId: runParam,
34-
project: {
35-
organization: {
36-
members: {
37-
some: {
38-
userId,
55+
try {
56+
const taskRun = await prisma.taskRun.findFirst({
57+
where: {
58+
friendlyId: runParam,
59+
project: {
60+
organization: {
61+
members: {
62+
some: {
63+
userId: user.id,
64+
},
3965
},
4066
},
4167
},
4268
},
43-
},
44-
});
69+
});
4570

46-
if (taskRun) {
47-
const cancelRunService = new CancelTaskRunService();
48-
await cancelRunService.call(taskRun);
49-
return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
50-
}
71+
if (taskRun) {
72+
const cancelRunService = new CancelTaskRunService();
73+
await cancelRunService.call(taskRun);
74+
return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
75+
}
5176

52-
// PG miss — try the mollifier buffer. The customer can hit cancel
53-
// on a buffered run from the dashboard during the burst window.
54-
// Snapshot a `mark_cancelled` patch; the drainer's
55-
// bifurcation routes the run to `engine.createCancelledRun` on
56-
// next pop.
57-
const buffer = getMollifierBuffer();
58-
const entry = buffer ? await buffer.getEntry(runParam) : null;
59-
if (!entry) {
60-
submission.error = { runParam: ["Run not found"] };
61-
return json(submission);
62-
}
77+
// PG miss — try the mollifier buffer. The customer can hit cancel
78+
// on a buffered run from the dashboard during the burst window.
79+
// Snapshot a `mark_cancelled` patch; the drainer's
80+
// bifurcation routes the run to `engine.createCancelledRun` on
81+
// next pop.
82+
const buffer = getMollifierBuffer();
83+
const entry = buffer ? await buffer.getEntry(runParam) : null;
84+
if (!entry) {
85+
submission.error = { runParam: ["Run not found"] };
86+
return json(submission);
87+
}
6388

64-
// Dashboard auth: verify the requesting user is a member of the
65-
// buffered run's org. The API path scopes by env id from the
66-
// authenticated request; the dashboard route uses org-membership
67-
// because the URL doesn't carry an envId.
68-
const member = await prisma.orgMember.findFirst({
69-
where: { userId, organizationId: entry.orgId },
70-
select: { id: true },
71-
});
72-
if (!member) {
73-
submission.error = { runParam: ["Run not found"] };
74-
return json(submission);
75-
}
89+
// Tenancy: verify the requesting user is a member of the buffered
90+
// run's org. The API path scopes by env id from the authenticated
91+
// request; the dashboard route uses org-membership because the URL
92+
// doesn't carry an envId.
93+
const member = await prisma.orgMember.findFirst({
94+
where: { userId: user.id, organizationId: entry.orgId },
95+
select: { id: true },
96+
});
97+
if (!member) {
98+
submission.error = { runParam: ["Run not found"] };
99+
return json(submission);
100+
}
76101

77-
const result = await buffer!.mutateSnapshot(runParam, {
78-
type: "mark_cancelled",
79-
cancelledAt: new Date().toISOString(),
80-
cancelReason: "Canceled by user",
81-
});
82-
if (result === "applied_to_snapshot") {
83-
return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
84-
}
85-
// "not_found" or "busy" — both indicate the drainer raced us between
86-
// the getEntry check above and mutateSnapshot. On "not_found" the
87-
// entry was just popped and the PG row is in flight; on "busy" the
88-
// drainer is mid-materialisation. Either way the customer should
89-
// retry — by then the PG row exists and the regular cancel path at
90-
// the top of this action takes over.
91-
return redirectWithErrorMessage(
92-
submission.value.redirectUrl,
93-
request,
94-
"Run is materialising — retry in a moment"
95-
);
96-
} catch (error) {
97-
if (error instanceof Error) {
98-
logger.error("Failed to cancel run", {
99-
error: {
100-
name: error.name,
101-
message: error.message,
102-
stack: error.stack,
103-
},
102+
const result = await buffer!.mutateSnapshot(runParam, {
103+
type: "mark_cancelled",
104+
cancelledAt: new Date().toISOString(),
105+
cancelReason: "Canceled by user",
104106
});
107+
if (result === "applied_to_snapshot") {
108+
return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
109+
}
110+
// "not_found" or "busy" — both indicate the drainer raced us between
111+
// the getEntry check above and mutateSnapshot. On "not_found" the
112+
// entry was just popped and the PG row is in flight; on "busy" the
113+
// drainer is mid-materialisation. Either way the customer should
114+
// retry — by then the PG row exists and the regular cancel path at
115+
// the top of this action takes over.
105116
return redirectWithErrorMessage(
106117
submission.value.redirectUrl,
107118
request,
108-
`Failed to cancel run, ${error.message}`
109-
);
110-
} else {
111-
logger.error("Failed to cancel run", { error });
112-
return redirectWithErrorMessage(
113-
submission.value.redirectUrl,
114-
request,
115-
`Failed to cancel run, ${JSON.stringify(error)}`
119+
"Run is materialising — retry in a moment"
116120
);
121+
} catch (error) {
122+
if (error instanceof Error) {
123+
logger.error("Failed to cancel run", {
124+
error: {
125+
name: error.name,
126+
message: error.message,
127+
stack: error.stack,
128+
},
129+
});
130+
return redirectWithErrorMessage(
131+
submission.value.redirectUrl,
132+
request,
133+
`Failed to cancel run, ${error.message}`
134+
);
135+
} else {
136+
logger.error("Failed to cancel run", { error });
137+
return redirectWithErrorMessage(
138+
submission.value.redirectUrl,
139+
request,
140+
`Failed to cancel run, ${JSON.stringify(error)}`
141+
);
142+
}
117143
}
118144
}
119-
};
145+
);

0 commit comments

Comments
 (0)