@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.
- package/README.md +174 -0
- package/agent-image/Dockerfile +254 -0
- package/agent-image/bin/aws +36 -0
- package/agent-image/bin/gh +193 -0
- package/agent-image/bin/git-credential-sky +89 -0
- package/agent-image/security-overlay.yml +176 -0
- package/cdk.json +6 -0
- package/dist/bin/app.js +112 -0
- package/dist/bin/foundation-deploy.js +1906 -0
- package/dist/bin/release-account.js +154 -0
- package/dist/chunk-4aye5cee.js +2416 -0
- package/dist/chunk-9ddxyvq2.js +1455 -0
- package/dist/chunk-v7tz8g50.js +428 -0
- package/dist/src/index.js +88 -0
- package/package.json +38 -0
- package/pipeline/buildspec.yml +34 -0
- package/src/artifacts.ts +318 -0
- package/src/deploy/assets/github-app-manifest.yml +29 -0
- package/src/deploy/assets/slack-app-manifest.yml +95 -0
- package/src/deploy/aws.ts +265 -0
- package/src/deploy/cli.ts +212 -0
- package/src/deploy/config-sync.ts +93 -0
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy.ts +566 -0
- package/src/deploy/endpoint.ts +242 -0
- package/src/deploy/github-app-create.ts +154 -0
- package/src/deploy/github-app-manifest.ts +53 -0
- package/src/deploy/image.ts +80 -0
- package/src/deploy/instance.ts +87 -0
- package/src/deploy/license-cache.ts +47 -0
- package/src/deploy/license.ts +272 -0
- package/src/deploy/paths.ts +65 -0
- package/src/deploy/post-deploy.ts +97 -0
- package/src/deploy/release.ts +282 -0
- package/src/deploy/runtime-secret.ts +241 -0
- package/src/deploy/setup.ts +393 -0
- package/src/deploy/sh.ts +74 -0
- package/src/deploy/slack-manifest.ts +112 -0
- package/src/deploy/stage-customization.ts +224 -0
- package/src/deploy/tracing.ts +243 -0
- package/src/deploy-permissions.ts +165 -0
- package/src/index.ts +60 -0
- package/src/lambda-bundle-context.ts +64 -0
- package/src/names.ts +170 -0
- package/src/release/kms.ts +86 -0
- package/src/release/manifest.ts +265 -0
- package/src/stacks/agent-stack.ts +938 -0
- package/src/stacks/api-stack.ts +1005 -0
- package/src/stacks/ci-stack.ts +96 -0
- package/src/stacks/data-stack.ts +446 -0
- package/src/stacks/network-stack.ts +282 -0
- package/src/stacks/newsletter-stack.ts +572 -0
- package/src/stacks/pipeline-stack.ts +242 -0
- package/src/stacks/release-account-stack.ts +229 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { crmPolicyFingerprint, parseInstanceConfig } from "@deployfoundation/foundation-core";
|
|
3
|
+
import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
|
|
4
|
+
import * as cdk from "aws-cdk-lib";
|
|
5
|
+
import * as apigateway from "aws-cdk-lib/aws-apigateway";
|
|
6
|
+
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
7
|
+
import * as cwActions from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
8
|
+
import type * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
9
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
10
|
+
import type * as kms from "aws-cdk-lib/aws-kms";
|
|
11
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
12
|
+
import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
|
|
13
|
+
import * as scheduler from "aws-cdk-lib/aws-scheduler";
|
|
14
|
+
import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
|
15
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
16
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
17
|
+
import type { Construct } from "constructs";
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
19
|
+
import { lambdaCode } from "../artifacts.ts";
|
|
20
|
+
import {
|
|
21
|
+
type Instance,
|
|
22
|
+
LIVE_ENDPOINT_NAME,
|
|
23
|
+
namesFor,
|
|
24
|
+
provisionsIntegration,
|
|
25
|
+
requiresAuthenticatedQueue,
|
|
26
|
+
} from "../names.ts";
|
|
27
|
+
|
|
28
|
+
export interface FoundationApiProps extends cdk.StackProps {
|
|
29
|
+
/** The deployment this stack belongs to; every name below comes from it. */
|
|
30
|
+
instance: Instance;
|
|
31
|
+
/**
|
|
32
|
+
* The instance's runtime config. Several proxies bake policy from it at
|
|
33
|
+
* synth — the identities email may draft as, the domains the browser may
|
|
34
|
+
* visit, CRM's channels and statuses — so a config sync alone can never
|
|
35
|
+
* widen a deployed proxy.
|
|
36
|
+
*/
|
|
37
|
+
configPath: string;
|
|
38
|
+
dataKey: kms.IKey;
|
|
39
|
+
table: dynamodb.ITable;
|
|
40
|
+
/** `pk` + `sk`: where the Google Calendar callback stores per-person tokens. */
|
|
41
|
+
itemsTable: dynamodb.ITable;
|
|
42
|
+
/** Dedicated CRM table, present only when the instance enables the integration. */
|
|
43
|
+
crmTable?: dynamodb.ITable;
|
|
44
|
+
/** Proxy-only one-hour proposal drafts; never granted to the agent runtime. */
|
|
45
|
+
upworkApprovalTable?: dynamodb.ITable;
|
|
46
|
+
secrets: {
|
|
47
|
+
signing: secretsmanager.ISecret;
|
|
48
|
+
slackApp: secretsmanager.ISecret;
|
|
49
|
+
googleCalendar: secretsmanager.ISecret;
|
|
50
|
+
/** The shared OAuth client; the gateway reads it to build a consent link. */
|
|
51
|
+
googleOauth: secretsmanager.ISecret;
|
|
52
|
+
/** The Drive identity. The gateway WRITES this one and never reads it. */
|
|
53
|
+
googleDrive: secretsmanager.ISecret;
|
|
54
|
+
/** The connected mailbox. The gateway WRITES it; only the proxy reads it. */
|
|
55
|
+
googleEmail: secretsmanager.ISecret;
|
|
56
|
+
/** Otter API key. Only the optional Otter proxy role can read it. */
|
|
57
|
+
otterApi?: secretsmanager.ISecret;
|
|
58
|
+
/** Knock's public OAuth client. Gateway reads it; deployment owns registration. */
|
|
59
|
+
knockOauthClient?: secretsmanager.ISecret;
|
|
60
|
+
/** Connected Knock credential. Gateway writes; proxy alone reads or refreshes. */
|
|
61
|
+
knockCredential?: secretsmanager.ISecret;
|
|
62
|
+
/** Combined Upwork OAuth client and token secret, present only for opted-in instances. */
|
|
63
|
+
upwork?: secretsmanager.ISecret;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The AgentCore runtime the invoker calls. On a phase-one deploy this is
|
|
67
|
+
* FoundationAgent's `…-pending` placeholder — a syntactically valid ARN that
|
|
68
|
+
* nothing can invoke, so the plumbing synthesizes before the image exists.
|
|
69
|
+
*/
|
|
70
|
+
agentRuntimeArn: string;
|
|
71
|
+
/** CSV of Slack user ids allowed to run privileged commands. */
|
|
72
|
+
admins: string;
|
|
73
|
+
/** Optional address subscribed to the DLQ alarm topic. */
|
|
74
|
+
alarmEmail?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* FoundationApi — the edge: API Gateway → gateway Lambda → SQS → invoker
|
|
79
|
+
* Lambda → `InvokeAgentRuntime`. The gateway must answer Slack inside 3
|
|
80
|
+
* seconds, so it only verifies, dedupes and enqueues; the invoker holds the
|
|
81
|
+
* blocking agent turn for up to Lambda's 15 minutes (which is also
|
|
82
|
+
* AgentCore's per-invoke ceiling).
|
|
83
|
+
*
|
|
84
|
+
* Neither Lambda is VPC-attached. Everything they call — Secrets Manager,
|
|
85
|
+
* SQS, DynamoDB, AgentCore — is a public AWS endpoint, so the VPC would buy
|
|
86
|
+
* nothing but ENI cold-start latency on the 3-second path.
|
|
87
|
+
*
|
|
88
|
+
* `maxReceiveCount: 1` on the invoke queue: chat prefers dropping a turn over
|
|
89
|
+
* duplicating it. A failed invoke rolls straight to the DLQ for manual
|
|
90
|
+
* redrive, and the DLQ alarm below is what says so.
|
|
91
|
+
*/
|
|
92
|
+
export class FoundationApi extends cdk.Stack {
|
|
93
|
+
public readonly api: apigateway.RestApi;
|
|
94
|
+
public readonly gatewayDlq: sqs.Queue;
|
|
95
|
+
public readonly invokeQueue: sqs.Queue;
|
|
96
|
+
public readonly invokeDlq: sqs.Queue;
|
|
97
|
+
/** The email proxy: the agent role's only email grant is to invoke this. */
|
|
98
|
+
public readonly emailProxyFunction: lambda.Function;
|
|
99
|
+
/** Isolated policy boundary for AgentCore Browser; absent when the capability is disabled. */
|
|
100
|
+
public readonly browserProxyFunction?: lambda.Function;
|
|
101
|
+
/** Read-only Otter proxy, present only when the instance opts in. */
|
|
102
|
+
public readonly otterProxyFunction?: lambda.Function;
|
|
103
|
+
/** CRM proxy, present only when the instance opts into its CRM integration. */
|
|
104
|
+
public readonly crmProxyFunction?: lambda.Function;
|
|
105
|
+
/** Read-only Knock remote-MCP proxy, present only for opted-in instances. */
|
|
106
|
+
public readonly knockProxyFunction?: lambda.Function;
|
|
107
|
+
/** Fixed-operation Upwork proxy, present only when the instance opts in. */
|
|
108
|
+
public readonly upworkProxyFunction?: lambda.Function;
|
|
109
|
+
|
|
110
|
+
constructor(scope: Construct, id: string, props: FoundationApiProps) {
|
|
111
|
+
super(scope, id, props);
|
|
112
|
+
const names = namesFor(props.instance);
|
|
113
|
+
const display = props.instance.displayName;
|
|
114
|
+
const upwork = provisionsIntegration(props.instance, "upwork")
|
|
115
|
+
? upworkConfigForProvisioning(props.configPath)
|
|
116
|
+
: undefined;
|
|
117
|
+
const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
|
|
118
|
+
const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
|
|
119
|
+
if (upwork !== undefined && (upworkSecret === undefined || upworkApprovalTable === undefined))
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`,
|
|
122
|
+
);
|
|
123
|
+
const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
|
|
124
|
+
|
|
125
|
+
this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
|
|
126
|
+
encryption: sqs.QueueEncryption.KMS,
|
|
127
|
+
encryptionMasterKey: props.dataKey,
|
|
128
|
+
enforceSSL: true,
|
|
129
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
this.invokeDlq = new sqs.Queue(this, "InvokeDlq", {
|
|
133
|
+
encryption: sqs.QueueEncryption.KMS,
|
|
134
|
+
encryptionMasterKey: props.dataKey,
|
|
135
|
+
enforceSSL: true,
|
|
136
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.invokeQueue = new sqs.Queue(this, "InvokeQueue", {
|
|
140
|
+
encryption: sqs.QueueEncryption.KMS,
|
|
141
|
+
encryptionMasterKey: props.dataKey,
|
|
142
|
+
enforceSSL: true,
|
|
143
|
+
// Matches the invoker's 15-minute timeout: Lambda extends visibility
|
|
144
|
+
// while the turn runs, and a dead invoker's message surfaces once and
|
|
145
|
+
// then rolls to the DLQ.
|
|
146
|
+
visibilityTimeout: cdk.Duration.seconds(900),
|
|
147
|
+
retentionPeriod: cdk.Duration.hours(6),
|
|
148
|
+
deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const gatewayRole = new iam.Role(this, "GatewayRole", {
|
|
152
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
153
|
+
description: `${display} gateway Lambda role (least privilege)`,
|
|
154
|
+
managedPolicies: [
|
|
155
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
gatewayRole.addToPolicy(
|
|
159
|
+
new iam.PolicyStatement({
|
|
160
|
+
sid: "GatewayCredentials",
|
|
161
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
162
|
+
// Slack request verification, plus the Google OAuth *client* used to
|
|
163
|
+
// build a consent link and redeem a code. No refresh token is ever
|
|
164
|
+
// read from a secret: they are per-person rows in `FoundationItems`.
|
|
165
|
+
resources: [
|
|
166
|
+
secretArnPattern(props.secrets.signing),
|
|
167
|
+
secretArnPattern(props.secrets.slackApp),
|
|
168
|
+
secretArnPattern(props.secrets.googleCalendar),
|
|
169
|
+
secretArnPattern(props.secrets.googleOauth),
|
|
170
|
+
...(props.secrets.knockOauthClient === undefined
|
|
171
|
+
? []
|
|
172
|
+
: [secretArnPattern(props.secrets.knockOauthClient)]),
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
gatewayRole.addToPolicy(
|
|
177
|
+
new iam.PolicyStatement({
|
|
178
|
+
sid: "DriveIdentityConnect",
|
|
179
|
+
// `/sky-drive-connect` stores the connected account's refresh token.
|
|
180
|
+
// Write-only and on this one secret: the gateway must be unable to
|
|
181
|
+
// read back the Drive credential it just wrote, which is what keeps
|
|
182
|
+
// Drive reads exclusive to the agent runtime.
|
|
183
|
+
actions: ["secretsmanager:PutSecretValue"],
|
|
184
|
+
resources: [secretArnPattern(props.secrets.googleDrive)],
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
gatewayRole.addToPolicy(
|
|
188
|
+
new iam.PolicyStatement({
|
|
189
|
+
sid: "EmailIdentityConnect",
|
|
190
|
+
// The same shape, and here it matters more: the mailbox credential is
|
|
191
|
+
// the one thing in the system that could SEND mail. A read grant would
|
|
192
|
+
// put it one injection away from an internet-facing Lambda.
|
|
193
|
+
actions: ["secretsmanager:PutSecretValue"],
|
|
194
|
+
resources: [secretArnPattern(props.secrets.googleEmail)],
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
if (props.secrets.knockCredential !== undefined) {
|
|
198
|
+
gatewayRole.addToPolicy(
|
|
199
|
+
new iam.PolicyStatement({
|
|
200
|
+
sid: "KnockCredentialConnect",
|
|
201
|
+
// The callback writes a newly connected token but never reads it;
|
|
202
|
+
// the fixed proxy owns every subsequent read and refresh.
|
|
203
|
+
actions: ["secretsmanager:PutSecretValue"],
|
|
204
|
+
resources: [secretArnPattern(props.secrets.knockCredential)],
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (upworkSecret !== undefined) {
|
|
209
|
+
gatewayRole.addToPolicy(
|
|
210
|
+
new iam.PolicyStatement({
|
|
211
|
+
sid: "UpworkOAuth",
|
|
212
|
+
// The callback exchanges a code and conditionally promotes refreshed
|
|
213
|
+
// token state alongside the manually entered client credentials.
|
|
214
|
+
actions: [
|
|
215
|
+
"secretsmanager:GetSecretValue",
|
|
216
|
+
"secretsmanager:PutSecretValue",
|
|
217
|
+
"secretsmanager:UpdateSecretVersionStage",
|
|
218
|
+
],
|
|
219
|
+
resources: [secretArnPattern(upworkSecret)],
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
gatewayRole.addToPolicy(
|
|
224
|
+
new iam.PolicyStatement({
|
|
225
|
+
sid: "DedupeAndThreadIndex",
|
|
226
|
+
// UpdateItem is the proactive channels' hourly budget counter: one row
|
|
227
|
+
// per channel per hour, incremented under a condition so two Lambdas
|
|
228
|
+
// cannot both spend the last start of the hour.
|
|
229
|
+
actions: ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
|
|
230
|
+
resources: [props.table.tableArn],
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
gatewayRole.addToPolicy(
|
|
234
|
+
new iam.PolicyStatement({
|
|
235
|
+
sid: "CalendarConnections",
|
|
236
|
+
// Write-only, and only on the OAuth callback: the gateway stores a
|
|
237
|
+
// person's refresh token but never reads one back.
|
|
238
|
+
actions: ["dynamodb:PutItem"],
|
|
239
|
+
resources: [props.itemsTable.tableArn],
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
gatewayRole.addToPolicy(
|
|
243
|
+
new iam.PolicyStatement({
|
|
244
|
+
sid: "QueueSend",
|
|
245
|
+
actions: ["sqs:SendMessage"],
|
|
246
|
+
// The gateway DLQ is granted separately by `deadLetterQueue` below.
|
|
247
|
+
resources: [this.invokeQueue.queueArn],
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
gatewayRole.addToPolicy(
|
|
251
|
+
new iam.PolicyStatement({
|
|
252
|
+
sid: "DataKeyUsage",
|
|
253
|
+
actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
254
|
+
resources: [props.dataKey.keyArn],
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
|
|
259
|
+
...(authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {}),
|
|
260
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
261
|
+
handler: "index.handler",
|
|
262
|
+
code: lambdaCode(this, "gateway"),
|
|
263
|
+
role: gatewayRole,
|
|
264
|
+
timeout: cdk.Duration.seconds(10),
|
|
265
|
+
memorySize: 256,
|
|
266
|
+
deadLetterQueue: this.gatewayDlq,
|
|
267
|
+
environment: {
|
|
268
|
+
FOUNDATION_TABLE_NAME: props.table.tableName,
|
|
269
|
+
FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
|
|
270
|
+
FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
|
|
271
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
272
|
+
FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
|
|
273
|
+
FOUNDATION_ADMINS: props.admins,
|
|
274
|
+
FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
|
|
275
|
+
// What the teammate is called. The gateway renders it in `/help`, in
|
|
276
|
+
// every "not configured" reply and in each OAuth result page, and
|
|
277
|
+
// `triggers.ts` uses it to tell a message addressed to the teammate
|
|
278
|
+
// from one addressed to a person. Without it the gateway falls back
|
|
279
|
+
// to a hard-coded name, wrong for every instance but one.
|
|
280
|
+
FOUNDATION_DISPLAY_NAME: props.instance.displayName,
|
|
281
|
+
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
282
|
+
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
|
|
283
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
|
|
284
|
+
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
|
|
285
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
|
|
286
|
+
...(props.secrets.knockOauthClient === undefined
|
|
287
|
+
? {}
|
|
288
|
+
: { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
|
|
289
|
+
...(props.secrets.knockCredential === undefined
|
|
290
|
+
? {}
|
|
291
|
+
: { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
|
|
292
|
+
...(upworkSecret === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
|
|
295
|
+
// Signed into every Drive and email consent link, so one instance's
|
|
296
|
+
// link cannot be redeemed against another's gateway.
|
|
297
|
+
FOUNDATION_INSTANCE: props.instance.name,
|
|
298
|
+
// Slash-command prefixes the gateway answers (current first, then legacy),
|
|
299
|
+
// from the instance file; the manifest renders the same prefix.
|
|
300
|
+
FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
|
|
301
|
+
// Read at SYNTH from the instance file, like FOUNDATION_ADMINS: the gateway
|
|
302
|
+
// decides what starts a turn and never reads `config/<name>.yaml`, so
|
|
303
|
+
// making the teammate speak unasked in a channel is a deploy, not a config sync.
|
|
304
|
+
// Empty for an instance that watches nothing, which is every instance
|
|
305
|
+
// that omits the block.
|
|
306
|
+
FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
|
|
307
|
+
FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
// One mention must produce one reply: Lambda's default of two async
|
|
311
|
+
// retries would re-run a slow turn into duplicate Slack messages.
|
|
312
|
+
gatewayFunction.configureAsyncInvoke({
|
|
313
|
+
retryAttempts: 0,
|
|
314
|
+
maxEventAge: cdk.Duration.seconds(60),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const invokerRole = new iam.Role(this, "InvokerRole", {
|
|
318
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
319
|
+
description: `${display} invoker Lambda role (least privilege)`,
|
|
320
|
+
managedPolicies: [
|
|
321
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
invokerRole.addToPolicy(
|
|
325
|
+
new iam.PolicyStatement({
|
|
326
|
+
sid: "InvokeAgentRuntime",
|
|
327
|
+
actions: ["bedrock-agentcore:InvokeAgentRuntime"],
|
|
328
|
+
// Name prefix, not the literal ARN: on a phase-one deploy the runtime
|
|
329
|
+
// does not exist yet, and the placeholder ARN this stack is handed
|
|
330
|
+
// would otherwise bake a dead resource into the policy.
|
|
331
|
+
resources: [
|
|
332
|
+
`arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*`,
|
|
333
|
+
`arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*/runtime-endpoint/*`,
|
|
334
|
+
],
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
invokerRole.addToPolicy(
|
|
338
|
+
new iam.PolicyStatement({
|
|
339
|
+
sid: "SlackBotToken",
|
|
340
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
341
|
+
resources: [secretArnPattern(props.secrets.slackApp)],
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
if (authenticatedQueue) {
|
|
345
|
+
// Hardened queue envelopes are HMAC-authenticated before AgentCore.
|
|
346
|
+
// This trusted invoker can verify Slack's signing secret; the agent role
|
|
347
|
+
// remains explicitly denied and cannot forge a normal turn.
|
|
348
|
+
invokerRole.addToPolicy(
|
|
349
|
+
new iam.PolicyStatement({
|
|
350
|
+
sid: "QueueEnvelopeVerification",
|
|
351
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
352
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
} else {
|
|
356
|
+
// Legacy instances do not yet require authenticated queue envelopes.
|
|
357
|
+
invokerRole.addToPolicy(
|
|
358
|
+
new iam.PolicyStatement({
|
|
359
|
+
sid: "DenySlackSigningSecret",
|
|
360
|
+
effect: iam.Effect.DENY,
|
|
361
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
362
|
+
resources: [
|
|
363
|
+
`arn:${this.partition}:secretsmanager:${this.region}:${this.account}:secret:${names.secretSlackSigning}-??????`,
|
|
364
|
+
],
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
invokerRole.addToPolicy(
|
|
369
|
+
new iam.PolicyStatement({
|
|
370
|
+
sid: "DataKeyUsage",
|
|
371
|
+
actions: ["kms:Decrypt", "kms:GenerateDataKey"],
|
|
372
|
+
resources: [props.dataKey.keyArn],
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const invokerFunction = new lambda.Function(this, "InvokerFunction", {
|
|
377
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
378
|
+
handler: "index.handler",
|
|
379
|
+
code: lambdaCode(this, "invoker"),
|
|
380
|
+
role: invokerRole,
|
|
381
|
+
timeout: cdk.Duration.seconds(900),
|
|
382
|
+
memorySize: 512,
|
|
383
|
+
environment: {
|
|
384
|
+
FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
|
|
385
|
+
FOUNDATION_AWS_REGION: this.region,
|
|
386
|
+
// Production traffic hits the pinned `live` endpoint, promoted by the
|
|
387
|
+
// deploy workflow after the smoke test — never DEFAULT (newest version).
|
|
388
|
+
FOUNDATION_AGENT_RUNTIME_QUALIFIER: LIVE_ENDPOINT_NAME,
|
|
389
|
+
...(authenticatedQueue
|
|
390
|
+
? { FOUNDATION_QUEUE_SIGNING_SECRET_ID: props.secrets.signing.secretName }
|
|
391
|
+
: {}),
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
invokerFunction.addEventSource(
|
|
395
|
+
new lambdaEventSources.SqsEventSource(this.invokeQueue, {
|
|
396
|
+
batchSize: 1,
|
|
397
|
+
// One message per invocation, so partial-batch reporting has nothing
|
|
398
|
+
// to report: a failed record must fail the whole invocation and roll
|
|
399
|
+
// to the DLQ (maxReceiveCount 1) rather than be retried.
|
|
400
|
+
reportBatchItemFailures: false,
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// AuthorizationType NONE by design: Slack cannot sign AWS IAM requests.
|
|
405
|
+
// Authentication is the Slack v2 signature the gateway Lambda verifies.
|
|
406
|
+
this.api = new apigateway.RestApi(this, "FoundationApi", {
|
|
407
|
+
restApiName: `${props.instance.naming.secretPrefix}-events`,
|
|
408
|
+
description: `${display} Slack ingress (events + slash commands + interactivity)`,
|
|
409
|
+
endpointTypes: [apigateway.EndpointType.REGIONAL],
|
|
410
|
+
deployOptions: {
|
|
411
|
+
stageName: "prod",
|
|
412
|
+
throttlingRateLimit: 50,
|
|
413
|
+
throttlingBurstLimit: 100,
|
|
414
|
+
metricsEnabled: true,
|
|
415
|
+
},
|
|
416
|
+
cloudWatchRole: false,
|
|
417
|
+
});
|
|
418
|
+
const slack = this.api.root.addResource("slack");
|
|
419
|
+
const integration = new apigateway.LambdaIntegration(gatewayFunction);
|
|
420
|
+
slack.addResource("events").addMethod("POST", integration);
|
|
421
|
+
slack.addResource("commands").addMethod("POST", integration);
|
|
422
|
+
// Block Kit answers to `ask_user` questions (Slack "Interactivity").
|
|
423
|
+
slack.addResource("interactive").addMethod("POST", integration);
|
|
424
|
+
// Google's OAuth redirect. Not a Slack route and not Slack-signed: it
|
|
425
|
+
// authenticates the person through the signed `state` the gateway issued.
|
|
426
|
+
this.api.root
|
|
427
|
+
.addResource("calendar")
|
|
428
|
+
.addResource("oauth")
|
|
429
|
+
.addResource("callback")
|
|
430
|
+
.addMethod("GET", integration);
|
|
431
|
+
// The same for the company Drive identity an admin connects.
|
|
432
|
+
this.api.root
|
|
433
|
+
.addResource("drive")
|
|
434
|
+
.addResource("oauth")
|
|
435
|
+
.addResource("callback")
|
|
436
|
+
.addMethod("GET", integration);
|
|
437
|
+
// And for the mailbox. The gateway derives this callback's URL from the
|
|
438
|
+
// shared client's `redirect_uri` (`redirectUriFor`), so nothing here has
|
|
439
|
+
// to tell it — which also avoids depending on the deployment stage from
|
|
440
|
+
// the very function the stage depends on.
|
|
441
|
+
this.api.root
|
|
442
|
+
.addResource("email")
|
|
443
|
+
.addResource("oauth")
|
|
444
|
+
.addResource("callback")
|
|
445
|
+
.addMethod("GET", integration);
|
|
446
|
+
if (provisionsIntegration(props.instance, "knock")) {
|
|
447
|
+
// Knock redirects only for deployments that opted its infrastructure in,
|
|
448
|
+
// which is what creates both its public client and the write-only
|
|
449
|
+
// callback credential destination.
|
|
450
|
+
this.api.root
|
|
451
|
+
.addResource("knock")
|
|
452
|
+
.addResource("oauth")
|
|
453
|
+
.addResource("callback")
|
|
454
|
+
.addMethod("GET", integration);
|
|
455
|
+
}
|
|
456
|
+
if (upworkSecret !== undefined && upwork !== undefined) {
|
|
457
|
+
const upworkRedirectUri = this.api.urlForPath("/upwork/oauth/callback");
|
|
458
|
+
this.api.root
|
|
459
|
+
.addResource("upwork")
|
|
460
|
+
.addResource("oauth")
|
|
461
|
+
.addResource("callback")
|
|
462
|
+
.addMethod("GET", integration);
|
|
463
|
+
new cdk.CfnOutput(this, "UpworkOAuthRedirectUrl", { value: upworkRedirectUri });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- email proxy -----------------------------------------------------
|
|
467
|
+
// The permission barrier for Gmail, and the reason the design puts it in
|
|
468
|
+
// a Lambda rather than a broker inside the agent container: this role is
|
|
469
|
+
// the ONLY principal in the account that can read the mailbox secret, and
|
|
470
|
+
// the agent's execution role carries an explicit Deny on it. A fully
|
|
471
|
+
// hijacked session can invoke the four operations and nothing more.
|
|
472
|
+
const emailProxyRole = new iam.Role(this, "EmailProxyRole", {
|
|
473
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
474
|
+
description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
|
|
475
|
+
managedPolicies: [
|
|
476
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
emailProxyRole.addToPolicy(
|
|
480
|
+
new iam.PolicyStatement({
|
|
481
|
+
sid: "EmailProxyCredentials",
|
|
482
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
483
|
+
// Exactly two: the mailbox, and the OAuth client its token is minted
|
|
484
|
+
// with. Nothing else in the account is readable by this role.
|
|
485
|
+
resources: [
|
|
486
|
+
secretArnPattern(props.secrets.googleEmail),
|
|
487
|
+
secretArnPattern(props.secrets.googleOauth),
|
|
488
|
+
],
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
emailProxyRole.addToPolicy(
|
|
492
|
+
new iam.PolicyStatement({
|
|
493
|
+
sid: "EmailProxyDataKey",
|
|
494
|
+
actions: ["kms:Decrypt"],
|
|
495
|
+
resources: [props.dataKey.keyArn],
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
this.emailProxyFunction = new lambda.Function(this, "EmailProxyFunction", {
|
|
500
|
+
functionName: namesFor(props.instance).emailProxyFunctionName,
|
|
501
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
502
|
+
handler: "index.handler",
|
|
503
|
+
code: lambdaCode(this, "email-proxy"),
|
|
504
|
+
role: emailProxyRole,
|
|
505
|
+
timeout: cdk.Duration.seconds(30),
|
|
506
|
+
memorySize: 256,
|
|
507
|
+
// No reserved concurrency: a fresh account's Lambda quota is 10 and
|
|
508
|
+
// reserving any of it is refused ("decreases UnreservedConcurrentExecution
|
|
509
|
+
// below its minimum", 2026-09-06). Gmail-side rate is bounded
|
|
510
|
+
// by the hourly routine and one turn per channel at a time instead.
|
|
511
|
+
environment: {
|
|
512
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
|
|
513
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
|
|
514
|
+
// Read at SYNTH from the instance's config file. The identity list a
|
|
515
|
+
// turn can draft as is therefore fixed at deploy time and cannot be
|
|
516
|
+
// widened by anything the agent sends or by a config sync alone.
|
|
517
|
+
FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentitiesFor(props.configPath)),
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// --- AgentCore Browser proxy ----------------------------------------
|
|
522
|
+
// The model-facing runtime can invoke this Lambda but has no Browser API grants. The proxy
|
|
523
|
+
// owns those grants and re-validates a gateway-signed DM/user scope plus this deploy-time
|
|
524
|
+
// domain policy before every read operation.
|
|
525
|
+
const browserDomains = provisionsIntegration(props.instance, "browser")
|
|
526
|
+
? browserDomainsFor(props.configPath)
|
|
527
|
+
: [];
|
|
528
|
+
if (browserDomains.length > 0) {
|
|
529
|
+
const browserProxyRole = new iam.Role(this, "BrowserProxyRole", {
|
|
530
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
531
|
+
description: `${display} policy-enforcing AgentCore Browser proxy role`,
|
|
532
|
+
managedPolicies: [
|
|
533
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
534
|
+
],
|
|
535
|
+
});
|
|
536
|
+
browserProxyRole.addToPolicy(
|
|
537
|
+
new iam.PolicyStatement({
|
|
538
|
+
sid: "BrowserProxySigningSecret",
|
|
539
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
540
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
browserProxyRole.addToPolicy(
|
|
544
|
+
new iam.PolicyStatement({
|
|
545
|
+
sid: "BrowserProxyDataKey",
|
|
546
|
+
actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
547
|
+
resources: [props.dataKey.keyArn],
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
browserProxyRole.addToPolicy(
|
|
551
|
+
new iam.PolicyStatement({
|
|
552
|
+
sid: "BrowserProxySessions",
|
|
553
|
+
actions: [
|
|
554
|
+
"bedrock-agentcore:StartBrowserSession",
|
|
555
|
+
"bedrock-agentcore:StopBrowserSession",
|
|
556
|
+
"bedrock-agentcore:SaveBrowserSessionProfile",
|
|
557
|
+
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
|
558
|
+
],
|
|
559
|
+
resources: [
|
|
560
|
+
`arn:${this.partition}:bedrock-agentcore:${this.region}:aws:browser/aws.browser.v1`,
|
|
561
|
+
`arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:browser-profile/*`,
|
|
562
|
+
],
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
browserProxyRole.addToPolicy(
|
|
566
|
+
new iam.PolicyStatement({
|
|
567
|
+
sid: "BrowserProxyProfiles",
|
|
568
|
+
actions: [
|
|
569
|
+
"bedrock-agentcore:CreateBrowserProfile",
|
|
570
|
+
"bedrock-agentcore:GetBrowserProfile",
|
|
571
|
+
"bedrock-agentcore:ListBrowserProfiles",
|
|
572
|
+
],
|
|
573
|
+
// Create/List have no resource ARN. The proxy derives opaque profile names from the
|
|
574
|
+
// verified Slack user and a server-side allowlisted domain and exposes no delete API.
|
|
575
|
+
resources: ["*"],
|
|
576
|
+
}),
|
|
577
|
+
);
|
|
578
|
+
browserProxyRole.addToPolicy(
|
|
579
|
+
new iam.PolicyStatement({
|
|
580
|
+
sid: "BrowserProxyLease",
|
|
581
|
+
actions: ["dynamodb:PutItem", "dynamodb:DeleteItem"],
|
|
582
|
+
resources: [props.itemsTable.tableArn],
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
this.browserProxyFunction = new lambda.Function(this, "BrowserProxyFunction", {
|
|
586
|
+
functionName: names.browserProxyFunctionName,
|
|
587
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
588
|
+
handler: "index.handler",
|
|
589
|
+
role: browserProxyRole,
|
|
590
|
+
timeout: cdk.Duration.seconds(240),
|
|
591
|
+
code: lambdaCode(this, "browser-proxy"),
|
|
592
|
+
environment: {
|
|
593
|
+
FOUNDATION_BROWSER_ALLOWED_DOMAINS: JSON.stringify(browserDomains),
|
|
594
|
+
FOUNDATION_INSTANCE: props.instance.name,
|
|
595
|
+
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
596
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
597
|
+
FOUNDATION_TEAM_ID: props.instance.slack.teamId,
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// --- Otter proxy -----------------------------------------------------
|
|
603
|
+
// Like email, this is a hard credential boundary: the shell-capable
|
|
604
|
+
// agent can invoke two fixed GET operations but cannot read the API key.
|
|
605
|
+
if (provisionsIntegration(props.instance, "otter")) {
|
|
606
|
+
const otterApi = props.secrets.otterApi;
|
|
607
|
+
if (otterApi === undefined)
|
|
608
|
+
throw new Error(
|
|
609
|
+
`${id}: integrations.otter is enabled but FoundationData supplied no Otter secret`,
|
|
610
|
+
);
|
|
611
|
+
const otterProxyRole = new iam.Role(this, "OtterProxyRole", {
|
|
612
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
613
|
+
description: `${display} Otter proxy Lambda role (the only reader of the API key)`,
|
|
614
|
+
managedPolicies: [
|
|
615
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
616
|
+
],
|
|
617
|
+
});
|
|
618
|
+
otterProxyRole.addToPolicy(
|
|
619
|
+
new iam.PolicyStatement({
|
|
620
|
+
sid: "OtterProxyCredentials",
|
|
621
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
622
|
+
// Otter key for the API call; Slack signing secret to verify the
|
|
623
|
+
// actor capability the gateway minted and the agent cannot forge.
|
|
624
|
+
resources: [secretArnPattern(otterApi), secretArnPattern(props.secrets.signing)],
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
this.otterProxyFunction = new lambda.Function(this, "OtterProxyFunction", {
|
|
628
|
+
functionName: names.otterProxyFunctionName,
|
|
629
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
630
|
+
handler: "index.handler",
|
|
631
|
+
code: lambdaCode(this, "otter-proxy"),
|
|
632
|
+
role: otterProxyRole,
|
|
633
|
+
timeout: cdk.Duration.seconds(30),
|
|
634
|
+
memorySize: 256,
|
|
635
|
+
environment: {
|
|
636
|
+
FOUNDATION_OTTER_API_SECRET_ID: otterApi.secretName,
|
|
637
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
638
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
639
|
+
FOUNDATION_ADMINS: props.admins,
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
new cdk.CfnOutput(this, "OtterProxyFunctionArn", {
|
|
643
|
+
value: this.otterProxyFunction.functionArn,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// --- Knock proxy -----------------------------------------------------
|
|
648
|
+
// The proxy is the only runtime role that can read or refresh the tenant
|
|
649
|
+
// credential. The gateway has write-only callback access; the agent can
|
|
650
|
+
// invoke this Lambda and is explicitly denied both Knock secrets.
|
|
651
|
+
if (provisionsIntegration(props.instance, "knock")) {
|
|
652
|
+
const knockCredential = props.secrets.knockCredential;
|
|
653
|
+
if (knockCredential === undefined)
|
|
654
|
+
throw new Error(
|
|
655
|
+
`${id}: integrations.knock is enabled but FoundationData supplied no Knock secret`,
|
|
656
|
+
);
|
|
657
|
+
const knock = knockConfigForProvisioning(props.configPath);
|
|
658
|
+
const knockProxyRole = new iam.Role(this, "KnockProxyRole", {
|
|
659
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
660
|
+
description: `${display} Knock read-only MCP proxy Lambda`,
|
|
661
|
+
managedPolicies: [
|
|
662
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
knockProxyRole.addToPolicy(
|
|
666
|
+
new iam.PolicyStatement({
|
|
667
|
+
sid: "KnockProxyCredentialRead",
|
|
668
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
669
|
+
resources: [secretArnPattern(knockCredential), secretArnPattern(props.secrets.signing)],
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
knockProxyRole.addToPolicy(
|
|
673
|
+
new iam.PolicyStatement({
|
|
674
|
+
sid: "KnockProxyCredentialRefresh",
|
|
675
|
+
actions: ["secretsmanager:PutSecretValue", "secretsmanager:UpdateSecretVersionStage"],
|
|
676
|
+
resources: [secretArnPattern(knockCredential)],
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
679
|
+
this.knockProxyFunction = new lambda.Function(this, "KnockProxyFunction", {
|
|
680
|
+
functionName: names.knockProxyFunctionName,
|
|
681
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
682
|
+
handler: "index.handler",
|
|
683
|
+
code: lambdaCode(this, "knock-proxy"),
|
|
684
|
+
role: knockProxyRole,
|
|
685
|
+
timeout: cdk.Duration.seconds(30),
|
|
686
|
+
memorySize: 256,
|
|
687
|
+
environment: {
|
|
688
|
+
FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: knockCredential.secretName,
|
|
689
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
690
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
691
|
+
FOUNDATION_ADMINS: props.admins,
|
|
692
|
+
FOUNDATION_KNOCK_DEBUG: String(knock.debug),
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
new cdk.CfnOutput(this, "KnockProxyFunctionArn", {
|
|
696
|
+
value: this.knockProxyFunction.functionArn,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// --- Upwork proxy ----------------------------------------------------
|
|
701
|
+
// The shell-capable agent receives only this Lambda ARN. OAuth client and
|
|
702
|
+
// token state remain in one retained secret that this role alone can read
|
|
703
|
+
// or update; every request also carries a gateway-signed actor envelope.
|
|
704
|
+
if (upworkSecret !== undefined && upwork !== undefined && upworkApprovalTable !== undefined) {
|
|
705
|
+
const upworkProxyRole = new iam.Role(this, "UpworkProxyRole", {
|
|
706
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
707
|
+
description: `${display} Upwork proxy Lambda role (fixed OAuth and proposal operations)`,
|
|
708
|
+
managedPolicies: [
|
|
709
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
710
|
+
],
|
|
711
|
+
});
|
|
712
|
+
upworkProxyRole.addToPolicy(
|
|
713
|
+
new iam.PolicyStatement({
|
|
714
|
+
sid: "UpworkProxySecret",
|
|
715
|
+
actions: [
|
|
716
|
+
"secretsmanager:GetSecretValue",
|
|
717
|
+
"secretsmanager:PutSecretValue",
|
|
718
|
+
"secretsmanager:UpdateSecretVersionStage",
|
|
719
|
+
],
|
|
720
|
+
resources: [secretArnPattern(upworkSecret)],
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
upworkProxyRole.addToPolicy(
|
|
724
|
+
new iam.PolicyStatement({
|
|
725
|
+
sid: "UpworkProxySigningSecret",
|
|
726
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
727
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
upworkProxyRole.addToPolicy(
|
|
731
|
+
new iam.PolicyStatement({
|
|
732
|
+
sid: "UpworkDraftState",
|
|
733
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
|
|
734
|
+
resources: [upworkApprovalTable.tableArn],
|
|
735
|
+
}),
|
|
736
|
+
);
|
|
737
|
+
this.upworkProxyFunction = new lambda.Function(this, "UpworkProxyFunction", {
|
|
738
|
+
functionName: names.upworkProxyFunctionName,
|
|
739
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
740
|
+
handler: "index.handler",
|
|
741
|
+
code: lambdaCode(this, "upwork-proxy"),
|
|
742
|
+
role: upworkProxyRole,
|
|
743
|
+
timeout: cdk.Duration.seconds(30),
|
|
744
|
+
memorySize: 256,
|
|
745
|
+
environment: {
|
|
746
|
+
FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName,
|
|
747
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
748
|
+
FOUNDATION_TABLE_NAME: upworkApprovalTable.tableName,
|
|
749
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
750
|
+
FOUNDATION_UPWORK_ALLOWED_CHANNELS: JSON.stringify(upwork.channels),
|
|
751
|
+
FOUNDATION_ADMINS: props.admins,
|
|
752
|
+
FOUNDATION_INSTANCE: props.instance.name,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
new cdk.CfnOutput(this, "UpworkProxyFunctionArn", {
|
|
756
|
+
value: this.upworkProxyFunction.functionArn,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// --- CRM proxy -------------------------------------------------------
|
|
761
|
+
// Fixed operations and deployment-baked policy over the instance's own
|
|
762
|
+
// self-hosted CRM table.
|
|
763
|
+
if (provisionsIntegration(props.instance, "crm")) {
|
|
764
|
+
const crm = crmConfigForProvisioning(props.configPath);
|
|
765
|
+
const crmTable = props.crmTable;
|
|
766
|
+
if (crmTable === undefined)
|
|
767
|
+
throw new Error(
|
|
768
|
+
`${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`,
|
|
769
|
+
);
|
|
770
|
+
const crmProxyRole = new iam.Role(this, "CrmProxyRole", {
|
|
771
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
772
|
+
description: `${display} CRM proxy Lambda role (self-hosted CRM table access only)`,
|
|
773
|
+
managedPolicies: [
|
|
774
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
775
|
+
],
|
|
776
|
+
});
|
|
777
|
+
crmProxyRole.addToPolicy(
|
|
778
|
+
new iam.PolicyStatement({
|
|
779
|
+
sid: "CrmProxySigningSecret",
|
|
780
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
781
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
782
|
+
}),
|
|
783
|
+
);
|
|
784
|
+
crmProxyRole.addToPolicy(
|
|
785
|
+
new iam.PolicyStatement({
|
|
786
|
+
sid: "CrmTableData",
|
|
787
|
+
actions: [
|
|
788
|
+
"dynamodb:GetItem",
|
|
789
|
+
"dynamodb:PutItem",
|
|
790
|
+
"dynamodb:UpdateItem",
|
|
791
|
+
"dynamodb:DeleteItem",
|
|
792
|
+
"dynamodb:BatchWriteItem",
|
|
793
|
+
"dynamodb:TransactWriteItems",
|
|
794
|
+
],
|
|
795
|
+
resources: [crmTable.tableArn],
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
crmProxyRole.addToPolicy(
|
|
799
|
+
new iam.PolicyStatement({
|
|
800
|
+
sid: "CrmRecordsIndex",
|
|
801
|
+
actions: ["dynamodb:Query"],
|
|
802
|
+
resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
|
|
806
|
+
functionName: names.crmProxyFunctionName,
|
|
807
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
808
|
+
handler: "index.handler",
|
|
809
|
+
code: lambdaCode(this, "crm-proxy"),
|
|
810
|
+
role: crmProxyRole,
|
|
811
|
+
timeout: cdk.Duration.seconds(30),
|
|
812
|
+
memorySize: 256,
|
|
813
|
+
environment: {
|
|
814
|
+
FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
|
|
815
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
816
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
817
|
+
FOUNDATION_CRM_TENANT_ID: props.instance.name,
|
|
818
|
+
FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
|
|
819
|
+
FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
|
|
820
|
+
FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
|
|
821
|
+
FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
|
|
825
|
+
value: this.crmProxyFunction.functionArn,
|
|
826
|
+
});
|
|
827
|
+
new cdk.CfnOutput(this, "CrmPolicyFingerprint", {
|
|
828
|
+
value: crm.policyFingerprint,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const alarmTopic = new sns.Topic(this, "AlarmTopic", {
|
|
833
|
+
displayName: `${display} alarms`,
|
|
834
|
+
});
|
|
835
|
+
if (props.alarmEmail) {
|
|
836
|
+
new sns.Subscription(this, "AlarmEmail", {
|
|
837
|
+
topic: alarmTopic,
|
|
838
|
+
protocol: sns.SubscriptionProtocol.EMAIL,
|
|
839
|
+
endpoint: props.alarmEmail,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const alarmAction = new cwActions.SnsAction(alarmTopic);
|
|
843
|
+
// Depth ≥ 1 on either DLQ means a user's message was silently dropped —
|
|
844
|
+
// the only way anyone learns it needs a redrive.
|
|
845
|
+
for (const [name, queue] of [
|
|
846
|
+
["GatewayDlqDepthAlarm", this.gatewayDlq],
|
|
847
|
+
["InvokeDlqDepthAlarm", this.invokeDlq],
|
|
848
|
+
] as const) {
|
|
849
|
+
const alarm = new cloudwatch.Alarm(this, name, {
|
|
850
|
+
alarmDescription: `${name}: messages are sitting in the DLQ`,
|
|
851
|
+
metric: queue.metricApproximateNumberOfMessagesVisible({
|
|
852
|
+
period: cdk.Duration.minutes(5),
|
|
853
|
+
statistic: "Maximum",
|
|
854
|
+
}),
|
|
855
|
+
threshold: 1,
|
|
856
|
+
evaluationPeriods: 1,
|
|
857
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
858
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
859
|
+
});
|
|
860
|
+
alarm.addAlarmAction(alarmAction);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// --- routines --------------------------------------------------------
|
|
864
|
+
// The group and the role live here because the queue does. The agent
|
|
865
|
+
// creates schedules INSIDE the group and passes the role; it never needs
|
|
866
|
+
// to create either, which is what keeps its own grants narrow.
|
|
867
|
+
new scheduler.CfnScheduleGroup(this, "RoutineGroup", { name: names.routineGroup });
|
|
868
|
+
|
|
869
|
+
const schedulerRole = new iam.Role(this, "RoutineSchedulerRole", {
|
|
870
|
+
roleName: names.routineSchedulerRole,
|
|
871
|
+
description: "Assumed by EventBridge Scheduler to enqueue a routine fire",
|
|
872
|
+
// Scoped to this account: without the condition any Scheduler schedule
|
|
873
|
+
// in any account could be pointed at this role.
|
|
874
|
+
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com", {
|
|
875
|
+
conditions: { StringEquals: { "aws:SourceAccount": this.account } },
|
|
876
|
+
}),
|
|
877
|
+
});
|
|
878
|
+
// Grants the KMS use on the data key too, since the queue is encrypted.
|
|
879
|
+
this.invokeQueue.grantSendMessages(schedulerRole);
|
|
880
|
+
|
|
881
|
+
new cdk.CfnOutput(this, "GoogleCalendarOAuthRedirectUrl", {
|
|
882
|
+
value: this.api.urlForPath("/calendar/oauth/callback"),
|
|
883
|
+
});
|
|
884
|
+
new cdk.CfnOutput(this, "GoogleDriveOAuthRedirectUrl", {
|
|
885
|
+
value: this.api.urlForPath("/drive/oauth/callback"),
|
|
886
|
+
});
|
|
887
|
+
new cdk.CfnOutput(this, "GoogleEmailOAuthRedirectUrl", {
|
|
888
|
+
value: this.api.urlForPath("/email/oauth/callback"),
|
|
889
|
+
});
|
|
890
|
+
if (this.knockProxyFunction !== undefined) {
|
|
891
|
+
new cdk.CfnOutput(this, "KnockOAuthRedirectUrl", {
|
|
892
|
+
value: this.api.urlForPath("/knock/oauth/callback"),
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
new cdk.CfnOutput(this, "EmailProxyFunctionArn", {
|
|
896
|
+
value: this.emailProxyFunction.functionArn,
|
|
897
|
+
});
|
|
898
|
+
if (this.browserProxyFunction !== undefined) {
|
|
899
|
+
new cdk.CfnOutput(this, "BrowserProxyFunctionArn", {
|
|
900
|
+
value: this.browserProxyFunction.functionArn,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
new cdk.CfnOutput(this, "InvokeQueueUrl", { value: this.invokeQueue.queueUrl });
|
|
904
|
+
new cdk.CfnOutput(this, "RoutineSchedulerRoleArn", { value: schedulerRole.roleArn });
|
|
905
|
+
if (authenticatedQueue)
|
|
906
|
+
new cdk.CfnOutput(this, "RoutineIngressFunctionArn", {
|
|
907
|
+
value: gatewayFunction.functionArn,
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
new cdk.CfnOutput(this, "EventsUrl", { value: this.api.urlForPath("/slack/events") });
|
|
911
|
+
new cdk.CfnOutput(this, "CommandsUrl", { value: this.api.urlForPath("/slack/commands") });
|
|
912
|
+
new cdk.CfnOutput(this, "InteractiveUrl", {
|
|
913
|
+
value: this.api.urlForPath("/slack/interactive"),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
interface ProvisionedCrmConfig {
|
|
919
|
+
channels: string[];
|
|
920
|
+
statuses: string[];
|
|
921
|
+
activityTypes: string[];
|
|
922
|
+
maxBatchSize: number;
|
|
923
|
+
policyFingerprint: string;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
|
|
927
|
+
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
928
|
+
const crm = config.capabilities.crm;
|
|
929
|
+
const missing = [
|
|
930
|
+
...(crm.enabled ? [] : ["capabilities.crm.enabled"]),
|
|
931
|
+
...(crm.channels.length > 0 ? [] : ["capabilities.crm.channels"]),
|
|
932
|
+
...(crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"]),
|
|
933
|
+
...(crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]),
|
|
934
|
+
];
|
|
935
|
+
if (missing.length > 0)
|
|
936
|
+
throw new Error(
|
|
937
|
+
`${configPath}: integrations.crm requires a configured CRM capability (${missing.join(", ")})`,
|
|
938
|
+
);
|
|
939
|
+
return {
|
|
940
|
+
channels: [...crm.channels],
|
|
941
|
+
statuses: [...crm.statuses],
|
|
942
|
+
activityTypes: [...crm.activityTypes],
|
|
943
|
+
maxBatchSize: crm.maxBatchSize,
|
|
944
|
+
policyFingerprint: crmPolicyFingerprint(crm),
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Deployment-baked debug policy: a config-only edit cannot widen the proxy. */
|
|
949
|
+
export function knockConfigForProvisioning(configPath: string): { debug: boolean } {
|
|
950
|
+
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
951
|
+
return {
|
|
952
|
+
debug: config.capabilities.knock.enabled && config.capabilities.knock.debug,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
interface ProvisionedUpworkConfig {
|
|
957
|
+
channels: string[];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function upworkConfigForProvisioning(configPath: string): ProvisionedUpworkConfig {
|
|
961
|
+
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
962
|
+
const upwork = config.capabilities.upwork;
|
|
963
|
+
const missing = [
|
|
964
|
+
...(upwork.enabled ? [] : ["capabilities.upwork.enabled"]),
|
|
965
|
+
...(upwork.channels.length > 0 ? [] : ["capabilities.upwork.channels"]),
|
|
966
|
+
];
|
|
967
|
+
if (missing.length > 0)
|
|
968
|
+
throw new Error(
|
|
969
|
+
`${configPath}: integrations.upwork requires a configured Upwork capability (${missing.join(", ")})`,
|
|
970
|
+
);
|
|
971
|
+
return { channels: [...upwork.channels] };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* The mailbox identities to bake into the proxy, read from the instance's own
|
|
976
|
+
* config file at synth.
|
|
977
|
+
*
|
|
978
|
+
* A plain file read: the CDK app must synthesize with no credentials and no
|
|
979
|
+
* network. A disabled capability yields an empty list, and so does a config
|
|
980
|
+
* that does not mention email at all — the proxy then refuses every identity,
|
|
981
|
+
* which is the correct behaviour for a capability nobody has turned on.
|
|
982
|
+
*/
|
|
983
|
+
export function emailIdentitiesFor(configPath: string): unknown[] {
|
|
984
|
+
const doc = parseYaml(readFileSync(configPath, "utf8")) as {
|
|
985
|
+
capabilities?: { email?: { enabled?: unknown; identities?: unknown } };
|
|
986
|
+
};
|
|
987
|
+
const email = doc.capabilities?.email;
|
|
988
|
+
if (email?.enabled !== true || !Array.isArray(email.identities)) return [];
|
|
989
|
+
return email.identities;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Server-side Browser allowlist baked into the isolated proxy at deploy time. */
|
|
993
|
+
export function browserDomainsFor(configPath: string): string[] {
|
|
994
|
+
const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
|
|
995
|
+
return browser.enabled && browser.mode === "read" ? [...browser.allowedDomains] : [];
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Secrets imported by name carry a partial ARN (no service-generated
|
|
1000
|
+
* 6-character suffix), which IAM will not match — append the wildcard the
|
|
1001
|
+
* same way CDK's own `grantRead` does.
|
|
1002
|
+
*/
|
|
1003
|
+
function secretArnPattern(secret: secretsmanager.ISecret): string {
|
|
1004
|
+
return secret.secretFullArn ?? `${secret.secretArn}-??????`;
|
|
1005
|
+
}
|