@deployfoundation/foundation-deploy 0.2.2 → 0.2.4
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/agent-image/Dockerfile +1 -0
- package/dist/bin/app.js +7 -3
- package/dist/bin/foundation-deploy.js +8 -2
- package/dist/bin/release-account.js +2 -2
- package/dist/{chunk-an8qhf3t.js → chunk-7h6zgsa5.js} +1 -1
- package/dist/{chunk-gcg67ck3.js → chunk-kf1yvx1w.js} +72 -12
- package/dist/{chunk-q9sxg76n.js → chunk-my0awr1g.js} +327 -28
- package/dist/src/index.js +2 -2
- package/package.json +1 -1
- package/src/artifacts.ts +1 -0
- package/src/deploy/assets/slack-app-manifest.yml +6 -0
- package/src/deploy/deploy.ts +6 -0
- package/src/deploy/runtime-secret.ts +12 -3
- package/src/stacks/agent-stack.ts +68 -0
- package/src/stacks/api-stack.ts +336 -48
- package/src/stacks/data-stack.ts +44 -0
|
@@ -39,6 +39,8 @@ export interface FoundationAgentProps extends cdk.StackProps {
|
|
|
39
39
|
crmTable?: dynamodb.ITable;
|
|
40
40
|
/** Proxy-only proposal drafts; explicitly denied to every agent-assumable role. */
|
|
41
41
|
upworkApprovalTable?: dynamodb.ITable;
|
|
42
|
+
/** Gateway-only E*TRADE OOB state; explicitly denied to every agent-assumable role. */
|
|
43
|
+
etradePendingTable?: dynamodb.ITable;
|
|
42
44
|
secrets: {
|
|
43
45
|
runtime: secretsmanager.ISecret;
|
|
44
46
|
codex: secretsmanager.ISecret;
|
|
@@ -404,6 +406,45 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
404
406
|
);
|
|
405
407
|
}
|
|
406
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Both roles are shell-reachable. Deny the stable retained secret names even
|
|
411
|
+
* while the integration is disabled, so an opt-out cannot expose orphaned
|
|
412
|
+
* OAuth material through the account's broad read-only role.
|
|
413
|
+
*/
|
|
414
|
+
private denyDirectEtradeAccess(role: iam.Role, props: FoundationAgentProps): void {
|
|
415
|
+
const retainedSecretArn = (name: string): string =>
|
|
416
|
+
cdk.Stack.of(this).formatArn({
|
|
417
|
+
service: "secretsmanager",
|
|
418
|
+
resource: "secret",
|
|
419
|
+
resourceName: `${name}-??????`,
|
|
420
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
421
|
+
});
|
|
422
|
+
role.addToPolicy(
|
|
423
|
+
new iam.PolicyStatement({
|
|
424
|
+
sid: "DenyEtradeSecrets",
|
|
425
|
+
effect: iam.Effect.DENY,
|
|
426
|
+
actions: ["secretsmanager:*"],
|
|
427
|
+
resources: [
|
|
428
|
+
retainedSecretArn(this.names.secretEtradeClient),
|
|
429
|
+
retainedSecretArn(this.names.secretEtradeCredential),
|
|
430
|
+
],
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
if (!provisionsIntegration(props.instance, "etrade")) return;
|
|
434
|
+
const pending = props.etradePendingTable;
|
|
435
|
+
if (pending === undefined)
|
|
436
|
+
throw new Error(
|
|
437
|
+
`${this.stackName}: integrations.etrade is enabled but FoundationData supplied no E*TRADE pending-state table`,
|
|
438
|
+
);
|
|
439
|
+
role.addToPolicy(
|
|
440
|
+
new iam.PolicyStatement({
|
|
441
|
+
sid: "DenyEtradePendingState",
|
|
442
|
+
effect: iam.Effect.DENY,
|
|
443
|
+
actions: ["dynamodb:*"],
|
|
444
|
+
resources: [pending.tableArn, `${pending.tableArn}/index/*`],
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
407
448
|
/**
|
|
408
449
|
* `<Prefix>ReadOnlyRole` — how the agent reads its OWN account.
|
|
409
450
|
*
|
|
@@ -431,6 +472,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
431
472
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
432
473
|
this.denyDirectKnockSecretAccess(role, props);
|
|
433
474
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
475
|
+
this.denyDirectEtradeAccess(role, props);
|
|
434
476
|
role.addToPolicy(
|
|
435
477
|
new iam.PolicyStatement({
|
|
436
478
|
sid: "DenyAgentCoreBrowserMetadata",
|
|
@@ -480,6 +522,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
480
522
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
481
523
|
this.denyDirectKnockSecretAccess(role, props);
|
|
482
524
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
525
|
+
this.denyDirectEtradeAccess(role, props);
|
|
483
526
|
|
|
484
527
|
// Foundation's own repository when a release names the image, because the
|
|
485
528
|
// runtime then pulls across accounts. The pull also needs Foundation's
|
|
@@ -721,6 +764,24 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
721
764
|
}),
|
|
722
765
|
);
|
|
723
766
|
}
|
|
767
|
+
if (provisionsIntegration(props.instance, "etrade")) {
|
|
768
|
+
// The entire financial-data surface is one fixed-operation Lambda. The
|
|
769
|
+
// explicit deny installed above prevents direct secret or pending-flow access.
|
|
770
|
+
role.addToPolicy(
|
|
771
|
+
new iam.PolicyStatement({
|
|
772
|
+
sid: "EtradeProxyInvoke",
|
|
773
|
+
actions: ["lambda:InvokeFunction"],
|
|
774
|
+
resources: [
|
|
775
|
+
cdk.Stack.of(this).formatArn({
|
|
776
|
+
service: "lambda",
|
|
777
|
+
resource: "function",
|
|
778
|
+
resourceName: this.names.etradeProxyFunctionName,
|
|
779
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
780
|
+
}),
|
|
781
|
+
],
|
|
782
|
+
}),
|
|
783
|
+
);
|
|
784
|
+
}
|
|
724
785
|
if (provisionsIntegration(props.instance, "crm")) {
|
|
725
786
|
// The CRM permission surface is one named proxy Lambda. Derive the ARN
|
|
726
787
|
// locally so FoundationAgent never depends on FoundationApi's output and closes a
|
|
@@ -857,6 +918,13 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
857
918
|
],
|
|
858
919
|
}),
|
|
859
920
|
);
|
|
921
|
+
role.addToPolicy(
|
|
922
|
+
new iam.PolicyStatement({
|
|
923
|
+
sid: "RoutineScheduleList",
|
|
924
|
+
actions: ["scheduler:ListSchedules"],
|
|
925
|
+
resources: ["*"],
|
|
926
|
+
}),
|
|
927
|
+
);
|
|
860
928
|
role.addToPolicy(
|
|
861
929
|
new iam.PolicyStatement({
|
|
862
930
|
sid: "PassRoutineSchedulerRole",
|
package/src/stacks/api-stack.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
type CrmOutreachConfig,
|
|
4
|
+
type ScheduledJobBinding,
|
|
5
|
+
type ScheduledJobConfig,
|
|
4
6
|
type WebhookJobBinding,
|
|
5
7
|
crmOutreachConfigured,
|
|
6
8
|
crmPolicyFingerprint,
|
|
9
|
+
isScheduledJob,
|
|
7
10
|
parseInstanceConfig,
|
|
11
|
+
scheduledJobBindings,
|
|
8
12
|
webhookJobBindings,
|
|
9
13
|
} from "@deployfoundation/foundation-core";
|
|
14
|
+
import { JOB_ACTOR } from "@deployfoundation/foundation-core/actor-capability";
|
|
10
15
|
import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
|
|
16
|
+
import { parseSchedule } from "@deployfoundation/foundation-core/schedule";
|
|
11
17
|
import * as cdk from "aws-cdk-lib";
|
|
12
18
|
import * as apigateway from "aws-cdk-lib/aws-apigateway";
|
|
13
19
|
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
@@ -50,6 +56,8 @@ export interface FoundationApiProps extends cdk.StackProps {
|
|
|
50
56
|
crmTable?: dynamodb.ITable;
|
|
51
57
|
/** Proxy-only one-hour proposal drafts; never granted to the agent runtime. */
|
|
52
58
|
upworkApprovalTable?: dynamodb.ITable;
|
|
59
|
+
/** Gateway-only OOB E*TRADE request-token state, present with the integration. */
|
|
60
|
+
etradePendingTable?: dynamodb.ITable;
|
|
53
61
|
secrets: {
|
|
54
62
|
signing: secretsmanager.ISecret;
|
|
55
63
|
slackApp: secretsmanager.ISecret;
|
|
@@ -68,6 +76,10 @@ export interface FoundationApiProps extends cdk.StackProps {
|
|
|
68
76
|
knockCredential?: secretsmanager.ISecret;
|
|
69
77
|
/** Combined Upwork OAuth client and token secret, present only for opted-in instances. */
|
|
70
78
|
upwork?: secretsmanager.ISecret;
|
|
79
|
+
/** E*TRADE OAuth consumer; gateway reads it but the agent never can. */
|
|
80
|
+
etradeClient?: secretsmanager.ISecret;
|
|
81
|
+
/** Connected E*TRADE credential; gateway writes and fixed read proxy alone reads. */
|
|
82
|
+
etradeCredential?: secretsmanager.ISecret;
|
|
71
83
|
};
|
|
72
84
|
/**
|
|
73
85
|
* The AgentCore runtime the invoker calls. On a phase-one deploy this is
|
|
@@ -113,6 +125,8 @@ export class FoundationApi extends cdk.Stack {
|
|
|
113
125
|
public readonly knockProxyFunction?: lambda.Function;
|
|
114
126
|
/** Fixed-operation Upwork proxy, present only when the instance opts in. */
|
|
115
127
|
public readonly upworkProxyFunction?: lambda.Function;
|
|
128
|
+
/** Fixed-operation E*TRADE read proxy, present only for opted-in instances. */
|
|
129
|
+
public readonly etradeProxyFunction?: lambda.Function;
|
|
116
130
|
|
|
117
131
|
constructor(scope: Construct, id: string, props: FoundationApiProps) {
|
|
118
132
|
super(scope, id, props);
|
|
@@ -127,7 +141,28 @@ export class FoundationApi extends cdk.Stack {
|
|
|
127
141
|
throw new Error(
|
|
128
142
|
`${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`,
|
|
129
143
|
);
|
|
144
|
+
const etrade = provisionsIntegration(props.instance, "etrade")
|
|
145
|
+
? etradeConfigForProvisioning(props.configPath)
|
|
146
|
+
: undefined;
|
|
147
|
+
const etradeClientSecret = etrade === undefined ? undefined : props.secrets.etradeClient;
|
|
148
|
+
const etradeCredentialSecret =
|
|
149
|
+
etrade === undefined ? undefined : props.secrets.etradeCredential;
|
|
150
|
+
const etradePendingTable = etrade === undefined ? undefined : props.etradePendingTable;
|
|
151
|
+
if (
|
|
152
|
+
etrade !== undefined &&
|
|
153
|
+
(etradeClientSecret === undefined ||
|
|
154
|
+
etradeCredentialSecret === undefined ||
|
|
155
|
+
etradePendingTable === undefined)
|
|
156
|
+
)
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${id}: integrations.etrade is enabled but its client secret, credential secret, or pending OAuth table was not supplied by FoundationData`,
|
|
159
|
+
);
|
|
130
160
|
const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
|
|
161
|
+
const configuredScheduledJobs = scheduledJobsFor(props.configPath);
|
|
162
|
+
const scheduledJobs = scheduledJobBindings(configuredScheduledJobs);
|
|
163
|
+
const scheduledJobTimezone = configTimezoneFor(props.configPath);
|
|
164
|
+
const etradeAllowedChannels = etrade === undefined ? "" : JSON.stringify(etrade.channels);
|
|
165
|
+
const etradeAllowedUsers = etrade === undefined ? "" : JSON.stringify(etrade.users);
|
|
131
166
|
|
|
132
167
|
this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
|
|
133
168
|
encryption: sqs.QueueEncryption.KMS,
|
|
@@ -227,6 +262,50 @@ export class FoundationApi extends cdk.Stack {
|
|
|
227
262
|
}),
|
|
228
263
|
);
|
|
229
264
|
}
|
|
265
|
+
if (
|
|
266
|
+
etradeClientSecret !== undefined &&
|
|
267
|
+
etradeCredentialSecret !== undefined &&
|
|
268
|
+
etradePendingTable !== undefined
|
|
269
|
+
) {
|
|
270
|
+
gatewayRole.addToPolicy(
|
|
271
|
+
new iam.PolicyStatement({
|
|
272
|
+
sid: "EtradeOAuthClientRead",
|
|
273
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
274
|
+
resources: [secretArnPattern(etradeClientSecret)],
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
gatewayRole.addToPolicy(
|
|
278
|
+
new iam.PolicyStatement({
|
|
279
|
+
sid: "EtradeOAuthCredentialWrite",
|
|
280
|
+
actions: [
|
|
281
|
+
"secretsmanager:DescribeSecret",
|
|
282
|
+
"secretsmanager:PutSecretValue",
|
|
283
|
+
"secretsmanager:UpdateSecretVersionStage",
|
|
284
|
+
],
|
|
285
|
+
resources: [secretArnPattern(etradeCredentialSecret)],
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
gatewayRole.addToPolicy(
|
|
289
|
+
new iam.PolicyStatement({
|
|
290
|
+
sid: "DenyEtradeOAuthCredentialRead",
|
|
291
|
+
effect: iam.Effect.DENY,
|
|
292
|
+
actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
|
|
293
|
+
resources: [secretArnPattern(etradeCredentialSecret)],
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
gatewayRole.addToPolicy(
|
|
297
|
+
new iam.PolicyStatement({
|
|
298
|
+
sid: "EtradeOAuthPendingState",
|
|
299
|
+
actions: [
|
|
300
|
+
"dynamodb:GetItem",
|
|
301
|
+
"dynamodb:PutItem",
|
|
302
|
+
"dynamodb:UpdateItem",
|
|
303
|
+
"dynamodb:DeleteItem",
|
|
304
|
+
],
|
|
305
|
+
resources: [etradePendingTable.tableArn],
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
230
309
|
gatewayRole.addToPolicy(
|
|
231
310
|
new iam.PolicyStatement({
|
|
232
311
|
sid: "DedupeAndThreadIndex",
|
|
@@ -286,6 +365,54 @@ export class FoundationApi extends cdk.Stack {
|
|
|
286
365
|
});
|
|
287
366
|
if (jobsWebhookSecret !== undefined) jobsWebhookSecret.grantRead(gatewayRole);
|
|
288
367
|
|
|
368
|
+
const gatewayEnvironment: Record<string, string> = {
|
|
369
|
+
FOUNDATION_TABLE_NAME: props.table.tableName,
|
|
370
|
+
FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
|
|
371
|
+
FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
|
|
372
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
373
|
+
FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
|
|
374
|
+
FOUNDATION_ADMINS: props.admins,
|
|
375
|
+
FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
|
|
376
|
+
FOUNDATION_DISPLAY_NAME: props.instance.displayName,
|
|
377
|
+
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
378
|
+
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
|
|
379
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
|
|
380
|
+
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
|
|
381
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
|
|
382
|
+
...(props.secrets.knockOauthClient === undefined
|
|
383
|
+
? {}
|
|
384
|
+
: { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
|
|
385
|
+
...(props.secrets.knockCredential === undefined
|
|
386
|
+
? {}
|
|
387
|
+
: { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
|
|
388
|
+
...(upworkSecret === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
|
|
391
|
+
...(etradeClientSecret === undefined ||
|
|
392
|
+
etradeCredentialSecret === undefined ||
|
|
393
|
+
etradePendingTable === undefined ||
|
|
394
|
+
etrade === undefined
|
|
395
|
+
? {}
|
|
396
|
+
: {
|
|
397
|
+
FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
|
|
398
|
+
FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
|
|
399
|
+
FOUNDATION_ETRADE_PENDING_TABLE_NAME: etradePendingTable.tableName,
|
|
400
|
+
FOUNDATION_ETRADE_ALLOWED_CHANNELS: etradeAllowedChannels,
|
|
401
|
+
FOUNDATION_ETRADE_ALLOWED_USERS: etradeAllowedUsers,
|
|
402
|
+
}),
|
|
403
|
+
FOUNDATION_INSTANCE: props.instance.name,
|
|
404
|
+
FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
|
|
405
|
+
FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
|
|
406
|
+
FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
|
|
407
|
+
...(jobsWebhookSecret === undefined
|
|
408
|
+
? {}
|
|
409
|
+
: {
|
|
410
|
+
FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
|
|
411
|
+
FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName,
|
|
412
|
+
}),
|
|
413
|
+
FOUNDATION_SCHEDULED_JOB_BINDINGS: JSON.stringify(scheduledJobs),
|
|
414
|
+
};
|
|
415
|
+
assertLambdaEnvironmentBudget(gatewayEnvironment);
|
|
289
416
|
const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
|
|
290
417
|
...(authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {}),
|
|
291
418
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
@@ -295,54 +422,7 @@ export class FoundationApi extends cdk.Stack {
|
|
|
295
422
|
timeout: cdk.Duration.seconds(10),
|
|
296
423
|
memorySize: 256,
|
|
297
424
|
deadLetterQueue: this.gatewayDlq,
|
|
298
|
-
environment:
|
|
299
|
-
FOUNDATION_TABLE_NAME: props.table.tableName,
|
|
300
|
-
FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
|
|
301
|
-
FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
|
|
302
|
-
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
303
|
-
FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
|
|
304
|
-
FOUNDATION_ADMINS: props.admins,
|
|
305
|
-
FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
|
|
306
|
-
// What the teammate is called. The gateway renders it in `/help`, in
|
|
307
|
-
// every "not configured" reply and in each OAuth result page, and
|
|
308
|
-
// `triggers.ts` uses it to tell a message addressed to the teammate
|
|
309
|
-
// from one addressed to a person. Without it the gateway falls back
|
|
310
|
-
// to a hard-coded name, wrong for every instance but one.
|
|
311
|
-
FOUNDATION_DISPLAY_NAME: props.instance.displayName,
|
|
312
|
-
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
313
|
-
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
|
|
314
|
-
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
|
|
315
|
-
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
|
|
316
|
-
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
|
|
317
|
-
...(props.secrets.knockOauthClient === undefined
|
|
318
|
-
? {}
|
|
319
|
-
: { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
|
|
320
|
-
...(props.secrets.knockCredential === undefined
|
|
321
|
-
? {}
|
|
322
|
-
: { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
|
|
323
|
-
...(upworkSecret === undefined
|
|
324
|
-
? {}
|
|
325
|
-
: { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
|
|
326
|
-
// Signed into every Drive and email consent link, so one instance's
|
|
327
|
-
// link cannot be redeemed against another's gateway.
|
|
328
|
-
FOUNDATION_INSTANCE: props.instance.name,
|
|
329
|
-
// Slash-command prefixes the gateway answers (current first, then legacy),
|
|
330
|
-
// from the instance file; the manifest renders the same prefix.
|
|
331
|
-
FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
|
|
332
|
-
// Read at SYNTH from the instance file, like FOUNDATION_ADMINS: the gateway
|
|
333
|
-
// decides what starts a turn and never reads `config/<name>.yaml`, so
|
|
334
|
-
// making the teammate speak unasked in a channel is a deploy, not a config sync.
|
|
335
|
-
// Empty for an instance that watches nothing, which is every instance
|
|
336
|
-
// that omits the block.
|
|
337
|
-
FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
|
|
338
|
-
FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
|
|
339
|
-
...(jobsWebhookSecret === undefined
|
|
340
|
-
? {}
|
|
341
|
-
: {
|
|
342
|
-
FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
|
|
343
|
-
FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName,
|
|
344
|
-
}),
|
|
345
|
-
},
|
|
425
|
+
environment: gatewayEnvironment,
|
|
346
426
|
});
|
|
347
427
|
// One mention must produce one reply: Lambda's default of two async
|
|
348
428
|
// retries would re-run a slow turn into duplicate Slack messages.
|
|
@@ -806,6 +886,64 @@ export class FoundationApi extends cdk.Stack {
|
|
|
806
886
|
});
|
|
807
887
|
}
|
|
808
888
|
|
|
889
|
+
// --- E*TRADE proxy ---------------------------------------------------
|
|
890
|
+
// This boundary exposes only six fixed GET operations. It alone can read
|
|
891
|
+
// the split OAuth consumer and connected access token, verifies signed
|
|
892
|
+
// actor/session claims, and reactivates inactive same-day tokens in place.
|
|
893
|
+
if (
|
|
894
|
+
etradeClientSecret !== undefined &&
|
|
895
|
+
etradeCredentialSecret !== undefined &&
|
|
896
|
+
etrade !== undefined
|
|
897
|
+
) {
|
|
898
|
+
const etradeProxyRole = new iam.Role(this, "EtradeProxyRole", {
|
|
899
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
900
|
+
description: `${display} E*TRADE fixed read-only proxy Lambda`,
|
|
901
|
+
managedPolicies: [
|
|
902
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
903
|
+
],
|
|
904
|
+
});
|
|
905
|
+
etradeProxyRole.addToPolicy(
|
|
906
|
+
new iam.PolicyStatement({
|
|
907
|
+
sid: "EtradeProxyOAuthSecrets",
|
|
908
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
909
|
+
resources: [
|
|
910
|
+
secretArnPattern(etradeClientSecret),
|
|
911
|
+
secretArnPattern(etradeCredentialSecret),
|
|
912
|
+
],
|
|
913
|
+
}),
|
|
914
|
+
);
|
|
915
|
+
etradeProxyRole.addToPolicy(
|
|
916
|
+
new iam.PolicyStatement({
|
|
917
|
+
sid: "EtradeProxySigningSecret",
|
|
918
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
919
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
920
|
+
}),
|
|
921
|
+
);
|
|
922
|
+
const etradeProxyEnvironment: Record<string, string> = {
|
|
923
|
+
FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
|
|
924
|
+
FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
|
|
925
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
926
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
927
|
+
FOUNDATION_ETRADE_ALLOWED_CHANNELS: JSON.stringify(etrade.channels),
|
|
928
|
+
FOUNDATION_ETRADE_ALLOWED_USERS: JSON.stringify(etrade.users),
|
|
929
|
+
FOUNDATION_ADMINS: props.admins,
|
|
930
|
+
};
|
|
931
|
+
assertLambdaEnvironmentBudget(etradeProxyEnvironment);
|
|
932
|
+
this.etradeProxyFunction = new lambda.Function(this, "EtradeProxyFunction", {
|
|
933
|
+
functionName: names.etradeProxyFunctionName,
|
|
934
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
935
|
+
handler: "index.handler",
|
|
936
|
+
code: lambdaCode(this, "etrade-proxy"),
|
|
937
|
+
role: etradeProxyRole,
|
|
938
|
+
timeout: cdk.Duration.seconds(30),
|
|
939
|
+
memorySize: 256,
|
|
940
|
+
environment: etradeProxyEnvironment,
|
|
941
|
+
});
|
|
942
|
+
new cdk.CfnOutput(this, "EtradeProxyFunctionArn", {
|
|
943
|
+
value: this.etradeProxyFunction.functionArn,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
809
947
|
// --- CRM proxy -------------------------------------------------------
|
|
810
948
|
// Fixed operations and deployment-baked policy over the instance's own
|
|
811
949
|
// self-hosted CRM table.
|
|
@@ -841,6 +979,9 @@ export class FoundationApi extends cdk.Stack {
|
|
|
841
979
|
"dynamodb:BatchGetItem",
|
|
842
980
|
"dynamodb:BatchWriteItem",
|
|
843
981
|
"dynamodb:TransactWriteItems",
|
|
982
|
+
// A transaction's ConditionCheck is its own IAM action; the
|
|
983
|
+
// repository guards every upsert and activity with one.
|
|
984
|
+
"dynamodb:ConditionCheckItem",
|
|
844
985
|
],
|
|
845
986
|
resources: [crmTable.tableArn],
|
|
846
987
|
}),
|
|
@@ -928,6 +1069,75 @@ export class FoundationApi extends cdk.Stack {
|
|
|
928
1069
|
alarm.addAlarmAction(alarmAction);
|
|
929
1070
|
}
|
|
930
1071
|
|
|
1072
|
+
// --- deployment-owned scheduled jobs --------------------------------
|
|
1073
|
+
// Config jobs never share the agent-managed routine group or pass role.
|
|
1074
|
+
// Only this unexposed dispatcher can mint the HMAC that ingress accepts
|
|
1075
|
+
// for JOB_ACTOR, so an agent shell cannot manufacture a privileged fire.
|
|
1076
|
+
if (configuredScheduledJobs.length > 0) {
|
|
1077
|
+
const dispatchRole = new iam.Role(this, "ScheduledJobDispatcherRole", {
|
|
1078
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1079
|
+
description: `${display} deployment-owned scheduled job dispatcher`,
|
|
1080
|
+
managedPolicies: [
|
|
1081
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1082
|
+
],
|
|
1083
|
+
});
|
|
1084
|
+
props.secrets.signing.grantRead(dispatchRole);
|
|
1085
|
+
gatewayFunction.grantInvoke(dispatchRole);
|
|
1086
|
+
const dispatchFunction = new lambda.Function(this, "ScheduledJobDispatcherFunction", {
|
|
1087
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1088
|
+
handler: "index.scheduledJobDispatcherHandler",
|
|
1089
|
+
code: lambdaCode(this, "gateway"),
|
|
1090
|
+
role: dispatchRole,
|
|
1091
|
+
timeout: cdk.Duration.seconds(30),
|
|
1092
|
+
memorySize: 128,
|
|
1093
|
+
deadLetterQueue: this.gatewayDlq,
|
|
1094
|
+
environment: {
|
|
1095
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1096
|
+
FOUNDATION_GATEWAY_FUNCTION_NAME: gatewayFunction.functionName,
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
dispatchFunction.configureAsyncInvoke({
|
|
1100
|
+
retryAttempts: 0,
|
|
1101
|
+
maxEventAge: cdk.Duration.minutes(15),
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const jobGroup = new scheduler.CfnScheduleGroup(this, "ScheduledJobGroup");
|
|
1105
|
+
const jobSchedulerRole = new iam.Role(this, "ScheduledJobSchedulerRole", {
|
|
1106
|
+
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com", {
|
|
1107
|
+
conditions: { StringEquals: { "aws:SourceAccount": this.account } },
|
|
1108
|
+
}),
|
|
1109
|
+
description: `${display} deployment-owned scheduled job invoker`,
|
|
1110
|
+
});
|
|
1111
|
+
dispatchFunction.grantInvoke(jobSchedulerRole);
|
|
1112
|
+
|
|
1113
|
+
for (const job of configuredScheduledJobs) {
|
|
1114
|
+
const parsed = parseSchedule(job.on.schedule);
|
|
1115
|
+
if (!parsed.ok) throw new Error(`Scheduled job ${job.id} has an invalid schedule`);
|
|
1116
|
+
const schedule = new scheduler.CfnSchedule(this, `ScheduledJob-${job.id}`, {
|
|
1117
|
+
groupName: jobGroup.ref,
|
|
1118
|
+
scheduleExpression: job.on.schedule,
|
|
1119
|
+
scheduleExpressionTimezone: scheduledJobTimezone,
|
|
1120
|
+
flexibleTimeWindow: { mode: "OFF" },
|
|
1121
|
+
...(parsed.kind === "at" ? { actionAfterCompletion: "DELETE" } : {}),
|
|
1122
|
+
target: {
|
|
1123
|
+
arn: dispatchFunction.functionArn,
|
|
1124
|
+
roleArn: jobSchedulerRole.roleArn,
|
|
1125
|
+
input: JSON.stringify({
|
|
1126
|
+
internal: "scheduled_job",
|
|
1127
|
+
routineId: `job-${job.id}`,
|
|
1128
|
+
name: job.name,
|
|
1129
|
+
channel: job.channel,
|
|
1130
|
+
prompt: job.prompt,
|
|
1131
|
+
createdBy: JOB_ACTOR,
|
|
1132
|
+
scheduledTime: "<aws.scheduler.scheduled-time>",
|
|
1133
|
+
}),
|
|
1134
|
+
retryPolicy: { maximumEventAgeInSeconds: 900, maximumRetryAttempts: 0 },
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
schedule.addResourceDependency(jobGroup);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
931
1141
|
// --- routines --------------------------------------------------------
|
|
932
1142
|
// The group and the role live here because the queue does. The agent
|
|
933
1143
|
// creates schedules INSIDE the group and passes the role; it never needs
|
|
@@ -1044,6 +1254,48 @@ function upworkConfigForProvisioning(configPath: string): ProvisionedUpworkConfi
|
|
|
1044
1254
|
return { channels: [...upwork.channels] };
|
|
1045
1255
|
}
|
|
1046
1256
|
|
|
1257
|
+
interface ProvisionedEtradeConfig {
|
|
1258
|
+
channels: string[];
|
|
1259
|
+
users: string[];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Deployment-baked E*TRADE access policy; config sync cannot widen it. */
|
|
1263
|
+
export function etradeConfigForProvisioning(configPath: string): ProvisionedEtradeConfig {
|
|
1264
|
+
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
1265
|
+
const etrade = config.capabilities.etrade;
|
|
1266
|
+
const missing = [
|
|
1267
|
+
...(etrade.enabled ? [] : ["capabilities.etrade.enabled"]),
|
|
1268
|
+
...(etrade.channels.length > 0 ? [] : ["capabilities.etrade.channels"]),
|
|
1269
|
+
...(etrade.users.length > 0 ? [] : ["capabilities.etrade.users"]),
|
|
1270
|
+
];
|
|
1271
|
+
if (missing.length > 0)
|
|
1272
|
+
throw new Error(
|
|
1273
|
+
`${configPath}: integrations.etrade requires a configured E*TRADE capability (${missing.join(", ")})`,
|
|
1274
|
+
);
|
|
1275
|
+
const seenAdmins = new Set<string>();
|
|
1276
|
+
const invalidAdmins = config.admins.filter((admin) => {
|
|
1277
|
+
const invalid =
|
|
1278
|
+
!/^[UW][A-Z0-9]{8,}$/.test(admin) || admin.length > 128 || seenAdmins.has(admin);
|
|
1279
|
+
seenAdmins.add(admin);
|
|
1280
|
+
return invalid;
|
|
1281
|
+
});
|
|
1282
|
+
if (invalidAdmins.length > 0) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`${configPath}: an E*TRADE deployment requires unique valid Slack user ids in admins`,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const nonAdmins = etrade.users.filter((user) => !config.admins.includes(user));
|
|
1288
|
+
if (nonAdmins.length > 0)
|
|
1289
|
+
throw new Error(
|
|
1290
|
+
`${configPath}: capabilities.etrade.users must be instance admins (${nonAdmins.join(", ")})`,
|
|
1291
|
+
);
|
|
1292
|
+
return { channels: [...etrade.channels], users: [...etrade.users] };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function configTimezoneFor(configPath: string): string {
|
|
1296
|
+
return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1047
1299
|
/**
|
|
1048
1300
|
* The mailbox identities to bake into the proxy, read from the instance's own
|
|
1049
1301
|
* config file at synth.
|
|
@@ -1081,6 +1333,42 @@ export function webhookJobsFor(configPath: string): WebhookJobBinding[] {
|
|
|
1081
1333
|
return jobs;
|
|
1082
1334
|
}
|
|
1083
1335
|
|
|
1336
|
+
export const LAMBDA_ENVIRONMENT_MAX_BYTES = 4_096;
|
|
1337
|
+
/** Conservative bound for generated names/ARNs after instance naming limits are applied. */
|
|
1338
|
+
export const LAMBDA_ENVIRONMENT_TOKEN_BYTES = 128;
|
|
1339
|
+
|
|
1340
|
+
/** Enforce Lambda's aggregate 4 KiB environment limit over every key and value. */
|
|
1341
|
+
export function assertLambdaEnvironmentBudget(values: Readonly<Record<string, string>>): void {
|
|
1342
|
+
let bytes = 2;
|
|
1343
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1344
|
+
bytes += Buffer.byteLength(key) + 3;
|
|
1345
|
+
bytes += cdk.Token.isUnresolved(value)
|
|
1346
|
+
? LAMBDA_ENVIRONMENT_TOKEN_BYTES
|
|
1347
|
+
: Buffer.byteLength(value);
|
|
1348
|
+
}
|
|
1349
|
+
if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
|
|
1350
|
+
throw new Error(
|
|
1351
|
+
`Gateway Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Deployment-baked hashes stop routine ingress minting arbitrary JOB_ACTOR turns. */
|
|
1357
|
+
export function scheduledJobBindingsFor(configPath: string): ScheduledJobBinding[] {
|
|
1358
|
+
return scheduledJobBindings(scheduledJobsFor(configPath));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** Scheduled jobs are deployment-owned infrastructure, never agent-owned routines. */
|
|
1362
|
+
export function scheduledJobsFor(configPath: string): ScheduledJobConfig[] {
|
|
1363
|
+
const jobs = parseInstanceConfig(readFileSync(configPath, "utf8")).jobs.filter(isScheduledJob);
|
|
1364
|
+
for (const job of jobs) {
|
|
1365
|
+
const parsed = parseSchedule(job.on.schedule);
|
|
1366
|
+
if (!parsed.ok)
|
|
1367
|
+
throw new Error(`Scheduled job ${job.id} has an invalid schedule: ${parsed.reason}`);
|
|
1368
|
+
}
|
|
1369
|
+
return jobs;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1084
1372
|
/** Server-side Browser allowlist baked into the isolated proxy at deploy time. */
|
|
1085
1373
|
export function browserDomainsFor(configPath: string): string[] {
|
|
1086
1374
|
const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
|
package/src/stacks/data-stack.ts
CHANGED
|
@@ -51,6 +51,8 @@ export class FoundationData extends cdk.Stack {
|
|
|
51
51
|
public readonly crmTable?: dynamodb.Table;
|
|
52
52
|
/** Ephemeral proposal drafts; accessible only to the credential-isolated Upwork proxy. */
|
|
53
53
|
public readonly upworkApprovalTable?: dynamodb.Table;
|
|
54
|
+
/** Short-lived OOB request-token state; gateway-only and destroyed with the opt-in. */
|
|
55
|
+
public readonly etradePendingTable?: dynamodb.Table;
|
|
54
56
|
public readonly secrets: {
|
|
55
57
|
signing: secretsmanager.ISecret;
|
|
56
58
|
slackApp: secretsmanager.ISecret;
|
|
@@ -69,6 +71,10 @@ export class FoundationData extends cdk.Stack {
|
|
|
69
71
|
knockCredential?: secretsmanager.ISecret;
|
|
70
72
|
/** Combined Upwork OAuth client and token secret, provisioned only when opted in. */
|
|
71
73
|
upwork?: secretsmanager.ISecret;
|
|
74
|
+
/** Retained E*TRADE OAuth consumer; the gateway reads this to connect. */
|
|
75
|
+
etradeClient?: secretsmanager.ISecret;
|
|
76
|
+
/** Retained access credential; gateway writes and fixed read proxy alone reads. */
|
|
77
|
+
etradeCredential?: secretsmanager.ISecret;
|
|
72
78
|
runtime: secretsmanager.ISecret;
|
|
73
79
|
};
|
|
74
80
|
|
|
@@ -158,6 +164,16 @@ export class FoundationData extends cdk.Stack {
|
|
|
158
164
|
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
159
165
|
})
|
|
160
166
|
: undefined;
|
|
167
|
+
this.etradePendingTable = provisionsIntegration(props.instance, "etrade")
|
|
168
|
+
? new dynamodb.Table(this, "EtradeOAuthPendingTable", {
|
|
169
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
170
|
+
timeToLiveAttribute: "ttl",
|
|
171
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
172
|
+
encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
|
|
173
|
+
encryptionKey: this.dataKey,
|
|
174
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
175
|
+
})
|
|
176
|
+
: undefined;
|
|
161
177
|
|
|
162
178
|
// An empty shell CDK is willing to synth without a real value; the real
|
|
163
179
|
// value is written out of band and survives every later deploy because
|
|
@@ -225,6 +241,32 @@ export class FoundationData extends cdk.Stack {
|
|
|
225
241
|
)
|
|
226
242
|
: undefined;
|
|
227
243
|
|
|
244
|
+
// E*TRADE access tokens expire daily, and disabling the integration must
|
|
245
|
+
// not strand fixed-name retained secrets CloudFormation cannot adopt on a
|
|
246
|
+
// later re-enable. Operators deliberately repopulate the client and reconnect.
|
|
247
|
+
const etradeClient = provisionsIntegration(props.instance, "etrade")
|
|
248
|
+
? new secretsmanager.Secret(this, "EtradeClientSecret", {
|
|
249
|
+
secretName: names.secretEtradeClient,
|
|
250
|
+
description: `E*TRADE OAuth consumer for ${display} ({ consumer_key, consumer_secret, environment })`,
|
|
251
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
252
|
+
generateSecretString: {
|
|
253
|
+
secretStringTemplate: "{}",
|
|
254
|
+
generateStringKey: "placeholder",
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
: undefined;
|
|
258
|
+
const etradeCredential = provisionsIntegration(props.instance, "etrade")
|
|
259
|
+
? new secretsmanager.Secret(this, "EtradeCredentialSecret", {
|
|
260
|
+
secretName: names.secretEtradeCredential,
|
|
261
|
+
description: `E*TRADE access token for ${display} ({ oauth_token, oauth_token_secret }); gateway writes and fixed read proxy alone reads`,
|
|
262
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
263
|
+
generateSecretString: {
|
|
264
|
+
secretStringTemplate: "{}",
|
|
265
|
+
generateStringKey: "placeholder",
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
: undefined;
|
|
269
|
+
|
|
228
270
|
this.secrets = {
|
|
229
271
|
// Already created out of band — import, never manage.
|
|
230
272
|
signing: secretsmanager.Secret.fromSecretNameV2(
|
|
@@ -305,6 +347,8 @@ export class FoundationData extends cdk.Stack {
|
|
|
305
347
|
...(knockOauthClient === undefined ? {} : { knockOauthClient }),
|
|
306
348
|
...(knockCredential === undefined ? {} : { knockCredential }),
|
|
307
349
|
...(upwork === undefined ? {} : { upwork }),
|
|
350
|
+
...(etradeClient === undefined ? {} : { etradeClient }),
|
|
351
|
+
...(etradeCredential === undefined ? {} : { etradeCredential }),
|
|
308
352
|
// The OAuth client every Google capability shares: Calendar builds a
|
|
309
353
|
// per-person consent link with it, Drive connects the company identity
|
|
310
354
|
// with it. Never a refresh token. A placeholder is fine — an unparseable
|