Skip to content

Commit 478d9ec

Browse files
authored
feat(trigger): reach AWS from trigger.dev runs through workload identity (#9578)
* feat(trigger): reach AWS from trigger.dev runs through workload identity Trigger.dev runs reached S3, AppConfig, CloudWatch and hosted Bedrock with the app IAM user's static keys. With TRIGGER_AWS_ROLE_ARN set, Sim's own AWS clients now use temporary credentials for that role, exchanged for the run's Trigger.dev workload-identity token (SDK 4.8.3). One session serves the worker process, refreshed at each attempt's start so the post-run metrics flush keeps valid credentials. Static keys and the default chain are unchanged for ECS and self-hosted deployments. * chore(deps): allow trigger.dev 4.8.3 ahead of the release-age window 4.8.3 is the first release with workload identity. The exclusion is temporary and comes out once it clears the 7-day threshold on 2026-11-06. Hosted-key metrics now key off Sim Cloud itself instead of the presence of static AWS keys, so removing those keys never silences them. * refactor(trigger): run the per-attempt setup through one module The init hook loads app modules by relative path, which the source-convention ratchet caps. Warming Redis and refreshing workload-identity credentials now sit behind prepareTriggerAttempt, so init still imports one module. * fix(aws): keep the default chain outside workload identity and cover ses getAwsCredentials returned explicit static keys when present, which dropped AWS_SESSION_TOKEN for clients that used to reach it through the default chain (Bedrock, CloudWatch). It now returns the workload-identity provider or undefined, so every non-Trigger path resolves exactly as before. The SES email client reads it too, so worker email keeps working once static keys leave Trigger.dev. The release-age comment records why the exclusions are bare names. * test(uploads): give the presigner credentials where the client now reads them The S3 client resolves credentials through the SDK default chain, which reads process.env, so the presigner test stubs the keys there instead of the env module mock. * fix(aws): serve unexpired workload credentials when an exchange fails Outside an executing attempt no workload-identity token can be minted, so a post-run metrics flush near the refresh window failed even though the cached credentials were still valid. A failed exchange now keeps serving the cached credentials until a minute before they expire.
1 parent 1a14c87 commit 478d9ec

16 files changed

Lines changed: 196 additions & 59 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ jobs:
292292
if [ -n "$TRIGGER_BRANCH" ]; then
293293
TARGET_ARGS+=(--branch "$TRIGGER_BRANCH")
294294
fi
295-
bunx trigger.dev@4.8.2 deploy "${TARGET_ARGS[@]}" --skip-promotion
295+
bunx trigger.dev@4.8.3 deploy "${TARGET_ARGS[@]}" --skip-promotion
296296
297297
- name: Validate deployment version output
298298
env:
@@ -643,7 +643,7 @@ jobs:
643643
if [ -n "$TRIGGER_BRANCH" ]; then
644644
TARGET_ARGS+=(--branch "$TRIGGER_BRANCH")
645645
fi
646-
bunx trigger.dev@4.8.2 promote "$VERSION" "${TARGET_ARGS[@]}"
646+
bunx trigger.dev@4.8.3 promote "$VERSION" "${TARGET_ARGS[@]}"
647647
648648
# Production only, once the new app serves traffic: submit the library and comparison URLs this
649649
# push changed to IndexNow. Best effort: nothing depends on it, and a failure never fails the run.

‎.github/workflows/trigger-dev.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
set -euo pipefail
4242
: "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}"
4343
: "${TRIGGER_PROJECT_ID:?TRIGGER_PROJECT_ID must be configured}"
44-
bunx trigger.dev@4.8.2 deploy --env preview --branch dev-sim --skip-promotion
44+
bunx trigger.dev@4.8.3 deploy --env preview --branch dev-sim --skip-promotion
4545
4646
- name: Promote current dev preview
4747
working-directory: ./apps/sim
@@ -61,4 +61,4 @@ jobs:
6161
echo "::notice::Skipping superseded dev task version $VERSION"
6262
exit 0
6363
fi
64-
bunx trigger.dev@4.8.2 promote "$VERSION" --env preview --branch dev-sim
64+
bunx trigger.dev@4.8.3 promote "$VERSION" --env preview --branch dev-sim

‎apps/sim/lib/core/config/appconfig.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createLogger } from '@sim/logger'
88
import { getErrorMessage } from '@sim/utils/errors'
99
import { toRecordOrNull } from '@sim/utils/object'
1010
import { LRUCache } from 'lru-cache'
11-
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
11+
import { getAwsCredentials } from '@/lib/core/config/aws'
1212
import { env } from '@/lib/core/config/env'
1313

1414
const logger = createLogger('AppConfig')
@@ -121,7 +121,7 @@ function getClient(): AppConfigDataClient {
121121
if (!client) {
122122
client = new AppConfigDataClient({
123123
region: env.AWS_REGION,
124-
credentials: getAwsCredentialsFromEnv(),
124+
credentials: getAwsCredentials(),
125125
})
126126
}
127127
return client

‎apps/sim/lib/core/config/aws.ts‎

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,126 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type AWSCredentials, awsCredentialProvider } from '@trigger.dev/sdk/oidc/aws'
13
import { env } from '@/lib/core/config/env'
24

3-
interface AwsCredentials {
4-
accessKeyId: string
5-
secretAccessKey: string
5+
const logger = createLogger('AwsCredentials')
6+
7+
type AwsCredentialsProvider = () => Promise<AWSCredentials>
8+
9+
/**
10+
* Refresh workload credentials this long before they expire. It must exceed the AWS SDK's own
11+
* five-minute refresh window, or a client keeps asking for credentials this module still
12+
* considers fresh.
13+
*/
14+
const REFRESH_BEFORE_EXPIRY_MS = 10 * 60_000
15+
16+
/**
17+
* At the start of a run, refresh workload credentials that expire within this window. A token can
18+
* only be minted while an attempt is executing, and work a run leaves behind (the hosted-key
19+
* metrics flush) still calls AWS after the attempt ends.
20+
*/
21+
const RUN_START_MIN_REMAINING_MS = 30 * 60_000
22+
23+
/**
24+
* One AWS session for the whole worker process, not one per run. Every run in a deployment
25+
* assumes the same role, and Sim's AWS clients are process-wide, so a run-named session would
26+
* attribute later runs' calls to the run that minted it.
27+
*/
28+
const WORKLOAD_ROLE_SESSION_NAME = 'sim-trigger-worker'
29+
30+
let workloadProvider: AwsCredentialsProvider | null = null
31+
let currentWorkloadCredentials: AWSCredentials | null = null
32+
let pendingWorkloadCredentials: Promise<AWSCredentials> | null = null
33+
34+
function getWorkloadProvider(roleArn: string): AwsCredentialsProvider {
35+
if (!workloadProvider) {
36+
if (!env.AWS_REGION) {
37+
throw new Error('TRIGGER_AWS_ROLE_ARN is set but AWS_REGION is not; set both.')
38+
}
39+
workloadProvider = awsCredentialProvider({
40+
roleArn,
41+
region: env.AWS_REGION,
42+
roleSessionName: WORKLOAD_ROLE_SESSION_NAME,
43+
})
44+
}
45+
return workloadProvider
46+
}
47+
48+
/** Credentials this close to expiry are treated as expired, so a signed request never outlives them. */
49+
const EXPIRY_SKEW_MS = 60_000
50+
51+
function remainingMs(credentials: AWSCredentials | null): number {
52+
return credentials ? credentials.expiration.getTime() - Date.now() : 0
53+
}
54+
55+
/**
56+
* The cached workload credentials while they have more than `minRemainingMs` left, otherwise a
57+
* fresh exchange. Concurrent callers share one exchange. When the exchange fails, still-valid
58+
* cached credentials are served instead: outside an executing attempt no token can be minted, and
59+
* work a run leaves behind (the metrics flush) must not lose credentials that have not expired.
60+
*/
61+
function resolveWorkloadCredentials(
62+
roleArn: string,
63+
minRemainingMs: number
64+
): Promise<AWSCredentials> {
65+
const current = currentWorkloadCredentials
66+
if (current && remainingMs(current) > minRemainingMs) {
67+
return Promise.resolve(current)
68+
}
69+
if (!pendingWorkloadCredentials) {
70+
pendingWorkloadCredentials = getWorkloadProvider(roleArn)()
71+
.then((credentials) => {
72+
currentWorkloadCredentials = credentials
73+
return credentials
74+
})
75+
.catch((error: unknown) => {
76+
const cached = currentWorkloadCredentials
77+
if (!cached || remainingMs(cached) <= EXPIRY_SKEW_MS) throw error
78+
logger.warn('Workload-identity exchange failed; serving cached credentials until expiry', {
79+
error,
80+
remainingMs: remainingMs(cached),
81+
})
82+
return cached
83+
})
84+
.finally(() => {
85+
pendingWorkloadCredentials = null
86+
})
87+
}
88+
return pendingWorkloadCredentials
689
}
790

891
/**
9-
* Explicit AWS credentials from the environment, or `undefined` to defer to the
10-
* default AWS provider chain (the ECS task role in our deployments).
92+
* Credentials for Sim's own AWS clients: file storage (S3), AppConfig, email (SES), hosted-key
93+
* metrics and hosted Bedrock. Never for a user's integration, which brings its own keys.
1194
*
12-
* Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is
13-
* identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and
14-
* `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise
15-
* the instance/task role.
95+
* With `TRIGGER_AWS_ROLE_ARN` set (deployed Trigger.dev runs), temporary credentials for that
96+
* role, exchanged for the run's Trigger.dev workload-identity token. It takes precedence over any
97+
* static keys in the environment, so a failed exchange surfaces instead of silently falling back
98+
* to a stored key. Otherwise `undefined`: the AWS SDK's default chain, which reads
99+
* `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`, then shared profiles, then
100+
* the ECS task role.
101+
*/
102+
export function getAwsCredentials(): AwsCredentialsProvider | undefined {
103+
const roleArn = env.TRIGGER_AWS_ROLE_ARN
104+
if (!roleArn) return undefined
105+
return () => resolveWorkloadCredentials(roleArn, REFRESH_BEFORE_EXPIRY_MS)
106+
}
107+
108+
/** Whether Sim's own AWS clients have credentials of their own rather than the default chain. */
109+
export function hasExplicitAwsCredentials(): boolean {
110+
return Boolean(env.TRIGGER_AWS_ROLE_ARN || (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY))
111+
}
112+
113+
/**
114+
* Refreshes workload credentials at the start of a Trigger.dev run, while a token can be minted,
115+
* so they outlive the attempt. A no-op outside workload identity. Never throws: a failure is
116+
* logged here, and the run's first AWS call retries the exchange and fails with the cause.
16117
*/
17-
export function getAwsCredentialsFromEnv(): AwsCredentials | undefined {
18-
return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
19-
? {
20-
accessKeyId: env.AWS_ACCESS_KEY_ID,
21-
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
22-
}
23-
: undefined
118+
export async function refreshAwsCredentialsForRun(): Promise<void> {
119+
const roleArn = env.TRIGGER_AWS_ROLE_ARN
120+
if (!roleArn) return
121+
try {
122+
await resolveWorkloadCredentials(roleArn, RUN_START_MIN_REMAINING_MS)
123+
} catch (error) {
124+
logger.error('Could not exchange the workload-identity token for AWS credentials', { error })
125+
}
24126
}

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ export const env = createEnv({
364364
AWS_REGION: z.string().optional(), // AWS region for S3 buckets
365365
AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID
366366
AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key
367+
TRIGGER_AWS_ROLE_ARN: z.string().optional(), // Role Trigger.dev runs assume through workload identity, instead of static keys
367368
S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage
368369
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
369370
S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { refreshAwsCredentialsForRun } from '@/lib/core/config/aws'
2+
import { warmRedisConnection } from '@/lib/core/config/redis'
3+
4+
/**
5+
* The per-attempt setup the Trigger.dev `init` hook awaits before `run()`. Warms the shared Redis
6+
* connection, and refreshes workload-identity AWS credentials while the attempt is executing and a
7+
* token can still be minted, so work a run leaves behind (the hosted-key metrics flush) has
8+
* credentials after the attempt ends. Never throws, like each step: a throw in `init` fails the
9+
* attempt.
10+
*/
11+
export async function prepareTriggerAttempt(): Promise<void> {
12+
await Promise.all([warmRedisConnection(), refreshAwsCredentialsForRun()])
13+
}

‎apps/sim/lib/messaging/email/providers/ses.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
22
import nodemailer from 'nodemailer'
33
import type SESTransport from 'nodemailer/lib/ses-transport'
4+
import { getAwsCredentials } from '@/lib/core/config/aws'
45
import { env } from '@/lib/core/config/env'
56
import { sendViaNodemailer } from '@/lib/messaging/email/providers/send-via-nodemailer'
67
import type { MailProvider } from '@/lib/messaging/email/types'
78

89
/**
910
* AWS SES via nodemailer's SES transport using the AWS SDK v3 client.
10-
* Credentials resolve through the SDK's default provider chain (env vars,
11-
* shared config, ECS/EKS task role, EC2 instance profile, SSO).
11+
* Credentials come from `getAwsCredentials()`: workload identity in Trigger.dev
12+
* runs, otherwise the SDK's default provider chain (env vars, shared config,
13+
* ECS/EKS task role, EC2 instance profile, SSO).
1214
*/
1315
export function createSesProvider(): MailProvider | null {
1416
const region = env.AWS_SES_REGION
1517
if (!region) return null
1618

17-
const sesClient = new SESv2Client({ region })
19+
const sesClient = new SESv2Client({ region, credentials: getAwsCredentials() })
1820
const sesOptions: SESTransport.Options = {
1921
SES: { sesClient, SendEmailCommand },
2022
}

‎apps/sim/lib/monitoring/metrics.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
} from '@aws-sdk/client-cloudwatch'
3131
import { createLogger } from '@sim/logger'
3232
import { getErrorMessage } from '@sim/utils/errors'
33+
import { getAwsCredentials } from '@/lib/core/config/aws'
34+
import { isHosted } from '@/lib/core/config/env-flags'
3335

3436
const logger = createLogger('HostedKeyMetrics')
3537

@@ -44,10 +46,10 @@ type QueueReason = 'actor_requests' | 'dimension' | 'queue_position'
4446
/** `metering` marks a provider call that succeeded but could not be priced. */
4547
type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering'
4648

47-
// Deployed envs (app + trigger worker) carry static AWS creds; local dev does
48-
// not. No creds → no-op, so recorders stay always-safe to call (same contract
49-
// as the previous no-op-meter behavior).
50-
const ENABLED = Boolean(process.env.AWS_ACCESS_KEY_ID)
49+
// Hosted keys exist only on Sim Cloud, whose app and workers can always reach
50+
// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops,
51+
// so they stay always-safe to call.
52+
const ENABLED = isHosted
5153

5254
const ENVIRONMENT =
5355
process.env.OTEL_DEPLOYMENT_ENVIRONMENT ||
@@ -63,7 +65,10 @@ let handlersRegistered = false
6365

6466
function getClient(): CloudWatchClient {
6567
if (!client) {
66-
client = new CloudWatchClient({ region: process.env.AWS_REGION || 'us-east-1' })
68+
client = new CloudWatchClient({
69+
region: process.env.AWS_REGION || 'us-east-1',
70+
credentials: getAwsCredentials(),
71+
})
6772
}
6873
return client
6974
}

‎apps/sim/lib/uploads/core/setup.server.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from 'fs'
22
import { mkdir } from 'fs/promises'
33
import path, { join } from 'path'
44
import { createLogger } from '@sim/logger'
5+
import { hasExplicitAwsCredentials } from '@/lib/core/config/aws'
56
import { env } from '@/lib/core/config/env'
67
import {
78
getStorageProvider,
@@ -88,7 +89,7 @@ if (typeof process !== 'undefined') {
8889
if (!env.S3_BUCKET_NAME || !env.AWS_REGION) {
8990
logger.warn('S3 storage configuration is incomplete')
9091
logger.warn('Set S3_BUCKET_NAME and AWS_REGION for S3 storage')
91-
} else if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
92+
} else if (!hasExplicitAwsCredentials()) {
9293
logger.warn('AWS credentials are not set in environment variables')
9394
logger.warn('Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for S3 storage')
9495
} else {

‎apps/sim/lib/uploads/providers/s3/client.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
1717
import { getErrorMessage } from '@sim/utils/errors'
1818
import { generateId } from '@sim/utils/id'
19-
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
19+
import { getAwsCredentials } from '@/lib/core/config/aws'
2020
import {
2121
assertKnownSizeWithinLimit,
2222
readNodeStreamToBufferWithLimit,
@@ -62,7 +62,7 @@ export function getS3Client(): S3Client {
6262
region,
6363
endpoint: S3_CONFIG.endpoint,
6464
forcePathStyle: S3_CONFIG.forcePathStyle,
65-
credentials: getAwsCredentialsFromEnv(),
65+
credentials: getAwsCredentials(),
6666
})
6767

6868
return _s3Client

0 commit comments

Comments
 (0)