|
1 | 1 | import { json } from "@remix-run/server-runtime"; |
| 2 | +import type { RbacSubject } from "@trigger.dev/plugins"; |
2 | 3 | import { z } from "zod"; |
3 | 4 | import type { ApiAuthenticationResultSuccess } from "~/services/apiAuth.server"; |
4 | 5 | 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 | +} |
6 | 39 |
|
7 | 40 | export const PacketParamsSchema = z.object({ |
8 | 41 | "*": z.string(), |
@@ -32,12 +65,22 @@ export function createPacketUploadRoute({ forceNoPrefix = false } = {}) { |
32 | 65 | resource: () => ({ type: "packets" }), |
33 | 66 | }, |
34 | 67 | }, |
35 | | - async ({ params, authentication }) => { |
| 68 | + async ({ params, authentication, subject }) => { |
36 | 69 | 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); |
37 | 80 | const signed = await generatePresignedUrl( |
38 | 81 | authentication.environment.project.externalRef, |
39 | 82 | authentication.environment.slug, |
40 | | - params["*"], |
| 83 | + filename, |
41 | 84 | "PUT", |
42 | 85 | { forceNoPrefix } |
43 | 86 | ); |
|
0 commit comments