Skip to content

Commit 76031b0

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp,core): scope stored packet downloads to runs
Adds a run-scoped route for downloading stored realtime payloads and outputs, and moves the SDK onto it. Public tokens can no longer create runs that point at existing stored packets. Mono-RevId: a39d74877a8b7823333f684e006d510c48e71b0b
1 parent 3f4ac50 commit 76031b0

15 files changed

Lines changed: 441 additions & 17 deletions

‎.changeset/tidy-packets-scope.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Authorize stored realtime payload and output downloads against their owning run. Large packet hydration continues to work for run-, task-, tag-, and batch-scoped public tokens without permitting arbitrary packet reads.

‎apps/webapp/app/routes/api.v1.packets.$.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { authenticateApiRequest } from "~/services/apiAuth.server";
5+
import { logger } from "~/services/logger.server";
56
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
67
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
78

@@ -51,6 +52,15 @@ export const loader = createLoaderApiRoute(
5152
async ({ params, authentication }) => {
5253
const filename = params["*"];
5354

55+
// Public tokens should use /api/v1/runs/:runId/packets/:field, which authorizes
56+
// against the owning run. Access here is kept for clients on older SDKs.
57+
if (authentication.type === "PUBLIC_JWT") {
58+
logger.info("Legacy packet download with public token", {
59+
environmentId: authentication.environment.id,
60+
projectId: authentication.environment.projectId,
61+
});
62+
}
63+
5464
const signed = await generatePresignedUrl(
5565
authentication.environment.project.externalRef,
5666
authentication.environment.slug,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { $replica } from "~/db.server";
4+
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5+
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
6+
import { runStore } from "~/v3/runStore.server";
7+
8+
const ParamsSchema = z.object({
9+
runId: z.string(),
10+
field: z.enum(["payload", "output"]),
11+
});
12+
13+
const select = {
14+
friendlyId: true,
15+
taskIdentifier: true,
16+
runTags: true,
17+
payload: true,
18+
payloadType: true,
19+
output: true,
20+
outputType: true,
21+
batch: { select: { friendlyId: true } },
22+
} as const;
23+
24+
function storedPacketPath(
25+
run: { payload: string; payloadType: string; output: string | null; outputType: string },
26+
field: "payload" | "output"
27+
): string | null {
28+
const [data, dataType] =
29+
field === "payload" ? [run.payload, run.payloadType] : [run.output, run.outputType];
30+
return dataType === "application/store" && data ? data : null;
31+
}
32+
33+
export const loader = createLoaderApiRoute(
34+
{
35+
params: ParamsSchema,
36+
allowJWT: true,
37+
corsStrategy: "all",
38+
findResource: async (params, authentication) => {
39+
const where = {
40+
friendlyId: params.runId,
41+
runtimeEnvironmentId: authentication.environment.id,
42+
};
43+
const args = { select };
44+
45+
// The replica may not have the stored output yet for a run that just completed.
46+
let run = await runStore.findRun(where, args, $replica);
47+
if (!run || !storedPacketPath(run, params.field)) {
48+
run = await runStore.findRunOnPrimary(where, args);
49+
}
50+
if (!run) return null;
51+
52+
const packetPath = storedPacketPath(run, params.field);
53+
return packetPath ? { ...run, packetPath } : null;
54+
},
55+
authorization: {
56+
action: "read",
57+
resource: (run) =>
58+
anyResource([
59+
{ type: "runs", id: run.friendlyId },
60+
{ type: "tasks", id: run.taskIdentifier },
61+
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
62+
...(run.batch ? [{ type: "batch", id: run.batch.friendlyId }] : []),
63+
]),
64+
},
65+
},
66+
async ({ authentication, resource }) => {
67+
const signed = await generatePresignedUrl(
68+
authentication.environment.project.externalRef,
69+
authentication.environment.slug,
70+
resource.packetPath,
71+
"GET"
72+
);
73+
74+
if (!signed.success) {
75+
return jsonPacketPresignFailure(signed);
76+
}
77+
78+
return json({ presignedUrl: signed.url });
79+
}
80+
);

‎apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ const { action, loader } = createActionApiRoute(
6363
corsStrategy: "all",
6464
},
6565
async ({ body, headers, params, authentication, ability }) => {
66+
if (authentication.type !== "PRIVATE" && body.options?.payloadType === "application/store") {
67+
return json({ error: "Stored payloads require a private API key" }, { status: 403 });
68+
}
69+
6670
const {
6771
"idempotency-key": idempotencyKey,
6872
"idempotency-key-ttl": idempotencyKeyTTL,

‎apps/webapp/app/routes/api.v1.tasks.batch.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ const { action, loader } = createActionApiRoute(
4141
corsStrategy: "all",
4242
},
4343
async ({ body, headers, params, authentication }) => {
44+
if (
45+
authentication.type !== "PRIVATE" &&
46+
body.items.some((item) => item.options?.payloadType === "application/store")
47+
) {
48+
return json({ error: "Stored payloads require a private API key" }, { status: 403 });
49+
}
50+
4451
if (!body.items.length) {
4552
return json({ error: "Batch cannot be triggered with no items" }, { status: 400 });
4653
}

‎apps/webapp/app/routes/api.v2.tasks.batch.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ const { action, loader } = createActionApiRoute(
4949
corsStrategy: "all",
5050
},
5151
async ({ body, headers, params, authentication, ability }) => {
52+
if (
53+
authentication.type !== "PRIVATE" &&
54+
body.items.some((item) => item.options?.payloadType === "application/store")
55+
) {
56+
return json({ error: "Stored payloads require a private API key" }, { status: 403 });
57+
}
58+
5259
if (!body.items.length) {
5360
return json({ error: "Batch cannot be triggered with no items" }, { status: 400 });
5461
}

‎apps/webapp/app/routes/api.v3.batches.$batchId.items.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
9595
const itemsIterator = await authorizedBatchItemStream(
9696
streamToAsyncIterable(parsedStream),
9797
authResult.ability,
98-
batchId
98+
batchId,
99+
authResult.subject.type !== "publicJWT"
99100
);
100101

101102
// Process the stream

‎apps/webapp/app/utils/batchItemAuthorization.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ export function batchPublicAccessScopes(
3636
export async function authorizedBatchItemStream(
3737
items: AsyncIterable<unknown>,
3838
ability: RbacAbility,
39-
batchId: string
39+
batchId: string,
40+
allowStoredPayloads = true
4041
): Promise<AsyncIterable<unknown>> {
41-
const authorized = authorizeBatchItems(items, ability, batchId);
42+
const authorized = authorizeBatchItems(items, ability, batchId, allowStoredPayloads);
4243

4344
// A batch-level write grant is authorization in its own right, so an empty
4445
// stream stays legal for credentials that own the batch: root keys (via the
@@ -77,11 +78,26 @@ export async function authorizedBatchItemStream(
7778
export async function* authorizeBatchItems(
7879
items: AsyncIterable<unknown>,
7980
ability: RbacAbility,
80-
batchId: string
81+
batchId: string,
82+
allowStoredPayloads = true
8183
): AsyncIterable<unknown> {
8284
const canWriteBatch = ability.can("write", { type: "batch", id: batchId });
8385

8486
for await (const item of items) {
87+
const payloadType =
88+
typeof item === "object" &&
89+
item !== null &&
90+
"options" in item &&
91+
typeof item.options === "object" &&
92+
item.options !== null &&
93+
"payloadType" in item.options
94+
? item.options.payloadType
95+
: undefined;
96+
97+
if (!allowStoredPayloads && payloadType === "application/store") {
98+
throw new BatchItemAuthorizationError();
99+
}
100+
85101
const task =
86102
typeof item === "object" && item !== null && "task" in item && typeof item.task === "string"
87103
? item.task

‎apps/webapp/test/api-auth.e2e.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,120 @@ describe("API bearer auth — baseline behavior", () => {
7070
});
7171
});
7272

73+
describe("Packet download authorization", () => {
74+
// The auth harness has no object store. Reaching its configuration error proves that
75+
// resource lookup and authorization passed and the handler attempted to presign.
76+
const objectStoreError = {
77+
error: "Failed to generate presigned URL: Object store is not configured for protocol: default",
78+
};
79+
80+
async function seedStoredRuns() {
81+
const seed = await seedTestEnvironment(server.prisma);
82+
const first = await seedTestRun(server.prisma, {
83+
environmentId: seed.environment.id,
84+
projectId: seed.project.id,
85+
});
86+
const second = await seedTestRun(server.prisma, {
87+
environmentId: seed.environment.id,
88+
projectId: seed.project.id,
89+
});
90+
91+
await Promise.all([
92+
server.prisma.taskRun.update({
93+
where: { id: first.run.id },
94+
data: {
95+
payload: `${first.runFriendlyId}/payload.json`,
96+
payloadType: "application/store",
97+
output: `${first.runFriendlyId}-1/output.json`,
98+
outputType: "application/store",
99+
},
100+
}),
101+
server.prisma.taskRun.update({
102+
where: { id: second.run.id },
103+
data: {
104+
payload: `${second.runFriendlyId}/payload.json`,
105+
payloadType: "application/store",
106+
},
107+
}),
108+
]);
109+
110+
return { ...seed, first, second };
111+
}
112+
113+
it("authorizes run packet downloads against the token's run scope", async () => {
114+
const { environment, first, second } = await seedStoredRuns();
115+
const firstJwt = await generateTestJWT(environment, {
116+
scopes: [`read:runs:${first.runFriendlyId}`],
117+
});
118+
const secondJwt = await generateTestJWT(environment, {
119+
scopes: [`read:runs:${second.runFriendlyId}`],
120+
});
121+
const asFirst = { headers: { Authorization: `Bearer ${firstJwt}` } };
122+
const asSecond = { headers: { Authorization: `Bearer ${secondJwt}` } };
123+
124+
const ownPayload = await server.webapp.fetch(
125+
`/api/v1/runs/${first.runFriendlyId}/packets/payload`,
126+
asFirst
127+
);
128+
const ownOutput = await server.webapp.fetch(
129+
`/api/v1/runs/${first.runFriendlyId}/packets/output`,
130+
asFirst
131+
);
132+
const otherRun = await server.webapp.fetch(
133+
`/api/v1/runs/${second.runFriendlyId}/packets/payload`,
134+
asFirst
135+
);
136+
const inlineOutput = await server.webapp.fetch(
137+
`/api/v1/runs/${second.runFriendlyId}/packets/output`,
138+
asSecond
139+
);
140+
141+
expect(ownPayload.status).toBe(500);
142+
expect(await ownPayload.json()).toEqual(objectStoreError);
143+
expect(ownOutput.status).toBe(500);
144+
expect(await ownOutput.json()).toEqual(objectStoreError);
145+
expect(otherRun.status).toBe(403);
146+
expect(inlineOutput.status).toBe(404);
147+
});
148+
149+
it("rejects stored-payload pointers from public JWTs on trigger", async () => {
150+
const { environment } = await seedTestEnvironment(server.prisma);
151+
const jwt = await generateTestJWT(environment, { scopes: ["write:tasks:test-task"] });
152+
153+
const res = await server.webapp.fetch("/api/v1/tasks/test-task/trigger", {
154+
method: "POST",
155+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
156+
body: JSON.stringify({
157+
payload: "run_victim/payload.json",
158+
options: { payloadType: "application/store" },
159+
}),
160+
});
161+
162+
expect(res.status).toBe(403);
163+
});
164+
165+
it("rejects stored-payload pointers from public JWTs on batch trigger", async () => {
166+
const { environment } = await seedTestEnvironment(server.prisma);
167+
const jwt = await generateTestJWT(environment, { scopes: ["write:tasks:test-task"] });
168+
169+
const res = await server.webapp.fetch("/api/v2/tasks/batch", {
170+
method: "POST",
171+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
172+
body: JSON.stringify({
173+
items: [
174+
{
175+
task: "test-task",
176+
payload: "run_victim/payload.json",
177+
options: { payloadType: "application/store" },
178+
},
179+
],
180+
}),
181+
});
182+
183+
expect(res.status).toBe(403);
184+
});
185+
});
186+
73187
describe("JWT bearer auth — baseline behavior", () => {
74188
it("valid JWT on JWT-enabled route: auth passes", async () => {
75189
const { environment } = await seedTestEnvironment(server.prisma);

‎apps/webapp/test/streamBatchItemsAuthorization.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ describe("streaming batch item authorization", () => {
4545
).resolves.toHaveLength(2);
4646
});
4747

48+
it("rejects stored packet pointers from delegated batch credentials", async () => {
49+
const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"]));
50+
async function* storedPayloadItem() {
51+
yield {
52+
task: "task-a",
53+
payload: "run_victim/payload.json",
54+
options: { payloadType: "application/store" },
55+
};
56+
}
57+
58+
await expect(
59+
collect(authorizeBatchItems(storedPayloadItem(), ability, "batch_123", false))
60+
).rejects.toThrow();
61+
});
62+
4863
it("does not delegate batch-wide writes from selected-task credentials", () => {
4964
const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"]));
5065

0 commit comments

Comments
 (0)