@deployfoundation/foundation-deploy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,2416 @@
1
+ import {
2
+ CUSTOMIZATION_ARTIFACT_NAME,
3
+ LIVE_ENDPOINT_NAME,
4
+ WEB_SEARCH_TARGET,
5
+ agentImage,
6
+ agentImageTagRequired,
7
+ capabilityEnabled,
8
+ crmPolicyFingerprint,
9
+ instanceNames,
10
+ lambdaCode,
11
+ newsletterManifestFor,
12
+ parseInstanceConfig,
13
+ provisionsIntegration,
14
+ releaseImageRepositoryArn,
15
+ requiresAuthenticatedQueue,
16
+ slackCommandPrefixes
17
+ } from "./chunk-9ddxyvq2.js";
18
+
19
+ // src/stacks/agent-stack.ts
20
+ import * as cdk from "aws-cdk-lib";
21
+ import * as ecr from "aws-cdk-lib/aws-ecr";
22
+ import * as iam from "aws-cdk-lib/aws-iam";
23
+ class FoundationAgent extends cdk.Stack {
24
+ repository;
25
+ executionRole;
26
+ accessPoint;
27
+ accessPointArn;
28
+ agentRuntimeArn;
29
+ webSearchUrl;
30
+ readOnlyRole;
31
+ names;
32
+ constructor(scope, id, props) {
33
+ super(scope, id, props);
34
+ const names = instanceNames(props.instance);
35
+ this.names = names;
36
+ const display = props.instance.displayName;
37
+ const deployRuntime = String(this.node.tryGetContext("deployRuntime") ?? "true") !== "false";
38
+ const imageTag = this.node.tryGetContext("agentImageTag");
39
+ this.repository = new ecr.Repository(this, "AgentRepository", {
40
+ repositoryName: names.ecrRepo,
41
+ imageScanOnPush: true,
42
+ imageTagMutability: ecr.TagMutability.IMMUTABLE,
43
+ encryption: ecr.RepositoryEncryption.KMS,
44
+ encryptionKey: props.dataKey,
45
+ lifecycleRules: [{ maxImageCount: 5 }]
46
+ });
47
+ const accessPoint = new cdk.CfnResource(this, "FoundationMountAccessPoint", {
48
+ type: "AWS::S3Files::AccessPoint",
49
+ properties: {
50
+ FileSystemId: props.fileSystemId,
51
+ PosixUser: { Uid: "10001", Gid: "10001" },
52
+ RootDirectory: {
53
+ Path: "/sky",
54
+ CreationPermissions: { OwnerUid: "10001", OwnerGid: "10001", Permissions: "0755" }
55
+ }
56
+ }
57
+ });
58
+ accessPoint.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
59
+ this.accessPointArn = accessPoint.getAtt("AccessPointArn").toString();
60
+ new cdk.CfnOutput(this, "FoundationMountAccessPointArn", { value: this.accessPointArn });
61
+ this.accessPoint = accessPoint;
62
+ this.executionRole = this.buildExecutionRole(props);
63
+ this.readOnlyRole = this.buildReadOnlyRole(props);
64
+ const gateway = this.buildToolGateway(display);
65
+ this.webSearchUrl = gateway.getAtt("GatewayUrl").toString();
66
+ this.executionRole.addToPolicy(new iam.PolicyStatement({
67
+ sid: "InvokeToolGateway",
68
+ actions: ["bedrock-agentcore:InvokeGateway"],
69
+ resources: [gateway.getAtt("GatewayArn").toString()]
70
+ }));
71
+ if (!deployRuntime) {
72
+ this.agentRuntimeArn = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}-pending`;
73
+ this.outputs();
74
+ return;
75
+ }
76
+ if (!imageTag && agentImageTagRequired(this)) {
77
+ throw new Error(`${id}: -c agentImageTag=<tag> is required when deploying the runtime. Pass the tag of an image already pushed to the ${names.ecrRepo} repository, deploy a release with -c release=<version>, or run the bootstrap phase with -c deployRuntime=false.`);
78
+ }
79
+ const explicitSubnets = String(this.node.tryGetContext("agentSubnetIds") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
80
+ const runtimeSubnets = explicitSubnets.length > 0 ? explicitSubnets : props.vpc.selectSubnets(props.egressSubnets).subnetIds;
81
+ const mountTargets = runtimeSubnets.map((subnetId, index) => new cdk.CfnResource(this, `MountTarget${index}`, {
82
+ type: "AWS::S3Files::MountTarget",
83
+ properties: {
84
+ FileSystemId: props.fileSystemId,
85
+ SubnetId: subnetId,
86
+ SecurityGroups: [props.mountTargetSecurityGroup.securityGroupId]
87
+ }
88
+ }));
89
+ const runtime = new cdk.CfnResource(this, "AgentRuntime", {
90
+ type: "AWS::BedrockAgentCore::Runtime",
91
+ properties: {
92
+ AgentRuntimeName: names.runtimeName,
93
+ Description: `${display} agent runtime (Codex + Slack, single workspace)`,
94
+ AgentRuntimeArtifact: {
95
+ ContainerConfiguration: {
96
+ ContainerUri: agentImage(this, this.repository, imageTag)
97
+ }
98
+ },
99
+ NetworkConfiguration: {
100
+ NetworkMode: "VPC",
101
+ NetworkModeConfig: {
102
+ Subnets: runtimeSubnets,
103
+ SecurityGroups: [props.agentSecurityGroup.securityGroupId]
104
+ }
105
+ },
106
+ FilesystemConfigurations: [
107
+ {
108
+ S3FilesAccessPoint: {
109
+ AccessPointArn: this.accessPointArn,
110
+ MountPath: "/mnt/sky"
111
+ }
112
+ }
113
+ ],
114
+ LifecycleConfiguration: {
115
+ IdleRuntimeSessionTimeout: 3600,
116
+ MaxLifetime: 28800
117
+ },
118
+ RoleArn: this.executionRole.roleArn,
119
+ EnvironmentVariables: {
120
+ RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
121
+ AWS_REGION: this.region,
122
+ FOUNDATION_WORKSPACE_ROOT: "/workspace"
123
+ },
124
+ Tags: { project: props.instance.naming.secretPrefix }
125
+ }
126
+ });
127
+ runtime.node.addDependency(this.executionRole);
128
+ runtime.node.addDependency(accessPoint);
129
+ for (const mountTarget of mountTargets)
130
+ runtime.node.addDependency(mountTarget);
131
+ this.agentRuntimeArn = runtime.getAtt("AgentRuntimeArn").toString();
132
+ this.outputs();
133
+ }
134
+ outputs() {
135
+ new cdk.CfnOutput(this, "AgentRuntimeArn", { value: this.agentRuntimeArn });
136
+ new cdk.CfnOutput(this, "RepositoryUri", { value: this.repository.repositoryUri });
137
+ new cdk.CfnOutput(this, "WebSearchGatewayUrl", { value: this.webSearchUrl });
138
+ new cdk.CfnOutput(this, "ReadOnlyRoleArn", { value: this.readOnlyRole.roleArn });
139
+ }
140
+ buildToolGateway(display) {
141
+ const gatewayArnPattern = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:gateway/*`;
142
+ const role = new iam.Role(this, "ToolGatewayRole", {
143
+ description: "AgentCore tool gateway service role (web search only)",
144
+ assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com", {
145
+ conditions: {
146
+ StringEquals: { "aws:SourceAccount": this.account },
147
+ ArnLike: { "aws:SourceArn": gatewayArnPattern }
148
+ }
149
+ })
150
+ });
151
+ role.addToPolicy(new iam.PolicyStatement({
152
+ sid: "GatewayInvoke",
153
+ actions: ["bedrock-agentcore:InvokeGateway"],
154
+ resources: [gatewayArnPattern]
155
+ }));
156
+ role.addToPolicy(new iam.PolicyStatement({
157
+ sid: "WebSearchTool",
158
+ actions: ["bedrock-agentcore:InvokeWebSearch"],
159
+ resources: [`arn:aws:bedrock-agentcore:${this.region}:aws:tool/web-search.v1`]
160
+ }));
161
+ const gateway = new cdk.CfnResource(this, "ToolGateway", {
162
+ type: "AWS::BedrockAgentCore::Gateway",
163
+ properties: {
164
+ Name: this.names.gatewayName,
165
+ Description: `${display} managed tool gateway (web search)`,
166
+ ProtocolType: "MCP",
167
+ AuthorizerType: "AWS_IAM",
168
+ RoleArn: role.roleArn
169
+ }
170
+ });
171
+ gateway.node.addDependency(role);
172
+ const target = new cdk.CfnResource(this, "WebSearchTarget", {
173
+ type: "AWS::BedrockAgentCore::GatewayTarget",
174
+ properties: {
175
+ GatewayIdentifier: gateway.getAtt("GatewayIdentifier"),
176
+ Name: WEB_SEARCH_TARGET,
177
+ TargetConfiguration: {
178
+ Mcp: {
179
+ Connector: {
180
+ Source: { ConnectorId: "web-search" },
181
+ Configurations: [{ Name: "WebSearch", ParameterValues: {} }]
182
+ }
183
+ }
184
+ },
185
+ CredentialProviderConfigurations: [{ CredentialProviderType: "GATEWAY_IAM_ROLE" }]
186
+ }
187
+ });
188
+ target.node.addDependency(gateway);
189
+ return gateway;
190
+ }
191
+ denyDirectCrmTableAccess(role, instance, crmTable) {
192
+ if (!instance.integrations.crmStorage)
193
+ return;
194
+ const retainedTableArn = cdk.Stack.of(this).formatArn({
195
+ service: "dynamodb",
196
+ resource: "table",
197
+ resourceName: "*-CrmTable*"
198
+ });
199
+ const resources = [
200
+ retainedTableArn,
201
+ `${retainedTableArn}/index/*`,
202
+ ...crmTable === undefined ? [] : [crmTable.tableArn, `${crmTable.tableArn}/index/*`]
203
+ ];
204
+ role.addToPolicy(new iam.PolicyStatement({
205
+ sid: "DenyDirectCrmTableAccess",
206
+ effect: iam.Effect.DENY,
207
+ actions: [
208
+ "dynamodb:GetItem",
209
+ "dynamodb:BatchGetItem",
210
+ "dynamodb:TransactGetItems",
211
+ "dynamodb:Query",
212
+ "dynamodb:Scan",
213
+ "dynamodb:PartiQLSelect",
214
+ "dynamodb:ExecuteStatement",
215
+ "dynamodb:BatchExecuteStatement",
216
+ "dynamodb:ExportTableToPointInTime"
217
+ ],
218
+ resources
219
+ }));
220
+ }
221
+ denyDirectKnockSecretAccess(role, props) {
222
+ if (!provisionsIntegration(props.instance, "knock"))
223
+ return;
224
+ const { knockOauthClient, knockCredential } = props.secrets;
225
+ if (knockOauthClient === undefined || knockCredential === undefined)
226
+ throw new Error(`${this.stackName}: integrations.knock is enabled but FoundationData supplied no Knock secrets`);
227
+ role.addToPolicy(new iam.PolicyStatement({
228
+ sid: "DenyKnockSecrets",
229
+ effect: iam.Effect.DENY,
230
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
231
+ resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)]
232
+ }));
233
+ }
234
+ denyDirectUpworkApprovalAccess(role, upworkApprovalTable) {
235
+ if (upworkApprovalTable === undefined)
236
+ return;
237
+ role.addToPolicy(new iam.PolicyStatement({
238
+ sid: "DenyDirectUpworkApprovalAccess",
239
+ effect: iam.Effect.DENY,
240
+ actions: ["dynamodb:*"],
241
+ resources: [upworkApprovalTable.tableArn, `${upworkApprovalTable.tableArn}/index/*`]
242
+ }));
243
+ }
244
+ buildReadOnlyRole(props) {
245
+ const role = new iam.Role(this, "AgentReadOnlyRole", {
246
+ roleName: `${props.instance.naming.prefix}ReadOnlyRole`,
247
+ description: `${props.instance.displayName} read-only role assumed by the agent (aws-readonly)`,
248
+ assumedBy: new iam.ArnPrincipal(this.executionRole.roleArn),
249
+ maxSessionDuration: cdk.Duration.hours(1),
250
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")]
251
+ });
252
+ this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
253
+ this.denyDirectKnockSecretAccess(role, props);
254
+ this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
255
+ role.addToPolicy(new iam.PolicyStatement({
256
+ sid: "DenyAgentCoreBrowserMetadata",
257
+ effect: iam.Effect.DENY,
258
+ actions: ["bedrock-agentcore:*Browser*"],
259
+ resources: ["*"]
260
+ }));
261
+ role.addToPolicy(new iam.PolicyStatement({
262
+ sid: "LogsInsights",
263
+ actions: [
264
+ "logs:StartQuery",
265
+ "logs:StopQuery",
266
+ "logs:GetQueryResults",
267
+ "logs:FilterLogEvents",
268
+ "logs:DescribeLogGroups",
269
+ "logs:DescribeLogStreams"
270
+ ],
271
+ resources: ["*"]
272
+ }));
273
+ role.addToPolicy(new iam.PolicyStatement({
274
+ sid: "TraceRead",
275
+ actions: ["xray:GetTraceSummaries", "xray:BatchGetTraces"],
276
+ resources: ["*"]
277
+ }));
278
+ this.executionRole.addToPolicy(new iam.PolicyStatement({
279
+ sid: "AssumeReadOnlyRole",
280
+ actions: ["sts:AssumeRole"],
281
+ resources: [role.roleArn]
282
+ }));
283
+ return role;
284
+ }
285
+ buildExecutionRole(props) {
286
+ const role = new iam.Role(this, "AgentExecutionRole", {
287
+ assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
288
+ description: `${props.instance.displayName} AgentCore execution role (least privilege)`
289
+ });
290
+ this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
291
+ this.denyDirectKnockSecretAccess(role, props);
292
+ this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
293
+ const releaseRepositoryArn = releaseImageRepositoryArn(this);
294
+ role.addToPolicy(new iam.PolicyStatement({
295
+ sid: "EcrPull",
296
+ actions: [
297
+ "ecr:BatchGetImage",
298
+ "ecr:GetDownloadUrlForLayer",
299
+ "ecr:BatchCheckLayerAvailability"
300
+ ],
301
+ resources: [
302
+ this.repository.repositoryArn,
303
+ ...releaseRepositoryArn === undefined ? [] : [releaseRepositoryArn]
304
+ ]
305
+ }));
306
+ role.addToPolicy(new iam.PolicyStatement({
307
+ sid: "EcrAuth",
308
+ actions: ["ecr:GetAuthorizationToken"],
309
+ resources: ["*"]
310
+ }));
311
+ role.addToPolicy(new iam.PolicyStatement({
312
+ sid: "AgentLogs",
313
+ actions: [
314
+ "logs:CreateLogGroup",
315
+ "logs:CreateLogStream",
316
+ "logs:PutLogEvents",
317
+ "logs:DescribeLogStreams"
318
+ ],
319
+ resources: [
320
+ `arn:${this.partition}:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/${this.names.runtimeName}-*`
321
+ ]
322
+ }));
323
+ role.addToPolicy(new iam.PolicyStatement({
324
+ sid: "TraceIngest",
325
+ actions: [
326
+ "xray:PutTraceSegments",
327
+ "xray:PutTelemetryRecords",
328
+ "xray:PutSpans",
329
+ "xray:PutSpansForIndexing"
330
+ ],
331
+ resources: ["*"]
332
+ }));
333
+ role.addToPolicy(new iam.PolicyStatement({
334
+ sid: "AgentSecrets",
335
+ actions: ["secretsmanager:GetSecretValue"],
336
+ resources: [
337
+ secretArnPattern(props.secrets.runtime),
338
+ secretArnPattern(props.secrets.codex),
339
+ secretArnPattern(props.secrets.googleAiStudio),
340
+ secretArnPattern(props.secrets.googleDrive),
341
+ secretArnPattern(props.secrets.googleCalendar),
342
+ secretArnPattern(props.secrets.googleOauth),
343
+ secretArnPattern(props.secrets.githubApp),
344
+ secretArnPattern(props.secrets.slackApp),
345
+ secretArnPattern(props.secrets.mongodbReadonly)
346
+ ]
347
+ }));
348
+ role.addToPolicy(new iam.PolicyStatement({
349
+ sid: "CodexCredentialWriteback",
350
+ actions: ["secretsmanager:PutSecretValue"],
351
+ resources: [secretArnPattern(props.secrets.codex)]
352
+ }));
353
+ role.addToPolicy(new iam.PolicyStatement({
354
+ sid: "DenySlackSigningSecret",
355
+ effect: iam.Effect.DENY,
356
+ actions: ["secretsmanager:GetSecretValue"],
357
+ resources: [secretArnPattern(props.secrets.signing)]
358
+ }));
359
+ role.addToPolicy(new iam.PolicyStatement({
360
+ sid: "DenyEmailSecret",
361
+ effect: iam.Effect.DENY,
362
+ actions: ["secretsmanager:GetSecretValue"],
363
+ resources: [secretArnPattern(props.secrets.googleEmail)]
364
+ }));
365
+ role.addToPolicy(new iam.PolicyStatement({
366
+ sid: "EmailProxyInvoke",
367
+ actions: ["lambda:InvokeFunction"],
368
+ resources: [
369
+ cdk.Stack.of(this).formatArn({
370
+ service: "lambda",
371
+ resource: "function",
372
+ resourceName: instanceNames(props.instance).emailProxyFunctionName,
373
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
374
+ })
375
+ ]
376
+ }));
377
+ role.addToPolicy(new iam.PolicyStatement({
378
+ sid: "DenyDirectAgentCoreBrowser",
379
+ effect: iam.Effect.DENY,
380
+ actions: ["bedrock-agentcore:*Browser*"],
381
+ resources: ["*"]
382
+ }));
383
+ if (provisionsIntegration(props.instance, "browser")) {
384
+ role.addToPolicy(new iam.PolicyStatement({
385
+ sid: "BrowserProxyInvoke",
386
+ actions: ["lambda:InvokeFunction"],
387
+ resources: [
388
+ cdk.Stack.of(this).formatArn({
389
+ service: "lambda",
390
+ resource: "function",
391
+ resourceName: this.names.browserProxyFunctionName,
392
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
393
+ })
394
+ ]
395
+ }));
396
+ }
397
+ if (provisionsIntegration(props.instance, "otter")) {
398
+ const otterApi = props.secrets.otterApi;
399
+ if (otterApi === undefined)
400
+ throw new Error(`${this.stackName}: integrations.otter is enabled but FoundationData supplied no Otter secret`);
401
+ role.addToPolicy(new iam.PolicyStatement({
402
+ sid: "DenyOtterSecret",
403
+ effect: iam.Effect.DENY,
404
+ actions: ["secretsmanager:GetSecretValue"],
405
+ resources: [secretArnPattern(otterApi)]
406
+ }));
407
+ role.addToPolicy(new iam.PolicyStatement({
408
+ sid: "OtterProxyInvoke",
409
+ actions: ["lambda:InvokeFunction"],
410
+ resources: [
411
+ cdk.Stack.of(this).formatArn({
412
+ service: "lambda",
413
+ resource: "function",
414
+ resourceName: this.names.otterProxyFunctionName,
415
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
416
+ })
417
+ ]
418
+ }));
419
+ }
420
+ if (provisionsIntegration(props.instance, "knock")) {
421
+ role.addToPolicy(new iam.PolicyStatement({
422
+ sid: "KnockProxyInvoke",
423
+ actions: ["lambda:InvokeFunction"],
424
+ resources: [
425
+ cdk.Stack.of(this).formatArn({
426
+ service: "lambda",
427
+ resource: "function",
428
+ resourceName: this.names.knockProxyFunctionName,
429
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
430
+ })
431
+ ]
432
+ }));
433
+ }
434
+ if (provisionsIntegration(props.instance, "upwork")) {
435
+ const upwork = props.secrets.upwork;
436
+ if (upwork === undefined)
437
+ throw new Error(`${this.stackName}: integrations.upwork is enabled but FoundationData supplied no Upwork secret`);
438
+ role.addToPolicy(new iam.PolicyStatement({
439
+ sid: "DenyUpworkSecret",
440
+ effect: iam.Effect.DENY,
441
+ actions: ["secretsmanager:GetSecretValue"],
442
+ resources: [secretArnPattern(upwork)]
443
+ }));
444
+ role.addToPolicy(new iam.PolicyStatement({
445
+ sid: "UpworkProxyInvoke",
446
+ actions: ["lambda:InvokeFunction"],
447
+ resources: [
448
+ cdk.Stack.of(this).formatArn({
449
+ service: "lambda",
450
+ resource: "function",
451
+ resourceName: this.names.upworkProxyFunctionName,
452
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
453
+ })
454
+ ]
455
+ }));
456
+ }
457
+ if (provisionsIntegration(props.instance, "crm")) {
458
+ role.addToPolicy(new iam.PolicyStatement({
459
+ sid: "CrmProxyInvoke",
460
+ actions: ["lambda:InvokeFunction"],
461
+ resources: [
462
+ cdk.Stack.of(this).formatArn({
463
+ service: "lambda",
464
+ resource: "function",
465
+ resourceName: this.names.crmProxyFunctionName,
466
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
467
+ })
468
+ ]
469
+ }));
470
+ }
471
+ role.addToPolicy(new iam.PolicyStatement({
472
+ sid: "ConfigAndSkills",
473
+ actions: ["s3:GetObject"],
474
+ resources: [props.bucket.arnForObjects("*")]
475
+ }));
476
+ role.addToPolicy(new iam.PolicyStatement({
477
+ sid: "MemoryWrite",
478
+ actions: ["s3:PutObject"],
479
+ resources: [props.bucket.arnForObjects("memory/*")]
480
+ }));
481
+ role.addToPolicy(new iam.PolicyStatement({
482
+ sid: "ConfigList",
483
+ actions: ["s3:ListBucket"],
484
+ resources: [props.bucket.bucketArn]
485
+ }));
486
+ role.addToPolicy(new iam.PolicyStatement({
487
+ sid: "DocumentsObjects",
488
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
489
+ resources: [props.documentBucket.arnForObjects("*")]
490
+ }));
491
+ role.addToPolicy(new iam.PolicyStatement({
492
+ sid: "DocumentsList",
493
+ actions: ["s3:ListBucket"],
494
+ resources: [props.documentBucket.bucketArn]
495
+ }));
496
+ role.addToPolicy(new iam.PolicyStatement({
497
+ sid: "PersistentMount",
498
+ actions: ["s3files:ClientMount", "s3files:ClientWrite"],
499
+ resources: [
500
+ `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`
501
+ ],
502
+ conditions: { ArnEquals: { "s3files:AccessPointArn": this.accessPointArn } }
503
+ }));
504
+ role.addToPolicy(new iam.PolicyStatement({
505
+ sid: "PersistentMountDescribe",
506
+ actions: ["s3files:Get*", "s3files:List*", "s3files:Describe*"],
507
+ resources: [
508
+ this.accessPointArn,
509
+ `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`
510
+ ]
511
+ }));
512
+ role.addToPolicy(new iam.PolicyStatement({
513
+ sid: "StateTable",
514
+ actions: [
515
+ "dynamodb:GetItem",
516
+ "dynamodb:PutItem",
517
+ "dynamodb:UpdateItem",
518
+ "dynamodb:DeleteItem",
519
+ "dynamodb:Query"
520
+ ],
521
+ resources: [props.table.tableArn]
522
+ }));
523
+ role.addToPolicy(new iam.PolicyStatement({
524
+ sid: "ItemsTable",
525
+ actions: [
526
+ "dynamodb:GetItem",
527
+ "dynamodb:PutItem",
528
+ "dynamodb:UpdateItem",
529
+ "dynamodb:DeleteItem",
530
+ "dynamodb:Query"
531
+ ],
532
+ resources: [props.itemsTable.tableArn]
533
+ }));
534
+ role.addToPolicy(new iam.PolicyStatement({
535
+ sid: "DataKeyUsage",
536
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
537
+ resources: [props.dataKey.keyArn]
538
+ }));
539
+ role.addToPolicy(new iam.PolicyStatement({
540
+ sid: "RoutineSchedules",
541
+ actions: [
542
+ "scheduler:CreateSchedule",
543
+ "scheduler:UpdateSchedule",
544
+ "scheduler:DeleteSchedule",
545
+ "scheduler:GetSchedule"
546
+ ],
547
+ resources: [
548
+ `arn:${this.partition}:scheduler:${this.region}:${this.account}:schedule/${this.names.routineGroup}/*`
549
+ ]
550
+ }));
551
+ role.addToPolicy(new iam.PolicyStatement({
552
+ sid: "PassRoutineSchedulerRole",
553
+ actions: ["iam:PassRole"],
554
+ resources: [
555
+ `arn:${this.partition}:iam::${this.account}:role/${this.names.routineSchedulerRole}`
556
+ ],
557
+ conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } }
558
+ }));
559
+ if (requiresAuthenticatedQueue(props.instance)) {
560
+ role.addToPolicy(new iam.PolicyStatement({
561
+ sid: "RoutineIngressInvoke",
562
+ actions: ["lambda:InvokeFunction"],
563
+ resources: [
564
+ cdk.Stack.of(this).formatArn({
565
+ service: "lambda",
566
+ resource: "function",
567
+ resourceName: this.names.slackGatewayFunctionName,
568
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
569
+ })
570
+ ]
571
+ }));
572
+ } else {
573
+ role.addToPolicy(new iam.PolicyStatement({
574
+ sid: "InvokeQueueSend",
575
+ actions: ["sqs:SendMessage"],
576
+ resources: [
577
+ `arn:${this.partition}:sqs:${this.region}:${this.account}:${this.names.api}-InvokeQueue*`
578
+ ]
579
+ }));
580
+ }
581
+ role.addToPolicy(new iam.PolicyStatement({
582
+ sid: "SessionEni",
583
+ actions: [
584
+ "ec2:CreateNetworkInterface",
585
+ "ec2:DescribeNetworkInterfaces",
586
+ "ec2:DeleteNetworkInterface",
587
+ "ec2:DescribeSubnets",
588
+ "ec2:DescribeSecurityGroups",
589
+ "ec2:DescribeVpcs"
590
+ ],
591
+ resources: ["*"]
592
+ }));
593
+ role.addToPolicy(new iam.PolicyStatement({
594
+ sid: "WorkloadIdentity",
595
+ actions: [
596
+ "bedrock-agentcore:GetWorkloadAccessToken",
597
+ "bedrock-agentcore:GetWorkloadAccessTokenForJWT",
598
+ "bedrock-agentcore:GetWorkloadAccessTokenForUserId"
599
+ ],
600
+ resources: [
601
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default*`
602
+ ]
603
+ }));
604
+ return role;
605
+ }
606
+ }
607
+ function secretArnPattern(secret) {
608
+ return secret.secretFullArn ?? `${secret.secretArn}-??????`;
609
+ }
610
+
611
+ // src/stacks/api-stack.ts
612
+ import { readFileSync } from "node:fs";
613
+ import * as cdk2 from "aws-cdk-lib";
614
+ import * as apigateway from "aws-cdk-lib/aws-apigateway";
615
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
616
+ import * as cwActions from "aws-cdk-lib/aws-cloudwatch-actions";
617
+ import * as iam2 from "aws-cdk-lib/aws-iam";
618
+ import * as lambda from "aws-cdk-lib/aws-lambda";
619
+ import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
620
+ import * as scheduler from "aws-cdk-lib/aws-scheduler";
621
+ import * as sns from "aws-cdk-lib/aws-sns";
622
+ import * as sqs from "aws-cdk-lib/aws-sqs";
623
+ import { parse as parseYaml } from "yaml";
624
+ class FoundationApi extends cdk2.Stack {
625
+ api;
626
+ gatewayDlq;
627
+ invokeQueue;
628
+ invokeDlq;
629
+ emailProxyFunction;
630
+ browserProxyFunction;
631
+ otterProxyFunction;
632
+ crmProxyFunction;
633
+ knockProxyFunction;
634
+ upworkProxyFunction;
635
+ constructor(scope, id, props) {
636
+ super(scope, id, props);
637
+ const names = instanceNames(props.instance);
638
+ const display = props.instance.displayName;
639
+ const upwork = provisionsIntegration(props.instance, "upwork") ? upworkConfigForProvisioning(props.configPath) : undefined;
640
+ const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
641
+ const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
642
+ if (upwork !== undefined && (upworkSecret === undefined || upworkApprovalTable === undefined))
643
+ throw new Error(`${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`);
644
+ const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
645
+ this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
646
+ encryption: sqs.QueueEncryption.KMS,
647
+ encryptionMasterKey: props.dataKey,
648
+ enforceSSL: true,
649
+ retentionPeriod: cdk2.Duration.days(14)
650
+ });
651
+ this.invokeDlq = new sqs.Queue(this, "InvokeDlq", {
652
+ encryption: sqs.QueueEncryption.KMS,
653
+ encryptionMasterKey: props.dataKey,
654
+ enforceSSL: true,
655
+ retentionPeriod: cdk2.Duration.days(14)
656
+ });
657
+ this.invokeQueue = new sqs.Queue(this, "InvokeQueue", {
658
+ encryption: sqs.QueueEncryption.KMS,
659
+ encryptionMasterKey: props.dataKey,
660
+ enforceSSL: true,
661
+ visibilityTimeout: cdk2.Duration.seconds(900),
662
+ retentionPeriod: cdk2.Duration.hours(6),
663
+ deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 }
664
+ });
665
+ const gatewayRole = new iam2.Role(this, "GatewayRole", {
666
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
667
+ description: `${display} gateway Lambda role (least privilege)`,
668
+ managedPolicies: [
669
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
670
+ ]
671
+ });
672
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
673
+ sid: "GatewayCredentials",
674
+ actions: ["secretsmanager:GetSecretValue"],
675
+ resources: [
676
+ secretArnPattern2(props.secrets.signing),
677
+ secretArnPattern2(props.secrets.slackApp),
678
+ secretArnPattern2(props.secrets.googleCalendar),
679
+ secretArnPattern2(props.secrets.googleOauth),
680
+ ...props.secrets.knockOauthClient === undefined ? [] : [secretArnPattern2(props.secrets.knockOauthClient)]
681
+ ]
682
+ }));
683
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
684
+ sid: "DriveIdentityConnect",
685
+ actions: ["secretsmanager:PutSecretValue"],
686
+ resources: [secretArnPattern2(props.secrets.googleDrive)]
687
+ }));
688
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
689
+ sid: "EmailIdentityConnect",
690
+ actions: ["secretsmanager:PutSecretValue"],
691
+ resources: [secretArnPattern2(props.secrets.googleEmail)]
692
+ }));
693
+ if (props.secrets.knockCredential !== undefined) {
694
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
695
+ sid: "KnockCredentialConnect",
696
+ actions: ["secretsmanager:PutSecretValue"],
697
+ resources: [secretArnPattern2(props.secrets.knockCredential)]
698
+ }));
699
+ }
700
+ if (upworkSecret !== undefined) {
701
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
702
+ sid: "UpworkOAuth",
703
+ actions: [
704
+ "secretsmanager:GetSecretValue",
705
+ "secretsmanager:PutSecretValue",
706
+ "secretsmanager:UpdateSecretVersionStage"
707
+ ],
708
+ resources: [secretArnPattern2(upworkSecret)]
709
+ }));
710
+ }
711
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
712
+ sid: "DedupeAndThreadIndex",
713
+ actions: ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
714
+ resources: [props.table.tableArn]
715
+ }));
716
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
717
+ sid: "CalendarConnections",
718
+ actions: ["dynamodb:PutItem"],
719
+ resources: [props.itemsTable.tableArn]
720
+ }));
721
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
722
+ sid: "QueueSend",
723
+ actions: ["sqs:SendMessage"],
724
+ resources: [this.invokeQueue.queueArn]
725
+ }));
726
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
727
+ sid: "DataKeyUsage",
728
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
729
+ resources: [props.dataKey.keyArn]
730
+ }));
731
+ const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
732
+ ...authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {},
733
+ runtime: lambda.Runtime.NODEJS_22_X,
734
+ handler: "index.handler",
735
+ code: lambdaCode(this, "gateway"),
736
+ role: gatewayRole,
737
+ timeout: cdk2.Duration.seconds(10),
738
+ memorySize: 256,
739
+ deadLetterQueue: this.gatewayDlq,
740
+ environment: {
741
+ FOUNDATION_TABLE_NAME: props.table.tableName,
742
+ FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
743
+ FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
744
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
745
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
746
+ FOUNDATION_ADMINS: props.admins,
747
+ FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
748
+ FOUNDATION_DISPLAY_NAME: props.instance.displayName,
749
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
750
+ FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
751
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
752
+ FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
753
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
754
+ ...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
755
+ ...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
756
+ ...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
757
+ FOUNDATION_INSTANCE: props.instance.name,
758
+ FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
759
+ FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
760
+ FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour)
761
+ }
762
+ });
763
+ gatewayFunction.configureAsyncInvoke({
764
+ retryAttempts: 0,
765
+ maxEventAge: cdk2.Duration.seconds(60)
766
+ });
767
+ const invokerRole = new iam2.Role(this, "InvokerRole", {
768
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
769
+ description: `${display} invoker Lambda role (least privilege)`,
770
+ managedPolicies: [
771
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
772
+ ]
773
+ });
774
+ invokerRole.addToPolicy(new iam2.PolicyStatement({
775
+ sid: "InvokeAgentRuntime",
776
+ actions: ["bedrock-agentcore:InvokeAgentRuntime"],
777
+ resources: [
778
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*`,
779
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*/runtime-endpoint/*`
780
+ ]
781
+ }));
782
+ invokerRole.addToPolicy(new iam2.PolicyStatement({
783
+ sid: "SlackBotToken",
784
+ actions: ["secretsmanager:GetSecretValue"],
785
+ resources: [secretArnPattern2(props.secrets.slackApp)]
786
+ }));
787
+ if (authenticatedQueue) {
788
+ invokerRole.addToPolicy(new iam2.PolicyStatement({
789
+ sid: "QueueEnvelopeVerification",
790
+ actions: ["secretsmanager:GetSecretValue"],
791
+ resources: [secretArnPattern2(props.secrets.signing)]
792
+ }));
793
+ } else {
794
+ invokerRole.addToPolicy(new iam2.PolicyStatement({
795
+ sid: "DenySlackSigningSecret",
796
+ effect: iam2.Effect.DENY,
797
+ actions: ["secretsmanager:GetSecretValue"],
798
+ resources: [
799
+ `arn:${this.partition}:secretsmanager:${this.region}:${this.account}:secret:${names.secretSlackSigning}-??????`
800
+ ]
801
+ }));
802
+ }
803
+ invokerRole.addToPolicy(new iam2.PolicyStatement({
804
+ sid: "DataKeyUsage",
805
+ actions: ["kms:Decrypt", "kms:GenerateDataKey"],
806
+ resources: [props.dataKey.keyArn]
807
+ }));
808
+ const invokerFunction = new lambda.Function(this, "InvokerFunction", {
809
+ runtime: lambda.Runtime.NODEJS_22_X,
810
+ handler: "index.handler",
811
+ code: lambdaCode(this, "invoker"),
812
+ role: invokerRole,
813
+ timeout: cdk2.Duration.seconds(900),
814
+ memorySize: 512,
815
+ environment: {
816
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
817
+ FOUNDATION_AWS_REGION: this.region,
818
+ FOUNDATION_AGENT_RUNTIME_QUALIFIER: LIVE_ENDPOINT_NAME,
819
+ ...authenticatedQueue ? { FOUNDATION_QUEUE_SIGNING_SECRET_ID: props.secrets.signing.secretName } : {}
820
+ }
821
+ });
822
+ invokerFunction.addEventSource(new lambdaEventSources.SqsEventSource(this.invokeQueue, {
823
+ batchSize: 1,
824
+ reportBatchItemFailures: false
825
+ }));
826
+ this.api = new apigateway.RestApi(this, "FoundationApi", {
827
+ restApiName: `${props.instance.naming.secretPrefix}-events`,
828
+ description: `${display} Slack ingress (events + slash commands + interactivity)`,
829
+ endpointTypes: [apigateway.EndpointType.REGIONAL],
830
+ deployOptions: {
831
+ stageName: "prod",
832
+ throttlingRateLimit: 50,
833
+ throttlingBurstLimit: 100,
834
+ metricsEnabled: true
835
+ },
836
+ cloudWatchRole: false
837
+ });
838
+ const slack = this.api.root.addResource("slack");
839
+ const integration = new apigateway.LambdaIntegration(gatewayFunction);
840
+ slack.addResource("events").addMethod("POST", integration);
841
+ slack.addResource("commands").addMethod("POST", integration);
842
+ slack.addResource("interactive").addMethod("POST", integration);
843
+ this.api.root.addResource("calendar").addResource("oauth").addResource("callback").addMethod("GET", integration);
844
+ this.api.root.addResource("drive").addResource("oauth").addResource("callback").addMethod("GET", integration);
845
+ this.api.root.addResource("email").addResource("oauth").addResource("callback").addMethod("GET", integration);
846
+ if (provisionsIntegration(props.instance, "knock")) {
847
+ this.api.root.addResource("knock").addResource("oauth").addResource("callback").addMethod("GET", integration);
848
+ }
849
+ if (upworkSecret !== undefined && upwork !== undefined) {
850
+ const upworkRedirectUri = this.api.urlForPath("/upwork/oauth/callback");
851
+ this.api.root.addResource("upwork").addResource("oauth").addResource("callback").addMethod("GET", integration);
852
+ new cdk2.CfnOutput(this, "UpworkOAuthRedirectUrl", { value: upworkRedirectUri });
853
+ }
854
+ const emailProxyRole = new iam2.Role(this, "EmailProxyRole", {
855
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
856
+ description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
857
+ managedPolicies: [
858
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
859
+ ]
860
+ });
861
+ emailProxyRole.addToPolicy(new iam2.PolicyStatement({
862
+ sid: "EmailProxyCredentials",
863
+ actions: ["secretsmanager:GetSecretValue"],
864
+ resources: [
865
+ secretArnPattern2(props.secrets.googleEmail),
866
+ secretArnPattern2(props.secrets.googleOauth)
867
+ ]
868
+ }));
869
+ emailProxyRole.addToPolicy(new iam2.PolicyStatement({
870
+ sid: "EmailProxyDataKey",
871
+ actions: ["kms:Decrypt"],
872
+ resources: [props.dataKey.keyArn]
873
+ }));
874
+ this.emailProxyFunction = new lambda.Function(this, "EmailProxyFunction", {
875
+ functionName: instanceNames(props.instance).emailProxyFunctionName,
876
+ runtime: lambda.Runtime.NODEJS_22_X,
877
+ handler: "index.handler",
878
+ code: lambdaCode(this, "email-proxy"),
879
+ role: emailProxyRole,
880
+ timeout: cdk2.Duration.seconds(30),
881
+ memorySize: 256,
882
+ environment: {
883
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
884
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
885
+ FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentitiesFor(props.configPath))
886
+ }
887
+ });
888
+ const browserDomains = provisionsIntegration(props.instance, "browser") ? browserDomainsFor(props.configPath) : [];
889
+ if (browserDomains.length > 0) {
890
+ const browserProxyRole = new iam2.Role(this, "BrowserProxyRole", {
891
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
892
+ description: `${display} policy-enforcing AgentCore Browser proxy role`,
893
+ managedPolicies: [
894
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
895
+ ]
896
+ });
897
+ browserProxyRole.addToPolicy(new iam2.PolicyStatement({
898
+ sid: "BrowserProxySigningSecret",
899
+ actions: ["secretsmanager:GetSecretValue"],
900
+ resources: [secretArnPattern2(props.secrets.signing)]
901
+ }));
902
+ browserProxyRole.addToPolicy(new iam2.PolicyStatement({
903
+ sid: "BrowserProxyDataKey",
904
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
905
+ resources: [props.dataKey.keyArn]
906
+ }));
907
+ browserProxyRole.addToPolicy(new iam2.PolicyStatement({
908
+ sid: "BrowserProxySessions",
909
+ actions: [
910
+ "bedrock-agentcore:StartBrowserSession",
911
+ "bedrock-agentcore:StopBrowserSession",
912
+ "bedrock-agentcore:SaveBrowserSessionProfile",
913
+ "bedrock-agentcore:ConnectBrowserAutomationStream"
914
+ ],
915
+ resources: [
916
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:aws:browser/aws.browser.v1`,
917
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:browser-profile/*`
918
+ ]
919
+ }));
920
+ browserProxyRole.addToPolicy(new iam2.PolicyStatement({
921
+ sid: "BrowserProxyProfiles",
922
+ actions: [
923
+ "bedrock-agentcore:CreateBrowserProfile",
924
+ "bedrock-agentcore:GetBrowserProfile",
925
+ "bedrock-agentcore:ListBrowserProfiles"
926
+ ],
927
+ resources: ["*"]
928
+ }));
929
+ browserProxyRole.addToPolicy(new iam2.PolicyStatement({
930
+ sid: "BrowserProxyLease",
931
+ actions: ["dynamodb:PutItem", "dynamodb:DeleteItem"],
932
+ resources: [props.itemsTable.tableArn]
933
+ }));
934
+ this.browserProxyFunction = new lambda.Function(this, "BrowserProxyFunction", {
935
+ functionName: names.browserProxyFunctionName,
936
+ runtime: lambda.Runtime.NODEJS_22_X,
937
+ handler: "index.handler",
938
+ role: browserProxyRole,
939
+ timeout: cdk2.Duration.seconds(240),
940
+ code: lambdaCode(this, "browser-proxy"),
941
+ environment: {
942
+ FOUNDATION_BROWSER_ALLOWED_DOMAINS: JSON.stringify(browserDomains),
943
+ FOUNDATION_INSTANCE: props.instance.name,
944
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
945
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
946
+ FOUNDATION_TEAM_ID: props.instance.slack.teamId
947
+ }
948
+ });
949
+ }
950
+ if (provisionsIntegration(props.instance, "otter")) {
951
+ const otterApi = props.secrets.otterApi;
952
+ if (otterApi === undefined)
953
+ throw new Error(`${id}: integrations.otter is enabled but FoundationData supplied no Otter secret`);
954
+ const otterProxyRole = new iam2.Role(this, "OtterProxyRole", {
955
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
956
+ description: `${display} Otter proxy Lambda role (the only reader of the API key)`,
957
+ managedPolicies: [
958
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
959
+ ]
960
+ });
961
+ otterProxyRole.addToPolicy(new iam2.PolicyStatement({
962
+ sid: "OtterProxyCredentials",
963
+ actions: ["secretsmanager:GetSecretValue"],
964
+ resources: [secretArnPattern2(otterApi), secretArnPattern2(props.secrets.signing)]
965
+ }));
966
+ this.otterProxyFunction = new lambda.Function(this, "OtterProxyFunction", {
967
+ functionName: names.otterProxyFunctionName,
968
+ runtime: lambda.Runtime.NODEJS_22_X,
969
+ handler: "index.handler",
970
+ code: lambdaCode(this, "otter-proxy"),
971
+ role: otterProxyRole,
972
+ timeout: cdk2.Duration.seconds(30),
973
+ memorySize: 256,
974
+ environment: {
975
+ FOUNDATION_OTTER_API_SECRET_ID: otterApi.secretName,
976
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
977
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
978
+ FOUNDATION_ADMINS: props.admins
979
+ }
980
+ });
981
+ new cdk2.CfnOutput(this, "OtterProxyFunctionArn", {
982
+ value: this.otterProxyFunction.functionArn
983
+ });
984
+ }
985
+ if (provisionsIntegration(props.instance, "knock")) {
986
+ const knockCredential = props.secrets.knockCredential;
987
+ if (knockCredential === undefined)
988
+ throw new Error(`${id}: integrations.knock is enabled but FoundationData supplied no Knock secret`);
989
+ const knock = knockConfigForProvisioning(props.configPath);
990
+ const knockProxyRole = new iam2.Role(this, "KnockProxyRole", {
991
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
992
+ description: `${display} Knock read-only MCP proxy Lambda`,
993
+ managedPolicies: [
994
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
995
+ ]
996
+ });
997
+ knockProxyRole.addToPolicy(new iam2.PolicyStatement({
998
+ sid: "KnockProxyCredentialRead",
999
+ actions: ["secretsmanager:GetSecretValue"],
1000
+ resources: [secretArnPattern2(knockCredential), secretArnPattern2(props.secrets.signing)]
1001
+ }));
1002
+ knockProxyRole.addToPolicy(new iam2.PolicyStatement({
1003
+ sid: "KnockProxyCredentialRefresh",
1004
+ actions: ["secretsmanager:PutSecretValue", "secretsmanager:UpdateSecretVersionStage"],
1005
+ resources: [secretArnPattern2(knockCredential)]
1006
+ }));
1007
+ this.knockProxyFunction = new lambda.Function(this, "KnockProxyFunction", {
1008
+ functionName: names.knockProxyFunctionName,
1009
+ runtime: lambda.Runtime.NODEJS_22_X,
1010
+ handler: "index.handler",
1011
+ code: lambdaCode(this, "knock-proxy"),
1012
+ role: knockProxyRole,
1013
+ timeout: cdk2.Duration.seconds(30),
1014
+ memorySize: 256,
1015
+ environment: {
1016
+ FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: knockCredential.secretName,
1017
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1018
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1019
+ FOUNDATION_ADMINS: props.admins,
1020
+ FOUNDATION_KNOCK_DEBUG: String(knock.debug)
1021
+ }
1022
+ });
1023
+ new cdk2.CfnOutput(this, "KnockProxyFunctionArn", {
1024
+ value: this.knockProxyFunction.functionArn
1025
+ });
1026
+ }
1027
+ if (upworkSecret !== undefined && upwork !== undefined && upworkApprovalTable !== undefined) {
1028
+ const upworkProxyRole = new iam2.Role(this, "UpworkProxyRole", {
1029
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1030
+ description: `${display} Upwork proxy Lambda role (fixed OAuth and proposal operations)`,
1031
+ managedPolicies: [
1032
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1033
+ ]
1034
+ });
1035
+ upworkProxyRole.addToPolicy(new iam2.PolicyStatement({
1036
+ sid: "UpworkProxySecret",
1037
+ actions: [
1038
+ "secretsmanager:GetSecretValue",
1039
+ "secretsmanager:PutSecretValue",
1040
+ "secretsmanager:UpdateSecretVersionStage"
1041
+ ],
1042
+ resources: [secretArnPattern2(upworkSecret)]
1043
+ }));
1044
+ upworkProxyRole.addToPolicy(new iam2.PolicyStatement({
1045
+ sid: "UpworkProxySigningSecret",
1046
+ actions: ["secretsmanager:GetSecretValue"],
1047
+ resources: [secretArnPattern2(props.secrets.signing)]
1048
+ }));
1049
+ upworkProxyRole.addToPolicy(new iam2.PolicyStatement({
1050
+ sid: "UpworkDraftState",
1051
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
1052
+ resources: [upworkApprovalTable.tableArn]
1053
+ }));
1054
+ this.upworkProxyFunction = new lambda.Function(this, "UpworkProxyFunction", {
1055
+ functionName: names.upworkProxyFunctionName,
1056
+ runtime: lambda.Runtime.NODEJS_22_X,
1057
+ handler: "index.handler",
1058
+ code: lambdaCode(this, "upwork-proxy"),
1059
+ role: upworkProxyRole,
1060
+ timeout: cdk2.Duration.seconds(30),
1061
+ memorySize: 256,
1062
+ environment: {
1063
+ FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName,
1064
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1065
+ FOUNDATION_TABLE_NAME: upworkApprovalTable.tableName,
1066
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1067
+ FOUNDATION_UPWORK_ALLOWED_CHANNELS: JSON.stringify(upwork.channels),
1068
+ FOUNDATION_ADMINS: props.admins,
1069
+ FOUNDATION_INSTANCE: props.instance.name
1070
+ }
1071
+ });
1072
+ new cdk2.CfnOutput(this, "UpworkProxyFunctionArn", {
1073
+ value: this.upworkProxyFunction.functionArn
1074
+ });
1075
+ }
1076
+ if (provisionsIntegration(props.instance, "crm")) {
1077
+ const crm = crmConfigForProvisioning(props.configPath);
1078
+ const crmTable = props.crmTable;
1079
+ if (crmTable === undefined)
1080
+ throw new Error(`${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`);
1081
+ const crmProxyRole = new iam2.Role(this, "CrmProxyRole", {
1082
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1083
+ description: `${display} CRM proxy Lambda role (self-hosted CRM table access only)`,
1084
+ managedPolicies: [
1085
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1086
+ ]
1087
+ });
1088
+ crmProxyRole.addToPolicy(new iam2.PolicyStatement({
1089
+ sid: "CrmProxySigningSecret",
1090
+ actions: ["secretsmanager:GetSecretValue"],
1091
+ resources: [secretArnPattern2(props.secrets.signing)]
1092
+ }));
1093
+ crmProxyRole.addToPolicy(new iam2.PolicyStatement({
1094
+ sid: "CrmTableData",
1095
+ actions: [
1096
+ "dynamodb:GetItem",
1097
+ "dynamodb:PutItem",
1098
+ "dynamodb:UpdateItem",
1099
+ "dynamodb:DeleteItem",
1100
+ "dynamodb:BatchWriteItem",
1101
+ "dynamodb:TransactWriteItems"
1102
+ ],
1103
+ resources: [crmTable.tableArn]
1104
+ }));
1105
+ crmProxyRole.addToPolicy(new iam2.PolicyStatement({
1106
+ sid: "CrmRecordsIndex",
1107
+ actions: ["dynamodb:Query"],
1108
+ resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`]
1109
+ }));
1110
+ this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
1111
+ functionName: names.crmProxyFunctionName,
1112
+ runtime: lambda.Runtime.NODEJS_22_X,
1113
+ handler: "index.handler",
1114
+ code: lambdaCode(this, "crm-proxy"),
1115
+ role: crmProxyRole,
1116
+ timeout: cdk2.Duration.seconds(30),
1117
+ memorySize: 256,
1118
+ environment: {
1119
+ FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
1120
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1121
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1122
+ FOUNDATION_CRM_TENANT_ID: props.instance.name,
1123
+ FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
1124
+ FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
1125
+ FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
1126
+ FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize)
1127
+ }
1128
+ });
1129
+ new cdk2.CfnOutput(this, "CrmProxyFunctionArn", {
1130
+ value: this.crmProxyFunction.functionArn
1131
+ });
1132
+ new cdk2.CfnOutput(this, "CrmPolicyFingerprint", {
1133
+ value: crm.policyFingerprint
1134
+ });
1135
+ }
1136
+ const alarmTopic = new sns.Topic(this, "AlarmTopic", {
1137
+ displayName: `${display} alarms`
1138
+ });
1139
+ if (props.alarmEmail) {
1140
+ new sns.Subscription(this, "AlarmEmail", {
1141
+ topic: alarmTopic,
1142
+ protocol: sns.SubscriptionProtocol.EMAIL,
1143
+ endpoint: props.alarmEmail
1144
+ });
1145
+ }
1146
+ const alarmAction = new cwActions.SnsAction(alarmTopic);
1147
+ for (const [name, queue] of [
1148
+ ["GatewayDlqDepthAlarm", this.gatewayDlq],
1149
+ ["InvokeDlqDepthAlarm", this.invokeDlq]
1150
+ ]) {
1151
+ const alarm = new cloudwatch.Alarm(this, name, {
1152
+ alarmDescription: `${name}: messages are sitting in the DLQ`,
1153
+ metric: queue.metricApproximateNumberOfMessagesVisible({
1154
+ period: cdk2.Duration.minutes(5),
1155
+ statistic: "Maximum"
1156
+ }),
1157
+ threshold: 1,
1158
+ evaluationPeriods: 1,
1159
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1160
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING
1161
+ });
1162
+ alarm.addAlarmAction(alarmAction);
1163
+ }
1164
+ new scheduler.CfnScheduleGroup(this, "RoutineGroup", { name: names.routineGroup });
1165
+ const schedulerRole = new iam2.Role(this, "RoutineSchedulerRole", {
1166
+ roleName: names.routineSchedulerRole,
1167
+ description: "Assumed by EventBridge Scheduler to enqueue a routine fire",
1168
+ assumedBy: new iam2.ServicePrincipal("scheduler.amazonaws.com", {
1169
+ conditions: { StringEquals: { "aws:SourceAccount": this.account } }
1170
+ })
1171
+ });
1172
+ this.invokeQueue.grantSendMessages(schedulerRole);
1173
+ new cdk2.CfnOutput(this, "GoogleCalendarOAuthRedirectUrl", {
1174
+ value: this.api.urlForPath("/calendar/oauth/callback")
1175
+ });
1176
+ new cdk2.CfnOutput(this, "GoogleDriveOAuthRedirectUrl", {
1177
+ value: this.api.urlForPath("/drive/oauth/callback")
1178
+ });
1179
+ new cdk2.CfnOutput(this, "GoogleEmailOAuthRedirectUrl", {
1180
+ value: this.api.urlForPath("/email/oauth/callback")
1181
+ });
1182
+ if (this.knockProxyFunction !== undefined) {
1183
+ new cdk2.CfnOutput(this, "KnockOAuthRedirectUrl", {
1184
+ value: this.api.urlForPath("/knock/oauth/callback")
1185
+ });
1186
+ }
1187
+ new cdk2.CfnOutput(this, "EmailProxyFunctionArn", {
1188
+ value: this.emailProxyFunction.functionArn
1189
+ });
1190
+ if (this.browserProxyFunction !== undefined) {
1191
+ new cdk2.CfnOutput(this, "BrowserProxyFunctionArn", {
1192
+ value: this.browserProxyFunction.functionArn
1193
+ });
1194
+ }
1195
+ new cdk2.CfnOutput(this, "InvokeQueueUrl", { value: this.invokeQueue.queueUrl });
1196
+ new cdk2.CfnOutput(this, "RoutineSchedulerRoleArn", { value: schedulerRole.roleArn });
1197
+ if (authenticatedQueue)
1198
+ new cdk2.CfnOutput(this, "RoutineIngressFunctionArn", {
1199
+ value: gatewayFunction.functionArn
1200
+ });
1201
+ new cdk2.CfnOutput(this, "EventsUrl", { value: this.api.urlForPath("/slack/events") });
1202
+ new cdk2.CfnOutput(this, "CommandsUrl", { value: this.api.urlForPath("/slack/commands") });
1203
+ new cdk2.CfnOutput(this, "InteractiveUrl", {
1204
+ value: this.api.urlForPath("/slack/interactive")
1205
+ });
1206
+ }
1207
+ }
1208
+ function crmConfigForProvisioning(configPath) {
1209
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1210
+ const crm = config.capabilities.crm;
1211
+ const missing = [
1212
+ ...crm.enabled ? [] : ["capabilities.crm.enabled"],
1213
+ ...crm.channels.length > 0 ? [] : ["capabilities.crm.channels"],
1214
+ ...crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"],
1215
+ ...crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]
1216
+ ];
1217
+ if (missing.length > 0)
1218
+ throw new Error(`${configPath}: integrations.crm requires a configured CRM capability (${missing.join(", ")})`);
1219
+ return {
1220
+ channels: [...crm.channels],
1221
+ statuses: [...crm.statuses],
1222
+ activityTypes: [...crm.activityTypes],
1223
+ maxBatchSize: crm.maxBatchSize,
1224
+ policyFingerprint: crmPolicyFingerprint(crm)
1225
+ };
1226
+ }
1227
+ function knockConfigForProvisioning(configPath) {
1228
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1229
+ return {
1230
+ debug: config.capabilities.knock.enabled && config.capabilities.knock.debug
1231
+ };
1232
+ }
1233
+ function upworkConfigForProvisioning(configPath) {
1234
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1235
+ const upwork = config.capabilities.upwork;
1236
+ const missing = [
1237
+ ...upwork.enabled ? [] : ["capabilities.upwork.enabled"],
1238
+ ...upwork.channels.length > 0 ? [] : ["capabilities.upwork.channels"]
1239
+ ];
1240
+ if (missing.length > 0)
1241
+ throw new Error(`${configPath}: integrations.upwork requires a configured Upwork capability (${missing.join(", ")})`);
1242
+ return { channels: [...upwork.channels] };
1243
+ }
1244
+ function emailIdentitiesFor(configPath) {
1245
+ const doc = parseYaml(readFileSync(configPath, "utf8"));
1246
+ const email = doc.capabilities?.email;
1247
+ if (email?.enabled !== true || !Array.isArray(email.identities))
1248
+ return [];
1249
+ return email.identities;
1250
+ }
1251
+ function browserDomainsFor(configPath) {
1252
+ const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
1253
+ return browser.enabled && browser.mode === "read" ? [...browser.allowedDomains] : [];
1254
+ }
1255
+ function secretArnPattern2(secret) {
1256
+ return secret.secretFullArn ?? `${secret.secretArn}-??????`;
1257
+ }
1258
+
1259
+ // src/deploy-permissions.ts
1260
+ import * as iam3 from "aws-cdk-lib/aws-iam";
1261
+ function deployStatements(stack, names, targets, instance) {
1262
+ const secretArn = (name) => `arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:${name}-??????`;
1263
+ const runtimeSecret = secretArn(names.secretRuntime);
1264
+ const knockOauthClient = secretArn(names.secretKnockOauthClient);
1265
+ return [
1266
+ new iam3.PolicyStatement({
1267
+ sid: "AssumeCdkBootstrapRoles",
1268
+ actions: ["sts:AssumeRole"],
1269
+ resources: [`arn:${stack.partition}:iam::${stack.account}:role/cdk-*`]
1270
+ }),
1271
+ new iam3.PolicyStatement({
1272
+ sid: "ReadStackOutputs",
1273
+ actions: ["cloudformation:DescribeStacks"],
1274
+ resources: ["*"]
1275
+ }),
1276
+ new iam3.PolicyStatement({
1277
+ sid: "EcrLogin",
1278
+ actions: ["ecr:GetAuthorizationToken"],
1279
+ resources: ["*"]
1280
+ }),
1281
+ new iam3.PolicyStatement({
1282
+ sid: "EcrPushAgentImage",
1283
+ actions: [
1284
+ "ecr:BatchCheckLayerAvailability",
1285
+ "ecr:CompleteLayerUpload",
1286
+ "ecr:InitiateLayerUpload",
1287
+ "ecr:PutImage",
1288
+ "ecr:UploadLayerPart",
1289
+ "ecr:BatchGetImage",
1290
+ "ecr:GetDownloadUrlForLayer",
1291
+ "ecr:DescribeImages"
1292
+ ],
1293
+ resources: [
1294
+ `arn:${stack.partition}:ecr:${stack.region}:${stack.account}:repository/${names.ecrRepo}`
1295
+ ]
1296
+ }),
1297
+ new iam3.PolicyStatement({
1298
+ sid: "ReadDeploySecrets",
1299
+ actions: ["secretsmanager:GetSecretValue"],
1300
+ resources: [secretArn(names.secretSlackApp), secretArn(names.secretGithubApp), runtimeSecret]
1301
+ }),
1302
+ new iam3.PolicyStatement({
1303
+ sid: "WriteRuntimeSecret",
1304
+ actions: ["secretsmanager:PutSecretValue"],
1305
+ resources: [runtimeSecret]
1306
+ }),
1307
+ ...instance?.license === undefined ? [] : [
1308
+ new iam3.PolicyStatement({
1309
+ sid: "CacheLicenseVerification",
1310
+ actions: [
1311
+ "secretsmanager:CreateSecret",
1312
+ "secretsmanager:DescribeSecret",
1313
+ "secretsmanager:GetSecretValue",
1314
+ "secretsmanager:PutSecretValue"
1315
+ ],
1316
+ resources: [secretArn(names.secretLicenseLastVerified)]
1317
+ })
1318
+ ],
1319
+ ...instance !== undefined && provisionsIntegration(instance, "knock") ? [
1320
+ new iam3.PolicyStatement({
1321
+ sid: "RegisterKnockOauthClient",
1322
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue"],
1323
+ resources: [knockOauthClient]
1324
+ })
1325
+ ] : [],
1326
+ new iam3.PolicyStatement({
1327
+ sid: "SyncConfigAndSkills",
1328
+ actions: ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:GetObject"],
1329
+ resources: [
1330
+ targets.bucket.bucketArn,
1331
+ targets.bucket.arnForObjects("config/*"),
1332
+ targets.bucket.arnForObjects("skills/*")
1333
+ ]
1334
+ }),
1335
+ new iam3.PolicyStatement({
1336
+ sid: "UseDataKey",
1337
+ actions: ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*"],
1338
+ resources: [targets.dataKey.keyArn]
1339
+ }),
1340
+ new iam3.PolicyStatement({
1341
+ sid: "SmokeTestRuntime",
1342
+ actions: [
1343
+ "bedrock-agentcore:InvokeAgentRuntime",
1344
+ "bedrock-agentcore:GetAgentRuntime",
1345
+ "bedrock-agentcore:ListAgentRuntimeVersions",
1346
+ "bedrock-agentcore:GetAgentRuntimeEndpoint",
1347
+ "bedrock-agentcore:UpdateAgentRuntimeEndpoint"
1348
+ ],
1349
+ resources: [
1350
+ `arn:${stack.partition}:bedrock-agentcore:${stack.region}:${stack.account}:runtime/${names.runtimeName}*`,
1351
+ `arn:${stack.partition}:bedrock-agentcore:${stack.region}:${stack.account}:runtime/${names.runtimeName}*/runtime-endpoint/*`
1352
+ ]
1353
+ }),
1354
+ new iam3.PolicyStatement({
1355
+ sid: "WhoAmI",
1356
+ actions: ["sts:GetCallerIdentity"],
1357
+ resources: ["*"]
1358
+ })
1359
+ ];
1360
+ }
1361
+
1362
+ // src/stacks/ci-stack.ts
1363
+ import * as cdk3 from "aws-cdk-lib";
1364
+ import * as iam4 from "aws-cdk-lib/aws-iam";
1365
+ class FoundationCi extends cdk3.Stack {
1366
+ deployRole;
1367
+ constructor(scope, id, props) {
1368
+ super(scope, id, props);
1369
+ const names = instanceNames(props.instance);
1370
+ const repository = props.repository ?? props.instance.github.repo;
1371
+ const ids = props.repositoryIds ?? props.instance.github.repoIds;
1372
+ const [owner, repo] = repository.split("/");
1373
+ const mainSubjects = [
1374
+ `repo:${repository}:ref:refs/heads/main`,
1375
+ `repo:${owner}@${ids.owner}/${repo}@${ids.repo}:ref:refs/heads/main`
1376
+ ];
1377
+ const provider = iam4.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GitHubOidc", `arn:${this.partition}:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`);
1378
+ this.deployRole = new iam4.Role(this, "FoundationDeployRole", {
1379
+ roleName: names.deployRole,
1380
+ description: `GitHub Actions deploy role for ${repository} (main only)`,
1381
+ maxSessionDuration: cdk3.Duration.hours(1),
1382
+ assumedBy: new iam4.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
1383
+ StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
1384
+ StringLike: {
1385
+ "token.actions.githubusercontent.com:sub": mainSubjects
1386
+ }
1387
+ }),
1388
+ inlinePolicies: {
1389
+ FoundationDeploy: new iam4.PolicyDocument({
1390
+ statements: deployStatements(this, names, { bucket: props.bucket, dataKey: props.dataKey }, props.instance)
1391
+ })
1392
+ }
1393
+ });
1394
+ new cdk3.CfnOutput(this, "DeployRoleArn", { value: this.deployRole.roleArn });
1395
+ }
1396
+ }
1397
+
1398
+ // src/stacks/data-stack.ts
1399
+ import * as cdk4 from "aws-cdk-lib";
1400
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
1401
+ import * as iam5 from "aws-cdk-lib/aws-iam";
1402
+ import * as kms from "aws-cdk-lib/aws-kms";
1403
+ import * as s3 from "aws-cdk-lib/aws-s3";
1404
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
1405
+ class FoundationData extends cdk4.Stack {
1406
+ dataKey;
1407
+ bucket;
1408
+ documentBucket;
1409
+ fileSystem;
1410
+ table;
1411
+ itemsTable;
1412
+ crmTable;
1413
+ upworkApprovalTable;
1414
+ secrets;
1415
+ constructor(scope, id, props) {
1416
+ super(scope, id, props);
1417
+ const names = instanceNames(props.instance);
1418
+ const display = props.instance.displayName;
1419
+ const crmStorage = props.instance.integrations.crmStorage;
1420
+ this.dataKey = new kms.Key(this, "FoundationDataKey", {
1421
+ description: `${display} CMK for the S3 buckets, DynamoDB table and SQS queues`,
1422
+ enableKeyRotation: true,
1423
+ alias: names.keyAlias
1424
+ });
1425
+ this.bucket = new s3.Bucket(this, "FoundationBucket", {
1426
+ versioned: true,
1427
+ encryption: s3.BucketEncryption.KMS,
1428
+ encryptionKey: this.dataKey,
1429
+ bucketKeyEnabled: true,
1430
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
1431
+ enforceSSL: true,
1432
+ removalPolicy: cdk4.RemovalPolicy.RETAIN
1433
+ });
1434
+ this.documentBucket = new s3.Bucket(this, "DocumentsBucket", {
1435
+ versioned: true,
1436
+ encryption: s3.BucketEncryption.KMS,
1437
+ encryptionKey: this.dataKey,
1438
+ bucketKeyEnabled: true,
1439
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
1440
+ enforceSSL: true,
1441
+ removalPolicy: cdk4.RemovalPolicy.RETAIN
1442
+ });
1443
+ this.table = new dynamodb.Table(this, "FoundationTable", {
1444
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
1445
+ timeToLiveAttribute: "ttl",
1446
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
1447
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
1448
+ encryptionKey: this.dataKey,
1449
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1450
+ removalPolicy: cdk4.RemovalPolicy.RETAIN
1451
+ });
1452
+ this.itemsTable = new dynamodb.Table(this, "FoundationItems", {
1453
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
1454
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
1455
+ timeToLiveAttribute: "ttl",
1456
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
1457
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
1458
+ encryptionKey: this.dataKey,
1459
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1460
+ removalPolicy: cdk4.RemovalPolicy.RETAIN
1461
+ });
1462
+ this.crmTable = crmStorage ? new dynamodb.Table(this, "CrmTable", {
1463
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
1464
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
1465
+ timeToLiveAttribute: "ttl",
1466
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
1467
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
1468
+ encryptionKey: this.dataKey,
1469
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1470
+ removalPolicy: cdk4.RemovalPolicy.RETAIN
1471
+ }) : undefined;
1472
+ this.crmTable?.addGlobalSecondaryIndex({
1473
+ indexName: "RecordsIndex",
1474
+ partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
1475
+ sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
1476
+ projectionType: dynamodb.ProjectionType.INCLUDE,
1477
+ nonKeyAttributes: ["recordType", "recordId"]
1478
+ });
1479
+ this.upworkApprovalTable = provisionsIntegration(props.instance, "upwork") ? new dynamodb.Table(this, "UpworkApprovalTable", {
1480
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
1481
+ timeToLiveAttribute: "ttl",
1482
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
1483
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
1484
+ encryptionKey: this.dataKey,
1485
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1486
+ removalPolicy: cdk4.RemovalPolicy.DESTROY
1487
+ }) : undefined;
1488
+ const secretShell = (constructId, name, description) => new secretsmanager.Secret(this, constructId, {
1489
+ secretName: name,
1490
+ description,
1491
+ removalPolicy: cdk4.RemovalPolicy.RETAIN,
1492
+ generateSecretString: {
1493
+ secretStringTemplate: "{}",
1494
+ generateStringKey: "placeholder"
1495
+ }
1496
+ });
1497
+ const otterApi = provisionsIntegration(props.instance, "otter") ? new secretsmanager.Secret(this, "OtterApiSecret", {
1498
+ secretName: names.secretOtterApi,
1499
+ description: `Otter Enterprise Public API key for ${display} ({ api_key })`,
1500
+ removalPolicy: cdk4.RemovalPolicy.DESTROY,
1501
+ generateSecretString: {
1502
+ secretStringTemplate: "{}",
1503
+ generateStringKey: "placeholder"
1504
+ }
1505
+ }) : undefined;
1506
+ const knockOauthClient = provisionsIntegration(props.instance, "knock") ? new secretsmanager.Secret(this, "KnockOauthClientSecret", {
1507
+ secretName: names.secretKnockOauthClient,
1508
+ description: `Knock public OAuth client for ${display} ({ client_id, redirect_uri })`,
1509
+ removalPolicy: cdk4.RemovalPolicy.DESTROY,
1510
+ generateSecretString: {
1511
+ secretStringTemplate: "{}",
1512
+ generateStringKey: "placeholder"
1513
+ }
1514
+ }) : undefined;
1515
+ const knockCredential = provisionsIntegration(props.instance, "knock") ? new secretsmanager.Secret(this, "KnockCredentialSecret", {
1516
+ secretName: names.secretKnockCredential,
1517
+ description: `Knock OAuth credential for ${display}; callback writes and proxy alone reads`,
1518
+ removalPolicy: cdk4.RemovalPolicy.DESTROY,
1519
+ generateSecretString: {
1520
+ secretStringTemplate: "{}",
1521
+ generateStringKey: "placeholder"
1522
+ }
1523
+ }) : undefined;
1524
+ const upwork = provisionsIntegration(props.instance, "upwork") ? secretShell("UpworkSecret", names.secretUpwork, `Upwork OAuth client and token store for ${display} ({ client_id, client_secret, access_token?, refresh_token?, expires_at?, tenant_id? })`) : undefined;
1525
+ this.secrets = {
1526
+ signing: secretsmanager.Secret.fromSecretNameV2(this, "SlackSigningSecret", names.secretSlackSigning),
1527
+ slackApp: secretsmanager.Secret.fromSecretNameV2(this, "SlackAppSecret", names.secretSlackApp),
1528
+ githubApp: secretsmanager.Secret.fromSecretNameV2(this, "GithubAppSecret", names.secretGithubApp),
1529
+ codex: secretShell("CodexSecret", names.secretCodex, "OpenAI Codex credential store (rotated tokens are written back here)"),
1530
+ googleAiStudio: new secretsmanager.Secret(this, "GoogleAiStudioSecret", {
1531
+ secretName: names.secretGoogleAiStudio,
1532
+ encryptionKey: this.dataKey,
1533
+ description: "Google AI Studio API key for image generation ({ api_key })",
1534
+ removalPolicy: cdk4.RemovalPolicy.RETAIN,
1535
+ generateSecretString: {
1536
+ secretStringTemplate: "{}",
1537
+ generateStringKey: "placeholder"
1538
+ }
1539
+ }),
1540
+ googleDrive: new secretsmanager.Secret(this, "GoogleDriveSecret", {
1541
+ secretName: names.secretGoogleDrive,
1542
+ encryptionKey: this.dataKey,
1543
+ description: `Google Drive service identity: the company-owned Drive account ${display} reads as. JSON { client_email, private_key, token_uri? }, optionally nested under identities.default. Access is granted by sharing files/folders with client_email; scope is read-only.`,
1544
+ removalPolicy: cdk4.RemovalPolicy.RETAIN,
1545
+ generateSecretString: {
1546
+ secretStringTemplate: "{}",
1547
+ generateStringKey: "placeholder"
1548
+ }
1549
+ }),
1550
+ googleCalendar: secretShell("GoogleCalendarSecret", names.secretGoogleCalendar, `Google Calendar OAuth client for ${display}'s per-person calendar connections`),
1551
+ googleEmail: secretShell("GoogleEmailSecret", names.secretGoogleEmail, `Gmail identity for ${display}, connected from Slack — written by the gateway, read only by the email proxy`),
1552
+ mongodbReadonly: new secretsmanager.Secret(this, "MongodbReadonlySecret", {
1553
+ secretName: names.secretMongodbReadonly,
1554
+ encryptionKey: this.dataKey,
1555
+ description: `Read-only MongoDB Atlas credential for ${display}. JSON { uri, databases? } where uri authenticates an Atlas user provisioned with the \`read\` role, and databases (optional) narrows which databases the agent may name.`,
1556
+ removalPolicy: cdk4.RemovalPolicy.RETAIN,
1557
+ generateSecretString: {
1558
+ secretStringTemplate: "{}",
1559
+ generateStringKey: "placeholder"
1560
+ }
1561
+ }),
1562
+ ...otterApi === undefined ? {} : { otterApi },
1563
+ ...knockOauthClient === undefined ? {} : { knockOauthClient },
1564
+ ...knockCredential === undefined ? {} : { knockCredential },
1565
+ ...upwork === undefined ? {} : { upwork },
1566
+ googleOauth: secretShell("GoogleOauthSecret", names.secretGoogleOauth, `Google OAuth client shared by ${display}'s Google capabilities ({ client_id, client_secret, redirect_uri })`),
1567
+ runtime: secretShell("RuntimeSecret", names.secretRuntime, "Agent runtime env map, written by scripts/setup.ts after deploy")
1568
+ };
1569
+ this.fileSystem = this.buildFileSystem(display);
1570
+ new cdk4.CfnOutput(this, "FoundationFileSystemId", {
1571
+ value: this.fileSystem.getAtt("FileSystemId").toString()
1572
+ });
1573
+ new cdk4.CfnOutput(this, "BucketName", { value: this.bucket.bucketName });
1574
+ new cdk4.CfnOutput(this, "DocumentsBucketName", { value: this.documentBucket.bucketName });
1575
+ new cdk4.CfnOutput(this, "TableName", { value: this.table.tableName });
1576
+ new cdk4.CfnOutput(this, "ItemsTableName", { value: this.itemsTable.tableName });
1577
+ }
1578
+ buildFileSystem(display) {
1579
+ const role = new iam5.Role(this, "S3FilesServiceRole", {
1580
+ description: `S3 Files service role for the ${display} persistent mount`,
1581
+ assumedBy: new iam5.ServicePrincipal("elasticfilesystem.amazonaws.com", {
1582
+ conditions: {
1583
+ StringEquals: { "aws:SourceAccount": this.account },
1584
+ ArnLike: {
1585
+ "aws:SourceArn": `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/*`
1586
+ }
1587
+ }
1588
+ })
1589
+ });
1590
+ role.addToPolicy(new iam5.PolicyStatement({
1591
+ sid: "MountObjects",
1592
+ actions: [
1593
+ "s3:AbortMultipartUpload",
1594
+ "s3:DeleteObject*",
1595
+ "s3:GetObject*",
1596
+ "s3:List*",
1597
+ "s3:PutObject*"
1598
+ ],
1599
+ resources: [this.bucket.arnForObjects("fs/*")]
1600
+ }));
1601
+ role.addToPolicy(new iam5.PolicyStatement({
1602
+ sid: "MountBucket",
1603
+ actions: [
1604
+ "s3:ListBucket",
1605
+ "s3:ListBucketVersions",
1606
+ "s3:GetBucketLocation",
1607
+ "s3:ListBucketMultipartUploads"
1608
+ ],
1609
+ resources: [this.bucket.bucketArn]
1610
+ }));
1611
+ role.addToPolicy(new iam5.PolicyStatement({
1612
+ sid: "MountKeyUsage",
1613
+ actions: [
1614
+ "kms:Decrypt",
1615
+ "kms:Encrypt",
1616
+ "kms:GenerateDataKey*",
1617
+ "kms:ReEncryptFrom",
1618
+ "kms:ReEncryptTo",
1619
+ "kms:DescribeKey"
1620
+ ],
1621
+ resources: [this.dataKey.keyArn]
1622
+ }));
1623
+ role.addToPolicy(new iam5.PolicyStatement({
1624
+ sid: "EventBridgeManage",
1625
+ actions: [
1626
+ "events:DeleteRule",
1627
+ "events:DisableRule",
1628
+ "events:EnableRule",
1629
+ "events:PutRule",
1630
+ "events:PutTargets",
1631
+ "events:RemoveTargets"
1632
+ ],
1633
+ resources: [`arn:${this.partition}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`],
1634
+ conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } }
1635
+ }));
1636
+ role.addToPolicy(new iam5.PolicyStatement({
1637
+ sid: "EventBridgeRead",
1638
+ actions: [
1639
+ "events:DescribeRule",
1640
+ "events:ListRuleNamesByTarget",
1641
+ "events:ListRules",
1642
+ "events:ListTargetsByRule"
1643
+ ],
1644
+ resources: [`arn:${this.partition}:events:*:*:rule/*`]
1645
+ }));
1646
+ const fileSystem = new cdk4.CfnResource(this, "FoundationFileSystem", {
1647
+ type: "AWS::S3Files::FileSystem",
1648
+ properties: {
1649
+ Bucket: this.bucket.bucketArn,
1650
+ Prefix: "fs/",
1651
+ KmsKeyId: this.dataKey.keyArn,
1652
+ RoleArn: role.roleArn,
1653
+ AcceptBucketWarning: true
1654
+ }
1655
+ });
1656
+ fileSystem.node.addDependency(role);
1657
+ fileSystem.applyRemovalPolicy(cdk4.RemovalPolicy.RETAIN);
1658
+ return fileSystem;
1659
+ }
1660
+ }
1661
+
1662
+ // src/stacks/network-stack.ts
1663
+ import * as cdk5 from "aws-cdk-lib";
1664
+ import * as ec2 from "aws-cdk-lib/aws-ec2";
1665
+ import * as iam6 from "aws-cdk-lib/aws-iam";
1666
+ var MONGODB_PORT = 27017;
1667
+
1668
+ class FoundationNetwork extends cdk5.Stack {
1669
+ vpc;
1670
+ endpointSecurityGroup;
1671
+ egressSubnets;
1672
+ agentSecurityGroup;
1673
+ gatewaySecurityGroup;
1674
+ mountTargetSecurityGroup;
1675
+ constructor(scope, id, props) {
1676
+ super(scope, id, props);
1677
+ const { prefix } = props.instance.naming;
1678
+ const importedVpcId = this.node.tryGetContext("vpcId");
1679
+ const createVpcEndpoints = String(this.node.tryGetContext("createVpcEndpoints") ?? "true") !== "false";
1680
+ if (importedVpcId) {
1681
+ this.vpc = ec2.Vpc.fromLookup(this, "ImportedVpc", { vpcId: importedVpcId });
1682
+ this.egressSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
1683
+ } else {
1684
+ this.vpc = new ec2.Vpc(this, "FoundationVpc", {
1685
+ maxAzs: 2,
1686
+ natGateways: 2,
1687
+ subnetConfiguration: [
1688
+ { name: "ingress", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
1689
+ { name: "egress", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
1690
+ { name: "app", subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 }
1691
+ ]
1692
+ });
1693
+ this.egressSubnets = { subnetGroupName: "egress" };
1694
+ new cdk5.CfnOutput(this, "NatEgressIp", {
1695
+ value: cdk5.Fn.join(",", this.vpc.publicSubnets.map((subnet) => subnet.node.tryFindChild("EIP")?.attrPublicIp ?? "").filter((value) => value !== "")),
1696
+ description: "Public egress addresses (NAT gateway EIPs) for allowlisting this instance"
1697
+ });
1698
+ }
1699
+ const egressOnly = (constructId, description) => {
1700
+ const sg = new ec2.SecurityGroup(this, constructId, {
1701
+ vpc: this.vpc,
1702
+ description,
1703
+ allowAllOutbound: false
1704
+ });
1705
+ sg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS egress");
1706
+ sg.addEgressRule(ec2.Peer.ipv4(this.vpc.vpcCidrBlock), ec2.Port.udp(53), "DNS to VPC resolver");
1707
+ sg.addEgressRule(ec2.Peer.ipv4(this.vpc.vpcCidrBlock), ec2.Port.tcp(53), "DNS over TCP to VPC resolver");
1708
+ return sg;
1709
+ };
1710
+ this.agentSecurityGroup = egressOnly("AgentSg", `${prefix} AgentCore session ENIs: 443 + DNS egress only`);
1711
+ this.gatewaySecurityGroup = egressOnly("GatewaySg", `${prefix} gateway Lambda ENIs: 443 + DNS egress only`);
1712
+ if (capabilityEnabled(props.configPath, "mongodbReadonly")) {
1713
+ this.agentSecurityGroup.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(MONGODB_PORT), "MongoDB Atlas");
1714
+ }
1715
+ this.mountTargetSecurityGroup = new ec2.SecurityGroup(this, "MountTargetSg", {
1716
+ vpc: this.vpc,
1717
+ description: `${prefix} S3 Files mount targets: NFS from the agent ENIs only`,
1718
+ allowAllOutbound: false
1719
+ });
1720
+ this.mountTargetSecurityGroup.addIngressRule(this.agentSecurityGroup, ec2.Port.tcp(2049), "NFS from the AgentCore session ENIs");
1721
+ this.agentSecurityGroup.addEgressRule(this.mountTargetSecurityGroup, ec2.Port.tcp(2049), "NFS to the S3 Files mount targets");
1722
+ if (!createVpcEndpoints)
1723
+ return;
1724
+ const endpointSg = new ec2.SecurityGroup(this, "EndpointSg", {
1725
+ vpc: this.vpc,
1726
+ description: `${prefix} interface VPC endpoints: HTTPS from within the VPC only`,
1727
+ allowAllOutbound: false
1728
+ });
1729
+ endpointSg.addIngressRule(ec2.Peer.ipv4(this.vpc.vpcCidrBlock), ec2.Port.tcp(443), "HTTPS from VPC");
1730
+ this.endpointSecurityGroup = endpointSg;
1731
+ const endpointSubnets = importedVpcId ? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS } : { subnetGroupName: "app" };
1732
+ const gatewayEndpointSubnets = importedVpcId ? [{ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }] : [{ subnetGroupName: "app" }, { subnetGroupName: "egress" }];
1733
+ const sameAccountOnly = new iam6.PolicyStatement({
1734
+ effect: iam6.Effect.ALLOW,
1735
+ principals: [new iam6.AnyPrincipal],
1736
+ actions: ["*"],
1737
+ resources: ["*"],
1738
+ conditions: { StringEquals: { "aws:PrincipalAccount": this.account } }
1739
+ });
1740
+ const skipEndpoints = new Set(String(this.node.tryGetContext("skipEndpoints") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
1741
+ if (!skipEndpoints.has("s3")) {
1742
+ const s3Endpoint = this.vpc.addGatewayEndpoint("S3Endpoint", {
1743
+ service: ec2.GatewayVpcEndpointAwsService.S3,
1744
+ subnets: gatewayEndpointSubnets
1745
+ });
1746
+ s3Endpoint.addToPolicy(sameAccountOnly);
1747
+ s3Endpoint.addToPolicy(new iam6.PolicyStatement({
1748
+ sid: "EcrLayerBucket",
1749
+ effect: iam6.Effect.ALLOW,
1750
+ principals: [new iam6.AnyPrincipal],
1751
+ actions: ["s3:GetObject"],
1752
+ resources: [`arn:${this.partition}:s3:::prod-${this.region}-starport-layer-bucket/*`]
1753
+ }));
1754
+ }
1755
+ if (!skipEndpoints.has("dynamodb")) {
1756
+ const dynamoEndpoint = this.vpc.addGatewayEndpoint("DynamoDbEndpoint", {
1757
+ service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
1758
+ subnets: gatewayEndpointSubnets
1759
+ });
1760
+ dynamoEndpoint.addToPolicy(sameAccountOnly);
1761
+ }
1762
+ const interfaceEndpoints = [
1763
+ [
1764
+ "BedrockAgentCoreEndpoint",
1765
+ "bedrock-agentcore",
1766
+ new ec2.InterfaceVpcEndpointService(`com.amazonaws.${this.region}.bedrock-agentcore`, 443)
1767
+ ],
1768
+ [
1769
+ "SecretsManagerEndpoint",
1770
+ "secretsmanager",
1771
+ ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER
1772
+ ],
1773
+ ["CloudWatchLogsEndpoint", "logs", ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS]
1774
+ ];
1775
+ for (const [endpointId, skipName, service] of interfaceEndpoints) {
1776
+ if (skipEndpoints.has(skipName))
1777
+ continue;
1778
+ const endpoint = this.vpc.addInterfaceEndpoint(endpointId, {
1779
+ service,
1780
+ subnets: endpointSubnets,
1781
+ securityGroups: [endpointSg],
1782
+ privateDnsEnabled: true
1783
+ });
1784
+ endpoint.addToPolicy(sameAccountOnly);
1785
+ }
1786
+ }
1787
+ }
1788
+
1789
+ // src/stacks/newsletter-stack.ts
1790
+ import * as cdk6 from "aws-cdk-lib";
1791
+ import * as apigateway2 from "aws-cdk-lib/aws-apigateway";
1792
+ import * as acm from "aws-cdk-lib/aws-certificatemanager";
1793
+ import * as cloudwatch2 from "aws-cdk-lib/aws-cloudwatch";
1794
+ import * as cloudwatchActions from "aws-cdk-lib/aws-cloudwatch-actions";
1795
+ import * as dynamodb2 from "aws-cdk-lib/aws-dynamodb";
1796
+ import * as iam7 from "aws-cdk-lib/aws-iam";
1797
+ import * as kms2 from "aws-cdk-lib/aws-kms";
1798
+ import * as lambda2 from "aws-cdk-lib/aws-lambda";
1799
+ import * as lambdaEventSources2 from "aws-cdk-lib/aws-lambda-event-sources";
1800
+ import * as route53 from "aws-cdk-lib/aws-route53";
1801
+ import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
1802
+ import * as secretsmanager2 from "aws-cdk-lib/aws-secretsmanager";
1803
+ import * as sns2 from "aws-cdk-lib/aws-sns";
1804
+ import * as snsSubscriptions from "aws-cdk-lib/aws-sns-subscriptions";
1805
+ import * as sqs2 from "aws-cdk-lib/aws-sqs";
1806
+ import * as wafv2 from "aws-cdk-lib/aws-wafv2";
1807
+ var ACTIVE_SUBSCRIBER_INDEX = "ScopeStateIndex";
1808
+
1809
+ class NewsletterStack extends cdk6.Stack {
1810
+ subscriberTable;
1811
+ campaignTable;
1812
+ campaignQueue;
1813
+ campaignDlq;
1814
+ suppressionDlq;
1815
+ api;
1816
+ tokenSigningSecret;
1817
+ campaignOperatorRole;
1818
+ constructor(scope, id, props) {
1819
+ super(scope, id, props);
1820
+ if (!props.instance.newsletter.enabled)
1821
+ throw new Error(`cannot create NewsletterStack: newsletter is disabled for ${props.instance.name}`);
1822
+ const config = props.instance.newsletter;
1823
+ const names = instanceNames(props.instance);
1824
+ const manifest = JSON.stringify(newsletterManifestFor(props.instance));
1825
+ if (Buffer.byteLength(manifest, "utf8") > 3000)
1826
+ throw new Error("newsletter manifest exceeds the safe Lambda environment limit");
1827
+ const publicPartitionKeys = config.scopes.flatMap(({ businessId, newsletterId }) => [
1828
+ `SCOPE#${businessId}#${newsletterId}`,
1829
+ `QUOTA#${businessId}#${newsletterId}`
1830
+ ]);
1831
+ const senderAddress = senderAddressFor(config.sender);
1832
+ const identityArn = `arn:${this.partition}:ses:${this.region}:${this.account}:identity/${config.domainIdentity}`;
1833
+ const configurationSetArn = `arn:${this.partition}:ses:${this.region}:${this.account}:configuration-set/${names.newsletterConfigurationSet}`;
1834
+ const zone = route53.HostedZone.fromHostedZoneAttributes(this, "NewsletterHostedZone", {
1835
+ hostedZoneId: config.hostedZoneId,
1836
+ zoneName: config.hostedZoneName
1837
+ });
1838
+ const newsletterKey = new kms2.Key(this, "NewsletterKey", {
1839
+ description: `${props.instance.displayName} newsletter PII and queue CMK`,
1840
+ enableKeyRotation: true,
1841
+ alias: names.newsletterKeyAlias,
1842
+ removalPolicy: cdk6.RemovalPolicy.RETAIN
1843
+ });
1844
+ this.subscriberTable = new dynamodb2.Table(this, "SubscriberTable", {
1845
+ partitionKey: { name: "pk", type: dynamodb2.AttributeType.STRING },
1846
+ sortKey: { name: "sk", type: dynamodb2.AttributeType.STRING },
1847
+ billingMode: dynamodb2.BillingMode.PAY_PER_REQUEST,
1848
+ encryption: dynamodb2.TableEncryption.CUSTOMER_MANAGED,
1849
+ encryptionKey: newsletterKey,
1850
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1851
+ timeToLiveAttribute: "ttl",
1852
+ removalPolicy: cdk6.RemovalPolicy.RETAIN
1853
+ });
1854
+ this.subscriberTable.addGlobalSecondaryIndex({
1855
+ indexName: ACTIVE_SUBSCRIBER_INDEX,
1856
+ partitionKey: { name: "scopeState", type: dynamodb2.AttributeType.STRING },
1857
+ sortKey: { name: "emailHash", type: dynamodb2.AttributeType.STRING },
1858
+ projectionType: dynamodb2.ProjectionType.ALL
1859
+ });
1860
+ this.campaignTable = new dynamodb2.Table(this, "CampaignTable", {
1861
+ partitionKey: { name: "pk", type: dynamodb2.AttributeType.STRING },
1862
+ sortKey: { name: "sk", type: dynamodb2.AttributeType.STRING },
1863
+ billingMode: dynamodb2.BillingMode.PAY_PER_REQUEST,
1864
+ encryption: dynamodb2.TableEncryption.CUSTOMER_MANAGED,
1865
+ encryptionKey: newsletterKey,
1866
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1867
+ removalPolicy: cdk6.RemovalPolicy.RETAIN
1868
+ });
1869
+ this.tokenSigningSecret = new secretsmanager2.Secret(this, "TokenSigningSecret", {
1870
+ secretName: names.newsletterTokenSigningSecret,
1871
+ description: "Newsletter confirmation and unsubscribe token HMAC secret",
1872
+ encryptionKey: newsletterKey,
1873
+ generateSecretString: { passwordLength: 64, excludePunctuation: true },
1874
+ removalPolicy: cdk6.RemovalPolicy.RETAIN
1875
+ });
1876
+ this.campaignDlq = new sqs2.Queue(this, "CampaignDlq", {
1877
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-campaign-dlq.fifo`,
1878
+ fifo: true,
1879
+ encryption: sqs2.QueueEncryption.KMS,
1880
+ encryptionMasterKey: newsletterKey,
1881
+ enforceSSL: true,
1882
+ retentionPeriod: cdk6.Duration.days(14)
1883
+ });
1884
+ this.campaignQueue = new sqs2.Queue(this, "CampaignQueue", {
1885
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-campaign.fifo`,
1886
+ fifo: true,
1887
+ contentBasedDeduplication: false,
1888
+ encryption: sqs2.QueueEncryption.KMS,
1889
+ encryptionMasterKey: newsletterKey,
1890
+ enforceSSL: true,
1891
+ visibilityTimeout: cdk6.Duration.minutes(15),
1892
+ retentionPeriod: cdk6.Duration.days(14),
1893
+ deadLetterQueue: { queue: this.campaignDlq, maxReceiveCount: 3 }
1894
+ });
1895
+ this.suppressionDlq = new sqs2.Queue(this, "SuppressionDlq", {
1896
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-suppression-dlq`,
1897
+ encryption: sqs2.QueueEncryption.KMS,
1898
+ encryptionMasterKey: newsletterKey,
1899
+ enforceSSL: true,
1900
+ retentionPeriod: cdk6.Duration.days(14)
1901
+ });
1902
+ const suppressionQueue = new sqs2.Queue(this, "SuppressionQueue", {
1903
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-suppression`,
1904
+ encryption: sqs2.QueueEncryption.KMS,
1905
+ encryptionMasterKey: newsletterKey,
1906
+ enforceSSL: true,
1907
+ visibilityTimeout: cdk6.Duration.minutes(5),
1908
+ retentionPeriod: cdk6.Duration.days(14),
1909
+ deadLetterQueue: { queue: this.suppressionDlq, maxReceiveCount: 3 }
1910
+ });
1911
+ const publicRole = lambdaRole(this, "PublicFunctionRole", "public newsletter opt-in Lambda");
1912
+ publicRole.addToPolicy(new iam7.PolicyStatement({
1913
+ sid: "SubscriberOptInState",
1914
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
1915
+ resources: [this.subscriberTable.tableArn],
1916
+ conditions: {
1917
+ "ForAllValues:StringEquals": {
1918
+ "dynamodb:LeadingKeys": publicPartitionKeys
1919
+ }
1920
+ }
1921
+ }));
1922
+ this.tokenSigningSecret.grantRead(publicRole);
1923
+ this.campaignQueue.grantSendMessages(publicRole);
1924
+ const suppressionRole = lambdaRole(this, "SuppressionFunctionRole", "SES newsletter suppression Lambda");
1925
+ suppressionRole.addToPolicy(new iam7.PolicyStatement({
1926
+ sid: "SubscriberSuppressionState",
1927
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem"],
1928
+ resources: [this.subscriberTable.tableArn]
1929
+ }));
1930
+ const campaignRole = lambdaRole(this, "CampaignFunctionRole", "newsletter campaign worker Lambda");
1931
+ campaignRole.addToPolicy(new iam7.PolicyStatement({
1932
+ sid: "ReadActiveSubscribers",
1933
+ actions: ["dynamodb:GetItem", "dynamodb:Query"],
1934
+ resources: [
1935
+ this.subscriberTable.tableArn,
1936
+ `${this.subscriberTable.tableArn}/index/${ACTIVE_SUBSCRIBER_INDEX}`
1937
+ ]
1938
+ }));
1939
+ campaignRole.addToPolicy(new iam7.PolicyStatement({
1940
+ sid: "CampaignDeliveryState",
1941
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
1942
+ resources: [this.campaignTable.tableArn]
1943
+ }));
1944
+ this.campaignQueue.grantConsumeMessages(campaignRole);
1945
+ this.campaignQueue.grantSendMessages(campaignRole);
1946
+ this.tokenSigningSecret.grantRead(campaignRole);
1947
+ grantSesSend(campaignRole, identityArn, senderAddress);
1948
+ const publicFunction = new lambda2.Function(this, "PublicFunction", {
1949
+ runtime: lambda2.Runtime.NODEJS_22_X,
1950
+ handler: "index.handler",
1951
+ code: lambdaCode(this, "newsletter-public"),
1952
+ role: publicRole,
1953
+ timeout: cdk6.Duration.seconds(15),
1954
+ memorySize: 256,
1955
+ environment: {
1956
+ NEWSLETTER_MANIFEST: manifest,
1957
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName,
1958
+ NEWSLETTER_TOKEN_SIGNING_SECRET_ID: this.tokenSigningSecret.secretName,
1959
+ NEWSLETTER_CAMPAIGN_QUEUE_URL: this.campaignQueue.queueUrl
1960
+ }
1961
+ });
1962
+ const suppressionFunction = new lambda2.Function(this, "SuppressionFunction", {
1963
+ runtime: lambda2.Runtime.NODEJS_22_X,
1964
+ handler: "index.handler",
1965
+ code: lambdaCode(this, "newsletter-ses-events"),
1966
+ role: suppressionRole,
1967
+ timeout: cdk6.Duration.seconds(30),
1968
+ memorySize: 256,
1969
+ environment: {
1970
+ NEWSLETTER_MANIFEST: manifest,
1971
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName
1972
+ }
1973
+ });
1974
+ suppressionFunction.addEventSource(new lambdaEventSources2.SqsEventSource(suppressionQueue, {
1975
+ batchSize: 1,
1976
+ reportBatchItemFailures: false
1977
+ }));
1978
+ const campaignFunction = new lambda2.Function(this, "CampaignFunction", {
1979
+ runtime: lambda2.Runtime.NODEJS_22_X,
1980
+ handler: "index.handler",
1981
+ code: lambdaCode(this, "newsletter-campaign"),
1982
+ role: campaignRole,
1983
+ timeout: cdk6.Duration.minutes(15),
1984
+ memorySize: 512,
1985
+ environment: {
1986
+ NEWSLETTER_MANIFEST: manifest,
1987
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName,
1988
+ NEWSLETTER_CAMPAIGN_TABLE_NAME: this.campaignTable.tableName,
1989
+ NEWSLETTER_CAMPAIGN_QUEUE_URL: this.campaignQueue.queueUrl,
1990
+ NEWSLETTER_TOKEN_SIGNING_SECRET_ID: this.tokenSigningSecret.secretName,
1991
+ NEWSLETTER_CONFIGURATION_SET: names.newsletterConfigurationSet
1992
+ }
1993
+ });
1994
+ campaignFunction.addEventSource(new lambdaEventSources2.SqsEventSource(this.campaignQueue, {
1995
+ batchSize: 1,
1996
+ reportBatchItemFailures: false,
1997
+ maxConcurrency: 2
1998
+ }));
1999
+ const eventsTopicName = `${props.instance.naming.secretPrefix}-newsletter-ses-events`;
2000
+ const eventsTopic = new sns2.Topic(this, "SesEventsTopic", {
2001
+ displayName: `${props.instance.displayName} newsletter SES events`,
2002
+ topicName: eventsTopicName,
2003
+ masterKey: newsletterKey
2004
+ });
2005
+ newsletterKey.addToResourcePolicy(new iam7.PolicyStatement({
2006
+ sid: "AllowSesToEncryptNewsletterEvents",
2007
+ principals: [new iam7.ServicePrincipal("ses.amazonaws.com")],
2008
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
2009
+ resources: ["*"],
2010
+ conditions: {
2011
+ StringEquals: {
2012
+ "aws:SourceAccount": this.account,
2013
+ "kms:EncryptionContext:aws:sns:topicArn": `arn:${this.partition}:sns:${this.region}:${this.account}:${eventsTopicName}`
2014
+ }
2015
+ }
2016
+ }));
2017
+ eventsTopic.addToResourcePolicy(new iam7.PolicyStatement({
2018
+ sid: "AllowOnlyThisSesIdentity",
2019
+ effect: iam7.Effect.ALLOW,
2020
+ principals: [new iam7.ServicePrincipal("ses.amazonaws.com")],
2021
+ actions: ["sns:Publish"],
2022
+ resources: [eventsTopic.topicArn],
2023
+ conditions: {
2024
+ StringEquals: { "aws:SourceAccount": this.account },
2025
+ ArnLike: { "aws:SourceArn": configurationSetArn }
2026
+ }
2027
+ }));
2028
+ eventsTopic.addSubscription(new snsSubscriptions.SqsSubscription(suppressionQueue, {
2029
+ rawMessageDelivery: true
2030
+ }));
2031
+ const identity = new cdk6.CfnResource(this, "NewsletterDomainIdentity", {
2032
+ type: "AWS::SES::EmailIdentity",
2033
+ properties: {
2034
+ EmailIdentity: config.domainIdentity,
2035
+ DkimAttributes: { SigningEnabled: true }
2036
+ }
2037
+ });
2038
+ const configurationSet = new cdk6.CfnResource(this, "NewsletterConfigurationSet", {
2039
+ type: "AWS::SES::ConfigurationSet",
2040
+ properties: { Name: names.newsletterConfigurationSet }
2041
+ });
2042
+ const eventDestination = new cdk6.CfnResource(this, "NewsletterEventDestination", {
2043
+ type: "AWS::SES::ConfigurationSetEventDestination",
2044
+ properties: {
2045
+ ConfigurationSetName: names.newsletterConfigurationSet,
2046
+ EventDestination: {
2047
+ Name: "NewsletterDeliveryAndSuppressionEvents",
2048
+ Enabled: true,
2049
+ MatchingEventTypes: ["DELIVERY", "BOUNCE", "COMPLAINT"],
2050
+ SnsDestination: { TopicARN: eventsTopic.topicArn }
2051
+ }
2052
+ }
2053
+ });
2054
+ eventDestination.addResourceDependency(configurationSet);
2055
+ eventDestination.node.addDependency(eventsTopic);
2056
+ for (const token of ["1", "2", "3"])
2057
+ new route53.CfnRecordSet(this, `NewsletterDkim${token}`, {
2058
+ hostedZoneId: config.hostedZoneId,
2059
+ name: identity.getAtt(`DkimDNSTokenName${token}`).toString(),
2060
+ type: "CNAME",
2061
+ ttl: "1800",
2062
+ resourceRecords: [identity.getAtt(`DkimDNSTokenValue${token}`).toString()]
2063
+ });
2064
+ configurationSet.node.addDependency(identity);
2065
+ this.api = new apigateway2.RestApi(this, "NewsletterApi", {
2066
+ restApiName: `${props.instance.naming.secretPrefix}-newsletter`,
2067
+ description: `${props.instance.displayName} public confirmed newsletter API`,
2068
+ endpointTypes: [apigateway2.EndpointType.REGIONAL],
2069
+ disableExecuteApiEndpoint: true,
2070
+ deployOptions: {
2071
+ stageName: "prod",
2072
+ throttlingRateLimit: 2,
2073
+ throttlingBurstLimit: 4,
2074
+ metricsEnabled: true
2075
+ },
2076
+ defaultCorsPreflightOptions: {
2077
+ allowOrigins: [...config.allowedOrigins],
2078
+ allowMethods: ["GET", "POST", "OPTIONS"],
2079
+ allowHeaders: ["Content-Type"]
2080
+ },
2081
+ cloudWatchRole: false
2082
+ });
2083
+ const v1 = this.api.root.addResource("v1");
2084
+ const businesses = v1.addResource("businesses");
2085
+ const business = businesses.addResource("{businessId}");
2086
+ const newsletters = business.addResource("newsletters");
2087
+ const newsletter = newsletters.addResource("{newsletterId}");
2088
+ const integration = new apigateway2.LambdaIntegration(publicFunction);
2089
+ newsletter.addResource("subscribe").addMethod("POST", integration);
2090
+ newsletter.addResource("confirm").addMethod("GET", integration);
2091
+ newsletter.addResource("unsubscribe").addMethod("GET", integration);
2092
+ newsletter.getResource("confirm")?.addMethod("POST", integration);
2093
+ newsletter.getResource("unsubscribe")?.addMethod("POST", integration);
2094
+ const webAcl = new wafv2.CfnWebACL(this, "NewsletterWebAcl", {
2095
+ scope: "REGIONAL",
2096
+ defaultAction: { allow: {} },
2097
+ visibilityConfig: {
2098
+ cloudWatchMetricsEnabled: true,
2099
+ metricName: `${props.instance.naming.secretPrefix}-newsletter-api`,
2100
+ sampledRequestsEnabled: false
2101
+ },
2102
+ rules: [
2103
+ {
2104
+ name: "PerIpSubscribeAbuseLimit",
2105
+ priority: 0,
2106
+ action: { block: {} },
2107
+ statement: {
2108
+ rateBasedStatement: {
2109
+ aggregateKeyType: "IP",
2110
+ limit: 10,
2111
+ scopeDownStatement: {
2112
+ byteMatchStatement: {
2113
+ fieldToMatch: { uriPath: {} },
2114
+ positionalConstraint: "ENDS_WITH",
2115
+ searchString: "/subscribe",
2116
+ textTransformations: [{ priority: 0, type: "NONE" }]
2117
+ }
2118
+ }
2119
+ }
2120
+ },
2121
+ visibilityConfig: {
2122
+ cloudWatchMetricsEnabled: true,
2123
+ metricName: `${props.instance.naming.secretPrefix}-newsletter-subscribe-rate`,
2124
+ sampledRequestsEnabled: false
2125
+ }
2126
+ }
2127
+ ]
2128
+ });
2129
+ const webAclAssociation = new wafv2.CfnWebACLAssociation(this, "NewsletterWebAclAssociation", {
2130
+ resourceArn: `arn:${this.partition}:apigateway:${this.region}::/restapis/${this.api.restApiId}/stages/${this.api.deploymentStage.stageName}`,
2131
+ webAclArn: webAcl.attrArn
2132
+ });
2133
+ webAclAssociation.node.addDependency(this.api.deploymentStage);
2134
+ const certificate = new acm.Certificate(this, "NewsletterCertificate", {
2135
+ domainName: config.apiDomain,
2136
+ validation: acm.CertificateValidation.fromDns(zone)
2137
+ });
2138
+ const domain = new apigateway2.DomainName(this, "NewsletterDomain", {
2139
+ domainName: config.apiDomain,
2140
+ certificate,
2141
+ endpointType: apigateway2.EndpointType.REGIONAL,
2142
+ securityPolicy: apigateway2.SecurityPolicy.TLS_1_2
2143
+ });
2144
+ new apigateway2.BasePathMapping(this, "NewsletterBasePath", {
2145
+ domainName: domain,
2146
+ restApi: this.api
2147
+ });
2148
+ new route53.ARecord(this, "NewsletterApiAlias", {
2149
+ zone,
2150
+ recordName: config.apiDomain,
2151
+ target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayDomain(domain))
2152
+ });
2153
+ this.campaignOperatorRole = new iam7.Role(this, "CampaignOperatorRole", {
2154
+ roleName: names.newsletterCampaignOperatorRole,
2155
+ description: "Human-only newsletter campaign draft, preview and approval role",
2156
+ assumedBy: new iam7.AccountPrincipal(this.account),
2157
+ maxSessionDuration: cdk6.Duration.hours(1)
2158
+ });
2159
+ this.campaignOperatorRole.addToPolicy(new iam7.PolicyStatement({
2160
+ sid: "CampaignDraftAndApproval",
2161
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
2162
+ resources: [this.campaignTable.tableArn]
2163
+ }));
2164
+ this.campaignQueue.grantSendMessages(this.campaignOperatorRole);
2165
+ this.campaignDlq.grantConsumeMessages(this.campaignOperatorRole);
2166
+ this.campaignOperatorRole.addToPolicy(new iam7.PolicyStatement({
2167
+ sid: "RedriveCampaignFailures",
2168
+ actions: [
2169
+ "sqs:StartMessageMoveTask",
2170
+ "sqs:CancelMessageMoveTask",
2171
+ "sqs:ListMessageMoveTasks"
2172
+ ],
2173
+ resources: [this.campaignDlq.queueArn]
2174
+ }));
2175
+ this.campaignOperatorRole.addToPolicy(new iam7.PolicyStatement({
2176
+ sid: "ReadNewsletterStackOutputs",
2177
+ actions: ["cloudformation:DescribeStacks"],
2178
+ resources: ["*"]
2179
+ }));
2180
+ this.campaignOperatorRole.addToPolicy(new iam7.PolicyStatement({
2181
+ sid: "RecordApprovalActor",
2182
+ actions: ["sts:GetCallerIdentity"],
2183
+ resources: ["*"]
2184
+ }));
2185
+ const alarmTopic = new sns2.Topic(this, "NewsletterAlarmTopic", {
2186
+ displayName: `${props.instance.displayName} newsletter alarms`
2187
+ });
2188
+ if (props.alarmEmail !== undefined)
2189
+ new sns2.Subscription(this, "NewsletterAlarmEmail", {
2190
+ topic: alarmTopic,
2191
+ protocol: sns2.SubscriptionProtocol.EMAIL,
2192
+ endpoint: props.alarmEmail
2193
+ });
2194
+ const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
2195
+ const alarms = [
2196
+ new cloudwatch2.Alarm(this, "CampaignDlqDepthAlarm", {
2197
+ alarmDescription: "Newsletter campaign messages are waiting in the DLQ",
2198
+ metric: this.campaignDlq.metricApproximateNumberOfMessagesVisible({
2199
+ period: cdk6.Duration.minutes(5),
2200
+ statistic: "Maximum"
2201
+ }),
2202
+ threshold: 1,
2203
+ evaluationPeriods: 1,
2204
+ comparisonOperator: cloudwatch2.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2205
+ treatMissingData: cloudwatch2.TreatMissingData.NOT_BREACHING
2206
+ }),
2207
+ new cloudwatch2.Alarm(this, "SuppressionDlqDepthAlarm", {
2208
+ alarmDescription: "Newsletter suppression events are waiting in the DLQ",
2209
+ metric: this.suppressionDlq.metricApproximateNumberOfMessagesVisible({
2210
+ period: cdk6.Duration.minutes(5),
2211
+ statistic: "Maximum"
2212
+ }),
2213
+ threshold: 1,
2214
+ evaluationPeriods: 1,
2215
+ comparisonOperator: cloudwatch2.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2216
+ treatMissingData: cloudwatch2.TreatMissingData.NOT_BREACHING
2217
+ }),
2218
+ new cloudwatch2.Alarm(this, "CampaignWorkerErrorsAlarm", {
2219
+ alarmDescription: "Newsletter campaign worker is failing",
2220
+ metric: campaignFunction.metricErrors({
2221
+ period: cdk6.Duration.minutes(5),
2222
+ statistic: "Sum"
2223
+ }),
2224
+ threshold: 1,
2225
+ evaluationPeriods: 1,
2226
+ comparisonOperator: cloudwatch2.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2227
+ treatMissingData: cloudwatch2.TreatMissingData.NOT_BREACHING
2228
+ }),
2229
+ new cloudwatch2.Alarm(this, "SuppressionWorkerErrorsAlarm", {
2230
+ alarmDescription: "Newsletter suppression worker is failing",
2231
+ metric: suppressionFunction.metricErrors({
2232
+ period: cdk6.Duration.minutes(5),
2233
+ statistic: "Sum"
2234
+ }),
2235
+ threshold: 1,
2236
+ evaluationPeriods: 1,
2237
+ comparisonOperator: cloudwatch2.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2238
+ treatMissingData: cloudwatch2.TreatMissingData.NOT_BREACHING
2239
+ }),
2240
+ new cloudwatch2.Alarm(this, "PublicFunctionErrorsAlarm", {
2241
+ alarmDescription: "Newsletter public Lambda is failing",
2242
+ metric: publicFunction.metricErrors({ period: cdk6.Duration.minutes(5), statistic: "Sum" }),
2243
+ threshold: 1,
2244
+ evaluationPeriods: 1,
2245
+ comparisonOperator: cloudwatch2.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2246
+ treatMissingData: cloudwatch2.TreatMissingData.NOT_BREACHING
2247
+ })
2248
+ ];
2249
+ for (const alarm of alarms)
2250
+ alarm.addAlarmAction(alarmAction);
2251
+ new cdk6.CfnOutput(this, "NewsletterApiUrl", { value: `https://${config.apiDomain}/v1` });
2252
+ new cdk6.CfnOutput(this, "SubscriberTableName", { value: this.subscriberTable.tableName });
2253
+ new cdk6.CfnOutput(this, "CampaignTableName", { value: this.campaignTable.tableName });
2254
+ new cdk6.CfnOutput(this, "CampaignQueueUrl", { value: this.campaignQueue.queueUrl });
2255
+ new cdk6.CfnOutput(this, "CampaignDlqArn", { value: this.campaignDlq.queueArn });
2256
+ new cdk6.CfnOutput(this, "CampaignDlqUrl", { value: this.campaignDlq.queueUrl });
2257
+ new cdk6.CfnOutput(this, "CampaignOperatorRoleArn", {
2258
+ value: this.campaignOperatorRole.roleArn
2259
+ });
2260
+ new cdk6.CfnOutput(this, "NewsletterConfigurationSetName", {
2261
+ value: names.newsletterConfigurationSet
2262
+ });
2263
+ }
2264
+ }
2265
+ function lambdaRole(scope, id, description) {
2266
+ return new iam7.Role(scope, id, {
2267
+ assumedBy: new iam7.ServicePrincipal("lambda.amazonaws.com"),
2268
+ description,
2269
+ managedPolicies: [
2270
+ iam7.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
2271
+ ]
2272
+ });
2273
+ }
2274
+ function grantSesSend(role, identityArn, senderAddress) {
2275
+ role.addToPolicy(new iam7.PolicyStatement({
2276
+ sid: "SendFromNewsletterIdentity",
2277
+ actions: ["ses:SendEmail"],
2278
+ resources: [identityArn],
2279
+ conditions: { StringEquals: { "ses:FromAddress": senderAddress } }
2280
+ }));
2281
+ }
2282
+ function senderAddressFor(sender) {
2283
+ const address = /<([^>]+)>$/.exec(sender)?.[1];
2284
+ if (address === undefined)
2285
+ throw new Error("newsletter sender is not a mailbox");
2286
+ return address;
2287
+ }
2288
+
2289
+ // src/stacks/pipeline-stack.ts
2290
+ import * as cdk7 from "aws-cdk-lib";
2291
+ import * as codebuild from "aws-cdk-lib/aws-codebuild";
2292
+ import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
2293
+ import * as actions from "aws-cdk-lib/aws-codepipeline-actions";
2294
+ import * as iam8 from "aws-cdk-lib/aws-iam";
2295
+ import * as sns3 from "aws-cdk-lib/aws-sns";
2296
+ import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
2297
+ var DEFAULT_BUILDSPEC_PATH = "buildspec.yml";
2298
+ var BUILD_TIMEOUT = cdk7.Duration.minutes(60);
2299
+
2300
+ class FoundationPipeline extends cdk7.Stack {
2301
+ pipeline;
2302
+ buildRole;
2303
+ constructor(scope, id, props) {
2304
+ super(scope, id, props);
2305
+ const names = instanceNames(props.instance);
2306
+ const { deploy } = props.instance;
2307
+ const customization = props.instance.customization;
2308
+ if (deploy.connectionArn === undefined)
2309
+ throw new Error(`instance ${props.instance.name}: deploy.connectionArn is required for a pipeline`);
2310
+ const [owner = "", repo = ""] = props.instance.github.repo.split("/");
2311
+ this.buildRole = new iam8.Role(this, "BuildRole", {
2312
+ description: `CodePipeline deployer for ${props.instance.name}`,
2313
+ assumedBy: new iam8.ServicePrincipal("codebuild.amazonaws.com"),
2314
+ inlinePolicies: {
2315
+ FoundationDeploy: new iam8.PolicyDocument({
2316
+ statements: deployStatements(this, names, { bucket: props.bucket, dataKey: props.dataKey }, props.instance)
2317
+ })
2318
+ }
2319
+ });
2320
+ const failureTopic = new sns3.Topic(this, "FailureTopic", {
2321
+ displayName: `${props.instance.displayName} deploy failures`
2322
+ });
2323
+ const alarmEmail = props.alarmEmail ?? props.instance.aws.alarmEmail;
2324
+ if (alarmEmail !== undefined && alarmEmail !== "")
2325
+ failureTopic.addSubscription(new subscriptions.EmailSubscription(alarmEmail));
2326
+ const project = new codebuild.PipelineProject(this, "Build", {
2327
+ projectName: `${props.instance.naming.prefix}Deploy`,
2328
+ description: `Build and deploy ${props.instance.name} from ${props.instance.github.repo}@${deploy.branch}`,
2329
+ role: this.buildRole,
2330
+ timeout: BUILD_TIMEOUT,
2331
+ environment: {
2332
+ buildImage: codebuild.LinuxArmBuildImage.AMAZON_LINUX_2023_STANDARD_3_0,
2333
+ computeType: codebuild.ComputeType.LARGE,
2334
+ privileged: true
2335
+ },
2336
+ cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER, codebuild.LocalCacheMode.SOURCE),
2337
+ environmentVariables: {
2338
+ FOUNDATION_INSTANCE_FILE: { value: props.instanceFilePath },
2339
+ FOUNDATION_NO_PROFILE: { value: "1" },
2340
+ FOUNDATION_IMAGE_CACHE: { value: "local" },
2341
+ FOUNDATION_SKIP_POST_DEPLOY: { value: "1" }
2342
+ },
2343
+ buildSpec: codebuild.BuildSpec.fromSourceFilename(props.buildspecPath ?? DEFAULT_BUILDSPEC_PATH)
2344
+ });
2345
+ const source = new codepipeline.Artifact("Source");
2346
+ const sourceActions = [
2347
+ new actions.CodeStarConnectionsSourceAction({
2348
+ actionName: "GitHub",
2349
+ connectionArn: deploy.connectionArn,
2350
+ owner,
2351
+ repo,
2352
+ branch: deploy.branch,
2353
+ output: source,
2354
+ triggerOnPush: true
2355
+ })
2356
+ ];
2357
+ let customizationSource;
2358
+ let customizationSourceAction;
2359
+ if (customization !== undefined) {
2360
+ const [customizationOwner = "", customizationRepo = ""] = customization.repository.split("/");
2361
+ const customizationArtifact = new codepipeline.Artifact(CUSTOMIZATION_ARTIFACT_NAME);
2362
+ customizationSource = customizationArtifact;
2363
+ customizationSourceAction = new actions.CodeStarConnectionsSourceAction({
2364
+ actionName: CUSTOMIZATION_ARTIFACT_NAME,
2365
+ connectionArn: deploy.connectionArn,
2366
+ owner: customizationOwner,
2367
+ repo: customizationRepo,
2368
+ branch: customization.branch,
2369
+ output: customizationArtifact,
2370
+ triggerOnPush: true
2371
+ });
2372
+ sourceActions.push(customizationSourceAction);
2373
+ }
2374
+ const stages = [{ stageName: "Source", actions: sourceActions }];
2375
+ if (customization !== undefined && customizationSourceAction !== undefined) {
2376
+ const customizationLinkPath = customization.path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
2377
+ stages.push({
2378
+ stageName: "Approval",
2379
+ actions: [
2380
+ new actions.ManualApprovalAction({
2381
+ actionName: "ReviewCustomization",
2382
+ notificationTopic: failureTopic,
2383
+ additionalInformation: `Review ${customization.path}: approve only intended runtime behavior; infrastructure and credentials stay deployment-owned.`,
2384
+ externalEntityLink: `https://github.com/${customization.repository}/blob/${customizationSourceAction.variables.commitId}/${customizationLinkPath}`
2385
+ })
2386
+ ]
2387
+ });
2388
+ }
2389
+ stages.push({
2390
+ stageName: "Deploy",
2391
+ actions: [
2392
+ new actions.CodeBuildAction({
2393
+ actionName: "BuildAndDeploy",
2394
+ project,
2395
+ input: source,
2396
+ extraInputs: customizationSource === undefined ? undefined : [customizationSource]
2397
+ })
2398
+ ]
2399
+ });
2400
+ this.pipeline = new codepipeline.Pipeline(this, "Pipeline", {
2401
+ pipelineName: `${props.instance.naming.prefix}Deploy`,
2402
+ pipelineType: codepipeline.PipelineType.V2,
2403
+ restartExecutionOnUpdate: false,
2404
+ stages
2405
+ });
2406
+ this.pipeline.onStateChange("OnFailure", {
2407
+ description: `${props.instance.name} deploy failed`,
2408
+ eventPattern: { detail: { state: ["FAILED"] } },
2409
+ target: new cdk7.aws_events_targets.SnsTopic(failureTopic)
2410
+ });
2411
+ new cdk7.CfnOutput(this, "PipelineName", { value: this.pipeline.pipelineName });
2412
+ new cdk7.CfnOutput(this, "BuildRoleArn", { value: this.buildRole.roleArn });
2413
+ }
2414
+ }
2415
+
2416
+ export { FoundationAgent, FoundationApi, deployStatements, FoundationCi, FoundationData, FoundationNetwork, NewsletterStack, DEFAULT_BUILDSPEC_PATH, BUILD_TIMEOUT, FoundationPipeline };