Skip to content

Commit 42fa548

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
fix(webapp): let scoped credentials trigger with SDK-uploaded stored payloads
Triggering a task with a payload over the offload threshold from inside a running task failed with `Stored payloads require an unrestricted private API key` on SDK 4.8.2 and later. Run tokens and restricted API keys can now submit offloaded payloads. Unrestricted keys are unchanged. Mono-RevId: 4d4b916ebb9890bf5db4aa842eeb1d45d22fd591
1 parent d4354dd commit 42fa548

14 files changed

Lines changed: 248 additions & 75 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Triggering a task with a payload over the offload threshold from inside a running task works again. Run-scoped tokens and restricted API keys upload offloaded payloads into their own namespace and may only trigger with payloads they uploaded, while pointers at other runs' stored payloads or outputs are still rejected.

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { canWriteParentRun } from "~/utils/parentRunAuthorization.server";
77
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
88
import { env } from "~/env.server";
99
import { logger } from "~/services/logger.server";
10+
import {
11+
STORED_PAYLOAD_FORBIDDEN_ERROR,
12+
storedPayloadPointerPolicy,
13+
} from "~/services/packetApi.server";
1014
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
1115
import { BatchTriggerV3Service } from "~/v3/services/batchTriggerV3.server";
1216
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
@@ -29,15 +33,15 @@ const { action } = createActionApiRoute(
2933
resource: (params) => ({ type: "tasks", id: params.taskId }),
3034
},
3135
},
32-
async ({ params, headers, body, authentication, restrictedApiKey, ability }) => {
36+
async ({ params, headers, body, authentication, subject, ability }) => {
37+
const allowStoredPayload = storedPayloadPointerPolicy(subject);
3338
if (
34-
(authentication.type !== "PRIVATE" || restrictedApiKey) &&
35-
body.items.some((item) => item.options?.payloadType === "application/store")
39+
body.items.some(
40+
(item) =>
41+
item.options?.payloadType === "application/store" && !allowStoredPayload(item.payload)
42+
)
3643
) {
37-
return json(
38-
{ error: "Stored payloads require an unrestricted private API key" },
39-
{ status: 403 }
40-
);
44+
return json({ error: STORED_PAYLOAD_FORBIDDEN_ERROR }, { status: 403 });
4145
}
4246

4347
const { taskId } = params;

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { getOneTimeUseToken } from "~/services/apiAuth.server";
1414
import { logger } from "~/services/logger.server";
1515
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
1616
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
17+
import {
18+
STORED_PAYLOAD_FORBIDDEN_ERROR,
19+
storedPayloadPointerPolicy,
20+
} from "~/services/packetApi.server";
1721
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
1822
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
1923
import {
@@ -62,15 +66,10 @@ const { action, loader } = createActionApiRoute(
6266
},
6367
corsStrategy: "all",
6468
},
65-
async ({ body, headers, params, authentication, restrictedApiKey, ability }) => {
66-
if (
67-
(authentication.type !== "PRIVATE" || restrictedApiKey) &&
68-
body.options?.payloadType === "application/store"
69-
) {
70-
return json(
71-
{ error: "Stored payloads require an unrestricted private API key" },
72-
{ status: 403 }
73-
);
69+
async ({ body, headers, params, authentication, subject, ability }) => {
70+
const allowStoredPayload = storedPayloadPointerPolicy(subject);
71+
if (body.options?.payloadType === "application/store" && !allowStoredPayload(body.payload)) {
72+
return json({ error: STORED_PAYLOAD_FORBIDDEN_ERROR }, { status: 403 });
7473
}
7574

7675
const {

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { BatchTriggerTaskV2RequestBody } from "@trigger.dev/core/v3";
33
import { env } from "~/env.server";
44
import { getOneTimeUseToken } from "~/services/apiAuth.server";
55
import { logger } from "~/services/logger.server";
6+
import {
7+
STORED_PAYLOAD_FORBIDDEN_ERROR,
8+
storedPayloadPointerPolicy,
9+
} from "~/services/packetApi.server";
610
import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
711
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
812
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -40,15 +44,15 @@ const { action, loader } = createActionApiRoute(
4044
},
4145
corsStrategy: "all",
4246
},
43-
async ({ body, headers, params, authentication, restrictedApiKey }) => {
47+
async ({ body, headers, params, authentication, subject }) => {
48+
const allowStoredPayload = storedPayloadPointerPolicy(subject);
4449
if (
45-
(authentication.type !== "PRIVATE" || restrictedApiKey) &&
46-
body.items.some((item) => item.options?.payloadType === "application/store")
50+
body.items.some(
51+
(item) =>
52+
item.options?.payloadType === "application/store" && !allowStoredPayload(item.payload)
53+
)
4754
) {
48-
return json(
49-
{ error: "Stored payloads require an unrestricted private API key" },
50-
{ status: 403 }
51-
);
55+
return json({ error: STORED_PAYLOAD_FORBIDDEN_ERROR }, { status: 403 });
5256
}
5357

5458
if (!body.items.length) {

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.
88
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
99
import { getOneTimeUseToken } from "~/services/apiAuth.server";
1010
import { logger } from "~/services/logger.server";
11+
import {
12+
STORED_PAYLOAD_FORBIDDEN_ERROR,
13+
storedPayloadPointerPolicy,
14+
} from "~/services/packetApi.server";
1115
import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
1216
import {
1317
handleRequestIdempotency,
@@ -48,15 +52,15 @@ const { action, loader } = createActionApiRoute(
4852
},
4953
corsStrategy: "all",
5054
},
51-
async ({ body, headers, params, authentication, restrictedApiKey, ability }) => {
55+
async ({ body, headers, params, authentication, subject, ability }) => {
56+
const allowStoredPayload = storedPayloadPointerPolicy(subject);
5257
if (
53-
(authentication.type !== "PRIVATE" || restrictedApiKey) &&
54-
body.items.some((item) => item.options?.payloadType === "application/store")
58+
body.items.some(
59+
(item) =>
60+
item.options?.payloadType === "application/store" && !allowStoredPayload(item.payload)
61+
)
5562
) {
56-
return json(
57-
{ error: "Stored payloads require an unrestricted private API key" },
58-
{ status: 403 }
59-
);
63+
return json({ error: STORED_PAYLOAD_FORBIDDEN_ERROR }, { status: 403 });
6064
}
6165

6266
if (!body.items.length) {

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
BatchItemAuthorizationError,
1414
} from "~/utils/batchItemAuthorization";
1515
import { ServiceValidationError } from "~/v3/services/baseService.server";
16+
import { storedPayloadPointerPolicy } from "~/services/packetApi.server";
1617

1718
const ParamsSchema = z.object({
1819
batchId: z.string(),
@@ -72,8 +73,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
7273
return json({ error: authResult.error }, { status: authResult.status });
7374
}
7475

75-
const restrictedApiKey = authResult.subject.type === "apiKey" && authResult.subject.restricted;
76-
7776
// Get the request body stream
7877
const body = request.body;
7978
if (!body) {
@@ -100,7 +99,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
10099
streamToAsyncIterable(parsedStream),
101100
authResult.ability,
102101
batchId,
103-
authResult.subject.type !== "publicJWT" && !restrictedApiKey
102+
storedPayloadPointerPolicy(authResult.subject)
104103
);
105104

106105
// Process the stream

‎apps/webapp/app/services/packetApi.server.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
11
import { json } from "@remix-run/server-runtime";
2+
import type { RbacSubject } from "@trigger.dev/plugins";
23
import { z } from "zod";
34
import type { ApiAuthenticationResultSuccess } from "~/services/apiAuth.server";
45
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5-
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
6+
import {
7+
formatStorageUri,
8+
generatePresignedUrl,
9+
isPacketPathUnder,
10+
jsonPacketPresignFailure,
11+
parseStorageUri,
12+
} from "~/v3/objectStore.server";
13+
14+
export const STORED_PAYLOAD_FORBIDDEN_ERROR =
15+
"Stored payloads require an unrestricted private API key or a payload this credential uploaded";
16+
17+
/**
18+
* Object-store prefix a scoped credential's packet uploads (offloaded trigger payloads and task
19+
* outputs alike) land under, so a pointer proves who uploaded it. Unrestricted credentials upload
20+
* wherever they ask and get undefined.
21+
*/
22+
function scopedPacketNamespace(subject: RbacSubject): string | undefined {
23+
if (subject.type === "runToken") return `uploads/run/${subject.runId}`;
24+
if (subject.type === "apiKey" && subject.restricted) return `uploads/key/${subject.apiKeyId}`;
25+
return undefined;
26+
}
27+
28+
/**
29+
* Which stored-payload pointers a credential may submit when triggering. A scoped credential
30+
* (run token, restricted key) may only reference its own uploads, so it cannot create a run
31+
* that reads another run's payload or output, nor replay a pointer it learned elsewhere.
32+
*/
33+
export function storedPayloadPointerPolicy(subject: RbacSubject): (pointer: unknown) => boolean {
34+
if (subject.type === "publicJWT") return () => false;
35+
const namespace = scopedPacketNamespace(subject);
36+
if (!namespace) return () => true;
37+
return (pointer) => isPacketPathUnder(pointer, namespace);
38+
}
639

740
export const PacketParamsSchema = z.object({
841
"*": z.string(),
@@ -32,12 +65,22 @@ export function createPacketUploadRoute({ forceNoPrefix = false } = {}) {
3265
resource: () => ({ type: "packets" }),
3366
},
3467
},
35-
async ({ params, authentication }) => {
68+
async ({ params, authentication, subject }) => {
3669
assertPacketNamespaceAccess(authentication);
70+
const { protocol, path } = parseStorageUri(params["*"]);
71+
const namespace = scopedPacketNamespace(subject);
72+
// The legacy route returns no storagePath, so a relocated upload could never be referenced.
73+
if (forceNoPrefix && namespace) {
74+
return json(
75+
{ error: "Scoped credentials must upload packets via /api/v2/packets; upgrade the SDK" },
76+
{ status: 400 }
77+
);
78+
}
79+
const filename = formatStorageUri(namespace ? `${namespace}/${path}` : path, protocol);
3780
const signed = await generatePresignedUrl(
3881
authentication.environment.project.externalRef,
3982
authentication.environment.slug,
40-
params["*"],
83+
filename,
4184
"PUT",
4285
{ forceNoPrefix }
4386
);

‎apps/webapp/app/services/routeBuilders/apiBuilder.server.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { logger } from "../logger.server";
1111
import { rbac } from "../rbac.server";
1212
import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server";
1313
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
14+
import type { RbacSubject } from "@trigger.dev/plugins";
1415
import { isUserActorToken } from "@trigger.dev/rbac";
1516
import {
1617
resolveAndRecheckUserActorClaims,
@@ -114,6 +115,7 @@ async function authenticateRequestForApiBuilder(
114115
authentication: ApiAuthenticationResultSuccess;
115116
ability: RbacAbility;
116117
restrictedApiKey: boolean;
118+
subject: RbacSubject;
117119
}
118120
> {
119121
const result = await authenticateBearerWithTelemetry(request, { allowJWT });
@@ -151,6 +153,7 @@ async function authenticateRequestForApiBuilder(
151153
authentication,
152154
ability: result.ability,
153155
restrictedApiKey,
156+
subject: result.subject,
154157
};
155158
}
156159

@@ -1213,6 +1216,7 @@ type ApiKeyActionHandlerFunction<
12131216
: undefined;
12141217
authentication: ApiAuthenticationResultSuccess;
12151218
restrictedApiKey: boolean;
1219+
subject: RbacSubject;
12161220
ability: RbacAbility;
12171221
request: Request;
12181222
resource?: TResource;
@@ -1451,6 +1455,7 @@ export function createActionApiRoute<
14511455
body: parsedBody,
14521456
authentication: authenticationResult,
14531457
restrictedApiKey: authResult.restrictedApiKey,
1458+
subject: authResult.subject,
14541459
ability,
14551460
request,
14561461
resource,

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ export async function authorizedBatchItemStream(
3737
items: AsyncIterable<unknown>,
3838
ability: RbacAbility,
3939
batchId: string,
40-
allowStoredPayloads = true
40+
allowStoredPayload: (pointer: unknown) => boolean = () => true
4141
): Promise<AsyncIterable<unknown>> {
42-
const authorized = authorizeBatchItems(items, ability, batchId, allowStoredPayloads);
42+
const authorized = authorizeBatchItems(items, ability, batchId, allowStoredPayload);
4343

4444
// A batch-level write grant is authorization in its own right, so an empty
4545
// stream stays legal for credentials that own the batch: root keys (via the
@@ -79,7 +79,7 @@ export async function* authorizeBatchItems(
7979
items: AsyncIterable<unknown>,
8080
ability: RbacAbility,
8181
batchId: string,
82-
allowStoredPayloads = true
82+
allowStoredPayload: (pointer: unknown) => boolean = () => true
8383
): AsyncIterable<unknown> {
8484
const canWriteBatch = ability.can("write", { type: "batch", id: batchId });
8585

@@ -94,7 +94,10 @@ export async function* authorizeBatchItems(
9494
? item.options.payloadType
9595
: undefined;
9696

97-
if (!allowStoredPayloads && payloadType === "application/store") {
97+
const payload =
98+
typeof item === "object" && item !== null && "payload" in item ? item.payload : undefined;
99+
100+
if (payloadType === "application/store" && !allowStoredPayload(payload)) {
98101
throw new BatchItemAuthorizationError();
99102
}
100103

‎apps/webapp/app/v3/objectStore.server.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ export function formatStorageUri(path: string, protocol?: string): string {
5757

5858
export const INVALID_PACKET_STORAGE_PATH = "Invalid packet storage path";
5959

60+
/** True when a stored-payload pointer, once normalised, sits under `prefix/`. Fails closed. */
61+
export function isPacketPathUnder(pointer: unknown, prefix: string): boolean {
62+
if (typeof pointer !== "string") return false;
63+
try {
64+
return resolveSafePacketRelativePath(parseStorageUri(pointer).path).startsWith(`${prefix}/`);
65+
} catch {
66+
return false;
67+
}
68+
}
69+
6070
export type PacketPresignFailure = {
6171
success: false;
6272
error: string;

0 commit comments

Comments
 (0)