Skip to content

Commit d640782

Browse files
committed
IAM auth support
Delegate the object store client to aws4fetch (existing) of the official AWS S3Client for IAM login.
1 parent ba2273f commit d640782

File tree

4 files changed

+254
-162
lines changed

4 files changed

+254
-162
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add IAM role-based auth support for object stores (no access keys required).

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ const EnvironmentSchema = z
344344
.default(60 * 1000 * 15), // 15 minutes
345345

346346
OBJECT_STORE_BASE_URL: z.string().optional(),
347+
OBJECT_STORE_BUCKET: z.string().optional(),
347348
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
348349
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
349350
OBJECT_STORE_REGION: z.string().optional(),

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

Lines changed: 78 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { AwsClient } from "aws4fetch";
21
import { env } from "~/env.server";
32
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
43
import { logger } from "~/services/logger.server";
54
import { singleton } from "~/utils/singleton";
65
import { startActiveSpan } from "./tracer.server";
76
import { IOPacket } from "@trigger.dev/core/v3";
7+
import { ObjectStoreClient, type ObjectStoreClientConfig } from "./objectStoreClient.server";
88

99
/**
1010
* Parsed storage URI with optional protocol prefix
@@ -49,106 +49,78 @@ export function formatStorageUri(path: string, protocol?: string): string {
4949
}
5050

5151
/**
52-
* Object storage client configuration
52+
* Get object storage configuration for a given protocol.
53+
* Returns a config if baseUrl is set, even without explicit credentials —
54+
* in that case the AWS credential chain (ECS task role, EC2 IMDS, etc.) is used,
55+
* and OBJECT_STORE_BUCKET must also be set.
5356
*/
54-
type ObjectStoreConfig = {
55-
baseUrl: string;
56-
accessKeyId: string;
57-
secretAccessKey: string;
58-
region?: string;
59-
service?: string;
60-
};
61-
62-
/**
63-
* Get object storage configuration for a given protocol
64-
* @param protocol Protocol name (e.g., "s3", "r2"), or undefined for default
65-
* @returns Configuration object or undefined if not configured
66-
*/
67-
function getObjectStoreConfig(protocol?: string): ObjectStoreConfig | undefined {
57+
function getObjectStoreConfig(protocol?: string): ObjectStoreClientConfig | undefined {
6858
if (protocol) {
6959
// Named provider (e.g., OBJECT_STORE_S3_*)
7060
const prefix = `OBJECT_STORE_${protocol.toUpperCase()}_`;
7161
const baseUrl = process.env[`${prefix}BASE_URL`];
72-
const accessKeyId = process.env[`${prefix}ACCESS_KEY_ID`];
73-
const secretAccessKey = process.env[`${prefix}SECRET_ACCESS_KEY`];
74-
const region = process.env[`${prefix}REGION`];
75-
const service = process.env[`${prefix}SERVICE`];
76-
77-
if (!baseUrl || !accessKeyId || !secretAccessKey) {
78-
return undefined;
79-
}
62+
if (!baseUrl) return undefined;
8063

8164
return {
8265
baseUrl,
83-
accessKeyId,
84-
secretAccessKey,
85-
region,
86-
service,
66+
bucket: process.env[`${prefix}BUCKET`] || undefined,
67+
accessKeyId: process.env[`${prefix}ACCESS_KEY_ID`] || undefined,
68+
secretAccessKey: process.env[`${prefix}SECRET_ACCESS_KEY`] || undefined,
69+
region: process.env[`${prefix}REGION`] || undefined,
70+
service: process.env[`${prefix}SERVICE`] || undefined,
8771
};
8872
}
8973

9074
// Default provider (backward compatible)
91-
if (
92-
!env.OBJECT_STORE_BASE_URL ||
93-
!env.OBJECT_STORE_ACCESS_KEY_ID ||
94-
!env.OBJECT_STORE_SECRET_ACCESS_KEY
95-
) {
75+
if (!env.OBJECT_STORE_BASE_URL) {
9676
return undefined;
9777
}
9878

9979
return {
10080
baseUrl: env.OBJECT_STORE_BASE_URL,
101-
accessKeyId: env.OBJECT_STORE_ACCESS_KEY_ID,
102-
secretAccessKey: env.OBJECT_STORE_SECRET_ACCESS_KEY,
103-
region: env.OBJECT_STORE_REGION,
104-
service: env.OBJECT_STORE_SERVICE,
81+
bucket: env.OBJECT_STORE_BUCKET || undefined,
82+
accessKeyId: env.OBJECT_STORE_ACCESS_KEY_ID || undefined,
83+
secretAccessKey: env.OBJECT_STORE_SECRET_ACCESS_KEY || undefined,
84+
region: env.OBJECT_STORE_REGION || undefined,
85+
service: env.OBJECT_STORE_SERVICE || undefined,
10586
};
10687
}
10788

10889
/**
109-
* Object storage clients registry
110-
* Maps protocol name to AwsClient instance
90+
* Object storage client registry. Maps protocol name to ObjectStoreClient singleton.
91+
* ObjectStoreClient internally uses either aws4fetch (static credentials) or the
92+
* AWS SDK S3Client (IAM credential chain), selected at creation time.
11193
*/
11294
const objectStoreClients = singleton(
11395
"objectStoreClients",
114-
() => new Map<string | undefined, AwsClient>()
96+
() => new Map<string, ObjectStoreClient>()
11597
);
11698

117-
/**
118-
* Get or create an object storage client for a given protocol
119-
* @param protocol Protocol name (e.g., "s3", "r2"), or undefined for default
120-
* @returns AwsClient instance or undefined if not configured
121-
*/
122-
function getObjectStoreClient(protocol?: string): AwsClient | undefined {
123-
const key = protocol;
124-
125-
if (objectStoreClients.has(key)) {
126-
return objectStoreClients.get(key);
127-
}
128-
99+
function getObjectStoreClient(protocol?: string): ObjectStoreClient | undefined {
129100
const config = getObjectStoreConfig(protocol);
130-
if (!config) {
131-
return undefined;
101+
if (!config) return undefined;
102+
103+
// Key includes baseUrl so that config changes (e.g. different containers in tests)
104+
// always produce a fresh client while production usage (stable env) is effectively
105+
// a per-protocol singleton.
106+
const cacheKey = `${protocol ?? "default"}:${config.baseUrl}`;
107+
if (objectStoreClients.has(cacheKey)) {
108+
return objectStoreClients.get(cacheKey);
132109
}
133110

134-
const client = new AwsClient({
135-
accessKeyId: config.accessKeyId,
136-
secretAccessKey: config.secretAccessKey,
137-
region: config.region,
138-
// We now set the default value to "s3" in the schema to enhance interoperability with various S3-compatible services.
139-
// Setting this env var to an empty string will restore the previous behavior of not setting a service.
140-
service: config.service ? config.service : undefined,
141-
});
142-
143-
objectStoreClients.set(key, client);
111+
const client = ObjectStoreClient.create(config);
112+
objectStoreClients.set(cacheKey, client);
144113
return client;
145114
}
146115

147116
export function hasObjectStoreClient(): boolean {
148-
return getObjectStoreClient() !== undefined || getObjectStoreClient(env.OBJECT_STORE_DEFAULT_PROTOCOL) !== undefined;
117+
const defaultConfig = getObjectStoreConfig();
118+
const protocolConfig = env.OBJECT_STORE_DEFAULT_PROTOCOL
119+
? getObjectStoreConfig(env.OBJECT_STORE_DEFAULT_PROTOCOL)
120+
: undefined;
121+
return !!(defaultConfig || protocolConfig);
149122
}
150123

151-
152124
export async function uploadPacketToObjectStore(
153125
filename: string,
154126
data: ReadableStream | string,
@@ -161,39 +133,21 @@ export async function uploadPacketToObjectStore(
161133
const client = getObjectStoreClient(protocol);
162134

163135
if (!client) {
164-
throw new Error(
165-
`Object store credentials are not set for protocol: ${protocol || "default"}`
166-
);
167-
}
168-
169-
const config = getObjectStoreConfig(protocol);
170-
if (!config?.baseUrl) {
171-
throw new Error(`Object store base URL is not set for protocol: ${protocol || "default"}`);
136+
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
172137
}
173138

174139
span.setAttributes({
175140
projectRef: environment.project.externalRef,
176141
environmentSlug: environment.slug,
177-
filename: filename,
142+
filename,
178143
protocol: protocol || "default",
179144
});
180145

181-
const url = new URL(config.baseUrl);
182-
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
146+
const key = `packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
183147

184-
logger.debug("Uploading to object store", { url: url.href, protocol: protocol || "default" });
148+
logger.debug("Uploading to object store", { key, protocol: protocol || "default" });
185149

186-
const response = await client.fetch(url.toString(), {
187-
method: "PUT",
188-
headers: {
189-
"Content-Type": contentType,
190-
},
191-
body: data,
192-
});
193-
194-
if (!response.ok) {
195-
throw new Error(`Failed to upload output to ${url}: ${response.statusText}`);
196-
}
150+
await client.putObject(key, data, contentType);
197151

198152
// Return filename with protocol prefix if specified
199153
return formatStorageUri(filename, protocol);
@@ -222,14 +176,7 @@ export async function downloadPacketFromObjectStore(
222176
const client = getObjectStoreClient(protocol);
223177

224178
if (!client) {
225-
throw new Error(
226-
`Object store credentials are not set for protocol: ${protocol || "default"}`
227-
);
228-
}
229-
230-
const config = getObjectStoreConfig(protocol);
231-
if (!config?.baseUrl) {
232-
throw new Error(`Object store base URL is not set for protocol: ${protocol || "default"}`);
179+
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
233180
}
234181

235182
span.setAttributes({
@@ -239,28 +186,13 @@ export async function downloadPacketFromObjectStore(
239186
protocol: protocol || "default",
240187
});
241188

242-
const url = new URL(config.baseUrl);
243-
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${path}`;
189+
const key = `packets/${environment.project.externalRef}/${environment.slug}/${path}`;
244190

245-
logger.debug("Downloading from object store", {
246-
url: url.href,
247-
protocol: protocol || "default",
248-
});
249-
250-
const response = await client.fetch(url.toString());
251-
252-
if (!response.ok) {
253-
throw new Error(`Failed to download input from ${url}: ${response.statusText}`);
254-
}
191+
logger.debug("Downloading from object store", { key, protocol: protocol || "default" });
255192

256-
const data = await response.text();
193+
const data = await client.getObject(key);
257194

258-
const rawPacket = {
259-
data,
260-
dataType: "application/json",
261-
};
262-
263-
return rawPacket;
195+
return { data, dataType: "application/json" };
264196
});
265197
}
266198

@@ -276,39 +208,26 @@ export async function uploadDataToObjectStore(
276208
const client = getObjectStoreClient(protocol);
277209

278210
if (!client) {
279-
throw new Error(
280-
`Object store credentials are not set for protocol: ${protocol || "default"}`
281-
);
211+
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
282212
}
283213

284214
const config = getObjectStoreConfig(protocol);
285-
if (!config?.baseUrl) {
286-
throw new Error(`Object store base URL is not set for protocol: ${protocol || "default"}`);
287-
}
288215

289216
span.setAttributes({
290217
prefix,
291218
filename,
292219
protocol: protocol || "default",
293220
});
294221

295-
const url = new URL(config.baseUrl);
296-
url.pathname = prefix ? `/${prefix}/${filename}` : `/${filename}`;
222+
const key = prefix ? `${prefix}/${filename}` : filename;
297223

298-
logger.debug("Uploading to object store", { url: url.href, protocol: protocol || "default" });
224+
logger.debug("Uploading to object store", { key, protocol: protocol || "default" });
299225

300-
const response = await client.fetch(url.toString(), {
301-
method: "PUT",
302-
headers: {
303-
"Content-Type": contentType,
304-
},
305-
body: data,
306-
});
307-
308-
if (!response.ok) {
309-
throw new Error(`Failed to upload data to ${url}: ${response.statusText}`);
310-
}
226+
await client.putObject(key, data, contentType);
311227

228+
// Return a full URL for the caller (reconstruct from baseUrl + key)
229+
const url = new URL(config!.baseUrl);
230+
url.pathname = `/${key}`;
312231
return url.href;
313232
});
314233
}
@@ -334,44 +253,41 @@ export async function generatePresignedRequest(
334253
if (!config?.baseUrl) {
335254
return {
336255
success: false,
337-
error: `Object store base URL is not set for protocol: ${protocol || "default"}`,
256+
error: `Object store is not configured for protocol: ${protocol || "default"}`,
338257
};
339258
}
340259

341260
const client = getObjectStoreClient(protocol);
342261
if (!client) {
343262
return {
344263
success: false,
345-
error: `Object store client is not initialized for protocol: ${protocol || "default"}`,
264+
error: `Object store is not configured for protocol: ${protocol || "default"}`,
346265
};
347266
}
348267

349-
const url = new URL(config.baseUrl);
350-
url.pathname = `/packets/${projectRef}/${envSlug}/${path}`;
351-
url.searchParams.set("X-Amz-Expires", "300"); // 5 minutes
268+
const key = `packets/${projectRef}/${envSlug}/${path}`;
352269

353-
const signed = await client.sign(
354-
new Request(url, {
355-
method,
356-
}),
357-
{
358-
aws: { signQuery: true },
359-
}
360-
);
361-
362-
logger.debug("Generated presigned URL", {
363-
url: signed.url,
364-
headers: Object.fromEntries(signed.headers),
365-
projectRef,
366-
envSlug,
367-
filename,
368-
protocol: protocol || "default",
369-
});
270+
try {
271+
const url = await client.presign(key, method, 300); // 5 minutes
370272

371-
return {
372-
success: true,
373-
request: signed,
374-
};
273+
logger.debug("Generated presigned URL", {
274+
url,
275+
projectRef,
276+
envSlug,
277+
filename,
278+
protocol: protocol || "default",
279+
});
280+
281+
return {
282+
success: true,
283+
request: new Request(url, { method }),
284+
};
285+
} catch (error) {
286+
return {
287+
success: false,
288+
error: `Failed to generate presigned URL: ${error instanceof Error ? error.message : String(error)}`,
289+
};
290+
}
375291
}
376292

377293
export async function generatePresignedUrl(

0 commit comments

Comments
 (0)