@deployfoundation/foundation-deploy 0.2.51 → 0.2.53
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 +4 -1
- package/dist/bin/app.js +6 -3
- package/dist/bin/foundation-deploy.js +141 -305
- package/dist/bin/release-account.js +1 -0
- package/dist/chunk-1pww63wd.js +301 -0
- package/dist/chunk-3614v9rr.js +907 -0
- package/dist/chunk-5wgwscd0.js +4 -0
- package/dist/{chunk-gnz747z2.js → chunk-96fjaysn.js} +204 -884
- package/dist/chunk-fjdgxw8h.js +516 -0
- package/dist/chunk-jf5rw6h4.js +1117 -0
- package/dist/chunk-m76jt5rc.js +376 -0
- package/dist/chunk-mh1206ne.js +1764 -0
- package/dist/chunk-nvsj7bbw.js +259 -0
- package/dist/chunk-p7zkm4pp.js +151 -0
- package/dist/chunk-pxhftba1.js +336 -0
- package/dist/{chunk-efqdhd2e.js → chunk-spq5kr1t.js} +333 -90
- package/dist/chunk-tey0gh86.js +38 -0
- package/dist/src/index.js +17 -13
- package/package.json +3 -1
- package/src/deploy/cli.ts +71 -0
- package/src/deploy/crm.ts +491 -0
- package/src/deploy/runtime-secret.ts +9 -0
- package/src/stacks/agent-stack.ts +68 -11
- package/src/stacks/api-stack.ts +340 -87
- package/src/stacks/data-stack.ts +27 -1
- package/src/stacks/secret-bearing-reads.ts +131 -0
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
provisionsIntegration,
|
|
24
24
|
requiresAuthenticatedQueue,
|
|
25
25
|
} from "../names.ts";
|
|
26
|
+
import { SECRET_BEARING_READ_ACTIONS } from "./secret-bearing-reads.ts";
|
|
26
27
|
|
|
27
28
|
export interface FoundationAgentProps extends cdk.StackProps {
|
|
28
29
|
/** The deployment this stack belongs to; every name below comes from it. */
|
|
@@ -154,6 +155,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
154
155
|
this.accessPoint = accessPoint;
|
|
155
156
|
this.executionRole = this.buildExecutionRole(props);
|
|
156
157
|
this.readOnlyRole = this.buildReadOnlyRole(props);
|
|
158
|
+
this.secretBearingReadsPolicy(props.instance, [this.executionRole, this.readOnlyRole]);
|
|
157
159
|
this.buildSpansIngestionPolicy();
|
|
158
160
|
|
|
159
161
|
// The gateway is independent of the runtime: it can (and does) exist
|
|
@@ -378,26 +380,47 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
378
380
|
`${retainedTableArn}/index/*`,
|
|
379
381
|
...(crmTable === undefined ? [] : [crmTable.tableArn, `${crmTable.tableArn}/index/*`]),
|
|
380
382
|
];
|
|
383
|
+
// Every DynamoDB action, not only reads. The table also holds the
|
|
384
|
+
// proxy-owned outreach ledger (`TENANT#<name>#OUTREACH`, the weekly draft
|
|
385
|
+
// counters and each contact's touch clock). Until 2026-10 the deny named
|
|
386
|
+
// only read actions, so a future broad allow could have let the agent
|
|
387
|
+
// write a counter back to zero, delete a clock or restore a copy, and lift
|
|
388
|
+
// the weekly caps without going through the proxy.
|
|
381
389
|
role.addToPolicy(
|
|
382
390
|
new iam.PolicyStatement({
|
|
383
391
|
sid: "DenyDirectCrmTableAccess",
|
|
384
392
|
effect: iam.Effect.DENY,
|
|
385
|
-
actions: [
|
|
386
|
-
"dynamodb:GetItem",
|
|
387
|
-
"dynamodb:BatchGetItem",
|
|
388
|
-
"dynamodb:TransactGetItems",
|
|
389
|
-
"dynamodb:Query",
|
|
390
|
-
"dynamodb:Scan",
|
|
391
|
-
"dynamodb:PartiQLSelect",
|
|
392
|
-
"dynamodb:ExecuteStatement",
|
|
393
|
-
"dynamodb:BatchExecuteStatement",
|
|
394
|
-
"dynamodb:ExportTableToPointInTime",
|
|
395
|
-
],
|
|
393
|
+
actions: ["dynamodb:*"],
|
|
396
394
|
resources,
|
|
397
395
|
}),
|
|
398
396
|
);
|
|
399
397
|
}
|
|
400
398
|
|
|
399
|
+
/**
|
|
400
|
+
* The key for a company's own CRM (`crm.provider` kind `http`) is the CRM
|
|
401
|
+
* proxy's alone. Both shell-reachable roles deny every action on it, by its
|
|
402
|
+
* fixed name, so no future broad grant can read it or overwrite it with a
|
|
403
|
+
* key of the agent's choosing.
|
|
404
|
+
*/
|
|
405
|
+
private denyDirectCrmHttpSecretAccess(role: iam.Role, instance: Instance): void {
|
|
406
|
+
if (!provisionsIntegration(instance, "crm") || instance.crm.provider.kind !== "http") return;
|
|
407
|
+
role.addToPolicy(
|
|
408
|
+
new iam.PolicyStatement({
|
|
409
|
+
sid: "DenyCrmHttpSecret",
|
|
410
|
+
effect: iam.Effect.DENY,
|
|
411
|
+
actions: ["secretsmanager:*"],
|
|
412
|
+
resources: [
|
|
413
|
+
cdk.Stack.of(this).formatArn({
|
|
414
|
+
service: "secretsmanager",
|
|
415
|
+
resource: "secret",
|
|
416
|
+
resourceName: `${this.names.secretCrmHttp}-??????`,
|
|
417
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
418
|
+
}),
|
|
419
|
+
],
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
401
424
|
/** Deny remains attached to both execution and assumed read-only roles. */
|
|
402
425
|
private denyDirectKnockSecretAccess(role: iam.Role, props: FoundationAgentProps): void {
|
|
403
426
|
if (!provisionsIntegration(props.instance, "knock")) return;
|
|
@@ -616,6 +639,8 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
616
639
|
* `ReadOnlyAccess` is AWS's own managed read-only policy. The inline
|
|
617
640
|
* additions are the two things it does NOT cover which the observability
|
|
618
641
|
* tools need: starting a Logs Insights query, and reading trace summaries.
|
|
642
|
+
* What it covers but must not, the reads whose responses carry secrets,
|
|
643
|
+
* is denied by `secretBearingReadsPolicy`.
|
|
619
644
|
*/
|
|
620
645
|
private buildReadOnlyRole(props: FoundationAgentProps): iam.Role {
|
|
621
646
|
const role = new iam.Role(this, "AgentReadOnlyRole", {
|
|
@@ -628,6 +653,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
628
653
|
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")],
|
|
629
654
|
});
|
|
630
655
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
656
|
+
this.denyDirectCrmHttpSecretAccess(role, props.instance);
|
|
631
657
|
this.denyDirectKnockSecretAccess(role, props);
|
|
632
658
|
this.denyDirectEmailSecretAccess(
|
|
633
659
|
role,
|
|
@@ -687,6 +713,36 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
687
713
|
return role;
|
|
688
714
|
}
|
|
689
715
|
|
|
716
|
+
/**
|
|
717
|
+
* `ReadOnlyAccess` reads that return secrets: Lambda and container
|
|
718
|
+
* environment variables, user data, job and connection arguments, sampled
|
|
719
|
+
* WAF request headers, minted tokens ({@link SECRET_BEARING_READ_ACTIONS}).
|
|
720
|
+
*
|
|
721
|
+
* Both shell-reachable roles carry the deny. The read-only role needs it
|
|
722
|
+
* today, since its managed policy grants every one of these. The execution
|
|
723
|
+
* role grants none of them, and it gets the deny anyway for the same reason
|
|
724
|
+
* as the secret denies above: no future broad grant may reopen them.
|
|
725
|
+
*
|
|
726
|
+
* A managed policy of its own, like the pages policy, because the execution
|
|
727
|
+
* role's inline policy is near IAM's size limit. `Resource: "*"` because
|
|
728
|
+
* the point is every function, project and parameter in the account, not
|
|
729
|
+
* the instance's own.
|
|
730
|
+
*/
|
|
731
|
+
private secretBearingReadsPolicy(instance: Instance, roles: iam.IRole[]): void {
|
|
732
|
+
new iam.ManagedPolicy(this, "AgentSecretBearingReadsDeny", {
|
|
733
|
+
description: `${instance.displayName} agent: no AWS read whose response carries a secret`,
|
|
734
|
+
roles,
|
|
735
|
+
statements: [
|
|
736
|
+
new iam.PolicyStatement({
|
|
737
|
+
sid: "DenySecretBearingReads",
|
|
738
|
+
effect: iam.Effect.DENY,
|
|
739
|
+
actions: [...SECRET_BEARING_READ_ACTIONS],
|
|
740
|
+
resources: ["*"],
|
|
741
|
+
}),
|
|
742
|
+
],
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
690
746
|
/**
|
|
691
747
|
* Let X-Ray write this runtime's spans into the runtime's own log group.
|
|
692
748
|
*
|
|
@@ -735,6 +791,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
735
791
|
description: `${props.instance.displayName} AgentCore execution role (least privilege)`,
|
|
736
792
|
});
|
|
737
793
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
794
|
+
this.denyDirectCrmHttpSecretAccess(role, props.instance);
|
|
738
795
|
this.denyDirectKnockSecretAccess(role, props);
|
|
739
796
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
740
797
|
this.denyDirectEmailSecretAccess(
|
package/src/stacks/api-stack.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
type CrmOutreachConfig,
|
|
4
|
+
type CrmStatusPolicy,
|
|
5
|
+
type CrmStatusesByType,
|
|
4
6
|
type EmailIdentity,
|
|
5
7
|
type GoogleDocsIdentity,
|
|
6
8
|
type ScheduledJobBinding,
|
|
@@ -16,7 +18,10 @@ import {
|
|
|
16
18
|
webhookJobBindings,
|
|
17
19
|
} from "@deployfoundation/foundation-core";
|
|
18
20
|
import { JOB_ACTOR } from "@deployfoundation/foundation-core/actor-capability";
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
FOUNDATION_VERSION_RE,
|
|
23
|
+
slackCommandPrefixes,
|
|
24
|
+
} from "@deployfoundation/foundation-core/instance";
|
|
20
25
|
import { parseSchedule } from "@deployfoundation/foundation-core/schedule";
|
|
21
26
|
import * as cdk from "aws-cdk-lib";
|
|
22
27
|
import * as apigateway from "aws-cdk-lib/aws-apigateway";
|
|
@@ -34,7 +39,7 @@ import * as sns from "aws-cdk-lib/aws-sns";
|
|
|
34
39
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
35
40
|
import type { Construct } from "constructs";
|
|
36
41
|
import { parse as parseYaml } from "yaml";
|
|
37
|
-
import { lambdaCode } from "../artifacts.ts";
|
|
42
|
+
import { lambdaCode, releaseSource } from "../artifacts.ts";
|
|
38
43
|
import {
|
|
39
44
|
type Instance,
|
|
40
45
|
LIVE_ENDPOINT_NAME,
|
|
@@ -1129,17 +1134,26 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1129
1134
|
|
|
1130
1135
|
// --- CRM proxy -------------------------------------------------------
|
|
1131
1136
|
// Fixed operations and deployment-baked policy over the instance's own
|
|
1132
|
-
// self-hosted CRM table
|
|
1137
|
+
// self-hosted CRM table, or over the company's own CRM through `crm/1`
|
|
1138
|
+
// (`crm.provider` in the instance file). Either way this role is the only
|
|
1139
|
+
// principal that touches the records or holds a credential for them.
|
|
1133
1140
|
if (provisionsIntegration(props.instance, "crm")) {
|
|
1134
1141
|
const crm = crmConfigForProvisioning(props.configPath);
|
|
1142
|
+
const provider = props.instance.crm.provider;
|
|
1135
1143
|
const crmTable = props.crmTable;
|
|
1136
1144
|
if (crmTable === undefined)
|
|
1137
1145
|
throw new Error(
|
|
1138
1146
|
`${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`,
|
|
1139
1147
|
);
|
|
1148
|
+
// With `http` the table holds only the outreach cap ledger, so the
|
|
1149
|
+
// proxy's role gets it only when outreach is configured.
|
|
1150
|
+
const usesTable = provider.kind === "dynamodb" || crm.outreach !== undefined;
|
|
1140
1151
|
const crmProxyRole = new iam.Role(this, "CrmProxyRole", {
|
|
1141
1152
|
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1142
|
-
description:
|
|
1153
|
+
description:
|
|
1154
|
+
provider.kind === "dynamodb"
|
|
1155
|
+
? `${display} CRM proxy Lambda role (self-hosted CRM table access only)`
|
|
1156
|
+
: `${display} CRM proxy Lambda role (crm/1 key and outreach ledger only)`,
|
|
1143
1157
|
managedPolicies: [
|
|
1144
1158
|
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1145
1159
|
],
|
|
@@ -1151,41 +1165,128 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1151
1165
|
resources: [secretArnPattern(props.secrets.signing)],
|
|
1152
1166
|
}),
|
|
1153
1167
|
);
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1168
|
+
if (provider.kind === "dynamodb") {
|
|
1169
|
+
crmProxyRole.addToPolicy(
|
|
1170
|
+
new iam.PolicyStatement({
|
|
1171
|
+
sid: "CrmTableData",
|
|
1172
|
+
actions: [
|
|
1173
|
+
"dynamodb:GetItem",
|
|
1174
|
+
"dynamodb:PutItem",
|
|
1175
|
+
"dynamodb:UpdateItem",
|
|
1176
|
+
"dynamodb:DeleteItem",
|
|
1177
|
+
"dynamodb:BatchGetItem",
|
|
1178
|
+
"dynamodb:BatchWriteItem",
|
|
1179
|
+
"dynamodb:TransactWriteItems",
|
|
1180
|
+
// A transaction's ConditionCheck is its own IAM action; the
|
|
1181
|
+
// repository guards every upsert and activity with one.
|
|
1182
|
+
"dynamodb:ConditionCheckItem",
|
|
1183
|
+
],
|
|
1184
|
+
resources: [crmTable.tableArn],
|
|
1185
|
+
}),
|
|
1186
|
+
);
|
|
1187
|
+
crmProxyRole.addToPolicy(
|
|
1188
|
+
new iam.PolicyStatement({
|
|
1189
|
+
sid: "CrmRecordsIndex",
|
|
1190
|
+
actions: ["dynamodb:Query"],
|
|
1191
|
+
resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
|
|
1192
|
+
}),
|
|
1193
|
+
);
|
|
1194
|
+
} else {
|
|
1195
|
+
// Read only. The key is written by an operator (`crm-key`), never by
|
|
1196
|
+
// the proxy; the name is the fixed one FoundationData created it
|
|
1197
|
+
// under, so this stack holds no cross-stack reference to it.
|
|
1198
|
+
crmProxyRole.addToPolicy(
|
|
1199
|
+
new iam.PolicyStatement({
|
|
1200
|
+
sid: "CrmHttpSecret",
|
|
1201
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
1202
|
+
resources: [
|
|
1203
|
+
secretArnPattern(
|
|
1204
|
+
secretsmanager.Secret.fromSecretNameV2(this, "CrmHttpSecret", names.secretCrmHttp),
|
|
1205
|
+
),
|
|
1206
|
+
],
|
|
1207
|
+
}),
|
|
1208
|
+
);
|
|
1209
|
+
// The ledger's items only: its weekly counters and reservations under
|
|
1210
|
+
// one partition (`TENANT#<instance>#OUTREACH`). The profile and
|
|
1211
|
+
// activity items a DynamoDB-provider proxy wrote stay in the table,
|
|
1212
|
+
// unreadable to this role, and so does the records index. A reserve
|
|
1213
|
+
// is one transaction (Put plus Update); a release deletes the
|
|
1214
|
+
// reservation in another.
|
|
1215
|
+
if (usesTable)
|
|
1216
|
+
crmProxyRole.addToPolicy(
|
|
1217
|
+
new iam.PolicyStatement({
|
|
1218
|
+
sid: "CrmOutreachLedger",
|
|
1219
|
+
actions: [
|
|
1220
|
+
"dynamodb:GetItem",
|
|
1221
|
+
"dynamodb:PutItem",
|
|
1222
|
+
"dynamodb:UpdateItem",
|
|
1223
|
+
"dynamodb:DeleteItem",
|
|
1224
|
+
"dynamodb:TransactWriteItems",
|
|
1225
|
+
],
|
|
1226
|
+
resources: [crmTable.tableArn],
|
|
1227
|
+
conditions: {
|
|
1228
|
+
"ForAllValues:StringEquals": {
|
|
1229
|
+
"dynamodb:LeadingKeys": [crmOutreachLedgerPartition(props.instance.name)],
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
}),
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1179
1235
|
// The table is encrypted with the instance's data key, so every read and
|
|
1180
1236
|
// write needs it. Without this grant each call failed as `proxy_error`
|
|
1181
1237
|
// (KMS AccessDenied) the first time a deployment actually used its CRM.
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1238
|
+
if (usesTable)
|
|
1239
|
+
crmProxyRole.addToPolicy(
|
|
1240
|
+
new iam.PolicyStatement({
|
|
1241
|
+
sid: "CrmTableKey",
|
|
1242
|
+
actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
1243
|
+
resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
|
|
1244
|
+
}),
|
|
1245
|
+
);
|
|
1246
|
+
const release = releaseVersionContext(this);
|
|
1247
|
+
const crmProxyEnvironment: Record<string, string> = {
|
|
1248
|
+
// Baked even when `http` has no ledger to keep (the proxy then ignores
|
|
1249
|
+
// it, and the role above has no grant on the table): dropping it would
|
|
1250
|
+
// drop FoundationData's export of the name while the API stack still
|
|
1251
|
+
// deployed imports it, and the data stack, deployed first, would fail.
|
|
1252
|
+
FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
|
|
1253
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1254
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
1255
|
+
FOUNDATION_CRM_TENANT_ID: props.instance.name,
|
|
1256
|
+
FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
|
|
1257
|
+
FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
|
|
1258
|
+
FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
|
|
1259
|
+
FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
|
|
1260
|
+
// Which status changes the agent may make, and whether it may
|
|
1261
|
+
// delete: refused here, so a config sync alone cannot widen them.
|
|
1262
|
+
FOUNDATION_CRM_STATUS_POLICY: JSON.stringify(crm.statusPolicy),
|
|
1263
|
+
FOUNDATION_CRM_DELETES: String(crm.deletes),
|
|
1264
|
+
// The outreach caps are enforced here, so they are baked like the
|
|
1265
|
+
// rest of the policy: a config sync alone cannot lift them.
|
|
1266
|
+
...(crm.outreach === undefined
|
|
1267
|
+
? {}
|
|
1268
|
+
: {
|
|
1269
|
+
FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
|
|
1270
|
+
FOUNDATION_CRM_TIMEZONE: crm.timezone,
|
|
1271
|
+
}),
|
|
1272
|
+
// Where the records live. Unset means `dynamodb`, so a DynamoDB
|
|
1273
|
+
// deployment's environment is what it was before `http` existed.
|
|
1274
|
+
...(provider.kind === "http"
|
|
1275
|
+
? {
|
|
1276
|
+
FOUNDATION_CRM_PROVIDER: "http",
|
|
1277
|
+
FOUNDATION_CRM_HTTP_BASE_URL: provider.baseUrl,
|
|
1278
|
+
// The id, never the key: the proxy reads it at request time.
|
|
1279
|
+
FOUNDATION_CRM_HTTP_SECRET_ID: names.secretCrmHttp,
|
|
1280
|
+
FOUNDATION_CRM_HTTP_EXPECTED_TENANT: provider.expectedTenant,
|
|
1281
|
+
// Only the User-Agent the CRM sees, for its logs.
|
|
1282
|
+
...(release === undefined ? {} : { FOUNDATION_RELEASE: release }),
|
|
1283
|
+
}
|
|
1284
|
+
: {}),
|
|
1285
|
+
};
|
|
1286
|
+
// The config parser caps the policy JSON (CRM_MAX_POLICY_ENV_BYTES);
|
|
1287
|
+
// this checks the whole environment, names and table included, at synth
|
|
1288
|
+
// rather than as a failed Lambda update halfway through a deploy.
|
|
1289
|
+
assertLambdaEnvironmentBudget(crmProxyEnvironment);
|
|
1189
1290
|
this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
|
|
1190
1291
|
functionName: names.crmProxyFunctionName,
|
|
1191
1292
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
@@ -1194,24 +1295,7 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1194
1295
|
role: crmProxyRole,
|
|
1195
1296
|
timeout: cdk.Duration.seconds(30),
|
|
1196
1297
|
memorySize: 256,
|
|
1197
|
-
environment:
|
|
1198
|
-
FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
|
|
1199
|
-
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1200
|
-
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
1201
|
-
FOUNDATION_CRM_TENANT_ID: props.instance.name,
|
|
1202
|
-
FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
|
|
1203
|
-
FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
|
|
1204
|
-
FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
|
|
1205
|
-
FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
|
|
1206
|
-
// The outreach caps are enforced here, so they are baked like the
|
|
1207
|
-
// rest of the policy: a config sync alone cannot lift them.
|
|
1208
|
-
...(crm.outreach === undefined
|
|
1209
|
-
? {}
|
|
1210
|
-
: {
|
|
1211
|
-
FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
|
|
1212
|
-
FOUNDATION_CRM_TIMEZONE: crm.timezone,
|
|
1213
|
-
}),
|
|
1214
|
-
},
|
|
1298
|
+
environment: crmProxyEnvironment,
|
|
1215
1299
|
});
|
|
1216
1300
|
new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
|
|
1217
1301
|
value: this.crmProxyFunction.functionArn,
|
|
@@ -1277,6 +1361,13 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1277
1361
|
agentRuntimeArn: props.agentRuntimeArn,
|
|
1278
1362
|
alarmAction,
|
|
1279
1363
|
});
|
|
1364
|
+
// A company's own CRM refusing, or answering as someone else.
|
|
1365
|
+
if (this.crmProxyFunction !== undefined && props.instance.crm.provider.kind === "http")
|
|
1366
|
+
addCrmProviderAlarms(this, {
|
|
1367
|
+
instance: props.instance,
|
|
1368
|
+
functionName: names.crmProxyFunctionName,
|
|
1369
|
+
alarmAction,
|
|
1370
|
+
});
|
|
1280
1371
|
|
|
1281
1372
|
// --- deployment-owned scheduled jobs --------------------------------
|
|
1282
1373
|
// Config jobs never share the agent-managed routine group or pass role.
|
|
@@ -1451,37 +1542,13 @@ export function addConfigLoadAlarm(
|
|
|
1451
1542
|
if (args.agentRuntimeArn.endsWith("-pending")) return undefined;
|
|
1452
1543
|
const runtimeId = cdk.Fn.select(1, cdk.Fn.split("/", args.agentRuntimeArn));
|
|
1453
1544
|
const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${LIVE_ENDPOINT_NAME}`;
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
managedPolicies: [
|
|
1458
|
-
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1459
|
-
],
|
|
1460
|
-
});
|
|
1461
|
-
role.addToPolicy(
|
|
1462
|
-
new iam.PolicyStatement({
|
|
1463
|
-
sid: "CreateRuntimeLiveLogGroup",
|
|
1464
|
-
actions: ["logs:CreateLogGroup"],
|
|
1465
|
-
resources: [
|
|
1466
|
-
`arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${logGroupName}`,
|
|
1467
|
-
],
|
|
1468
|
-
}),
|
|
1469
|
-
);
|
|
1470
|
-
const ensureFunction = new lambda.Function(stack, "AgentLiveLogGroupFunction", {
|
|
1471
|
-
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1472
|
-
handler: "index.runtimeLogGroupHandler",
|
|
1473
|
-
code: lambdaCode(stack, "gateway"),
|
|
1474
|
-
role,
|
|
1475
|
-
timeout: cdk.Duration.seconds(30),
|
|
1476
|
-
memorySize: 128,
|
|
1477
|
-
});
|
|
1478
|
-
const ensureLogGroup = new cdk.CustomResource(stack, "AgentLiveLogGroup", {
|
|
1479
|
-
serviceToken: ensureFunction.functionArn,
|
|
1545
|
+
const ensureLogGroup = ensureLogGroupExists(stack, {
|
|
1546
|
+
id: "AgentLiveLogGroup",
|
|
1547
|
+
sid: "CreateRuntimeLiveLogGroup",
|
|
1480
1548
|
resourceType: "Custom::FoundationRuntimeLogGroup",
|
|
1481
|
-
|
|
1549
|
+
description: `${args.instance.displayName} creates the runtime's live log group when missing`,
|
|
1550
|
+
logGroupName,
|
|
1482
1551
|
});
|
|
1483
|
-
// The function needs its permission before CloudFormation invokes it.
|
|
1484
|
-
ensureLogGroup.node.addDependency(role);
|
|
1485
1552
|
const filter = new logs.MetricFilter(stack, "ConfigLoadFailureMetricFilter", {
|
|
1486
1553
|
logGroup: logs.LogGroup.fromLogGroupName(stack, "AgentLiveLogs", logGroupName),
|
|
1487
1554
|
filterPattern: logs.FilterPattern.any(
|
|
@@ -1512,11 +1579,193 @@ export function addConfigLoadAlarm(
|
|
|
1512
1579
|
return alarm;
|
|
1513
1580
|
}
|
|
1514
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* A custom resource that creates `logGroupName` when it is missing and leaves
|
|
1584
|
+
* it alone when it exists (or when the stack is deleted), so a metric filter
|
|
1585
|
+
* can attach to a group that AWS, not CloudFormation, creates on first use.
|
|
1586
|
+
* The handler (`runtimeLogGroupHandler`, in the release-signed gateway bundle)
|
|
1587
|
+
* accepts only an AgentCore runtime's or a Lambda's group, and the role may
|
|
1588
|
+
* create exactly this one.
|
|
1589
|
+
*/
|
|
1590
|
+
function ensureLogGroupExists(
|
|
1591
|
+
stack: cdk.Stack,
|
|
1592
|
+
args: {
|
|
1593
|
+
/** Construct id prefix: `<id>Role`, `<id>Function`, and `<id>` for the resource. */
|
|
1594
|
+
id: string;
|
|
1595
|
+
sid: string;
|
|
1596
|
+
resourceType: string;
|
|
1597
|
+
description: string;
|
|
1598
|
+
logGroupName: string;
|
|
1599
|
+
},
|
|
1600
|
+
): cdk.CustomResource {
|
|
1601
|
+
const role = new iam.Role(stack, `${args.id}Role`, {
|
|
1602
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1603
|
+
description: args.description,
|
|
1604
|
+
managedPolicies: [
|
|
1605
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1606
|
+
],
|
|
1607
|
+
});
|
|
1608
|
+
role.addToPolicy(
|
|
1609
|
+
new iam.PolicyStatement({
|
|
1610
|
+
sid: args.sid,
|
|
1611
|
+
actions: ["logs:CreateLogGroup"],
|
|
1612
|
+
resources: [
|
|
1613
|
+
`arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${args.logGroupName}`,
|
|
1614
|
+
],
|
|
1615
|
+
}),
|
|
1616
|
+
);
|
|
1617
|
+
const ensureFunction = new lambda.Function(stack, `${args.id}Function`, {
|
|
1618
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1619
|
+
handler: "index.runtimeLogGroupHandler",
|
|
1620
|
+
code: lambdaCode(stack, "gateway"),
|
|
1621
|
+
role,
|
|
1622
|
+
timeout: cdk.Duration.seconds(30),
|
|
1623
|
+
memorySize: 128,
|
|
1624
|
+
});
|
|
1625
|
+
const ensureLogGroup = new cdk.CustomResource(stack, args.id, {
|
|
1626
|
+
serviceToken: ensureFunction.functionArn,
|
|
1627
|
+
resourceType: args.resourceType,
|
|
1628
|
+
properties: { LogGroupName: args.logGroupName },
|
|
1629
|
+
});
|
|
1630
|
+
// The function needs its permission before CloudFormation invokes it.
|
|
1631
|
+
ensureLogGroup.node.addDependency(role);
|
|
1632
|
+
return ensureLogGroup;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* The CRM proxy's log codes that mean a company's own CRM (`crm.provider`
|
|
1637
|
+
* kind `http`) needs a person. Each is one metric filter on the proxy's log
|
|
1638
|
+
* group and one alarm on the instance's alarm topic; the log line itself
|
|
1639
|
+
* carries only fixed words (the op, the status class, which `/meta` check
|
|
1640
|
+
* failed), never a record or a reply body.
|
|
1641
|
+
*/
|
|
1642
|
+
export const CRM_PROVIDER_ALARMS = [
|
|
1643
|
+
{
|
|
1644
|
+
event: "crm_proxy",
|
|
1645
|
+
code: "tenant_mismatch",
|
|
1646
|
+
name: "TenantMismatch",
|
|
1647
|
+
description:
|
|
1648
|
+
"The CRM answered as a different tenant from crm.provider.expectedTenant, so the CRM proxy refused the reply and the CRM tools fail closed. Check that the key written with crm-key was issued for that tenant, and that expectedTenant is right.",
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
event: "crm_proxy",
|
|
1652
|
+
code: "upstream_unauthorized",
|
|
1653
|
+
name: "UpstreamUnauthorized",
|
|
1654
|
+
description:
|
|
1655
|
+
"The CRM refused the CRM proxy's key (401), even after the proxy re-read it: revoked, expired or wrong. Issue a new key in the CRM and run foundation-deploy crm-key; the proxy picks it up on its next call.",
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
event: "crm_proxy",
|
|
1659
|
+
code: "upstream_forbidden",
|
|
1660
|
+
name: "UpstreamForbidden",
|
|
1661
|
+
description:
|
|
1662
|
+
"The CRM answered 403: a scope the key lacks, the CRM's kill switch, or a WAF in front of it blocking the proxy. Check the key's scopes, the switch and the WAF's logs.",
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
event: "crm_proxy",
|
|
1666
|
+
code: "unconfigured",
|
|
1667
|
+
name: "Unconfigured",
|
|
1668
|
+
description:
|
|
1669
|
+
"The CRM proxy is unconfigured, so every CRM tool fails closed: no key written yet (run foundation-deploy crm-key), or the CRM's /meta does not match the deployment. The log line's check field names which: contract, statuses, activity_types, batch_size or operations.",
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
event: "crm_proxy_warning",
|
|
1673
|
+
code: "ledger_kept",
|
|
1674
|
+
name: "LedgerKept",
|
|
1675
|
+
description:
|
|
1676
|
+
"A draft's outreach reservation was kept because the CRM's answer was unknown (a timeout, a 5xx, a 429 or a reply without the contract header). The weekly cap counts it, failing closed. Check in the CRM whether that draft was logged.",
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
event: "crm_proxy_warning",
|
|
1680
|
+
code: "policy_mismatch",
|
|
1681
|
+
name: "PolicyMismatch",
|
|
1682
|
+
description:
|
|
1683
|
+
"The CRM's own policy in /meta differs from the deployment's capabilities.crm (the log line's fields name which). Each side enforces its own, so the stricter wins; align the two.",
|
|
1684
|
+
},
|
|
1685
|
+
] as const;
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Alarms on the CRM proxy's log group for a company's own CRM: one per
|
|
1689
|
+
* {@link CRM_PROVIDER_ALARMS} entry, on the instance's alarm topic.
|
|
1690
|
+
*
|
|
1691
|
+
* Lambda creates `/aws/lambda/<function>` on the first invocation, and a
|
|
1692
|
+
* proxy that ran before (on the DynamoDB provider) already has one that
|
|
1693
|
+
* CloudFormation does not own, so the group is ensured by a custom resource
|
|
1694
|
+
* exactly as the runtime's live group is.
|
|
1695
|
+
*
|
|
1696
|
+
* Five minutes, except `policy_mismatch`: it is logged at every `/meta` check
|
|
1697
|
+
* for as long as the two policies differ, so an hour keeps it from
|
|
1698
|
+
* re-notifying after every quiet gap.
|
|
1699
|
+
*/
|
|
1700
|
+
export function addCrmProviderAlarms(
|
|
1701
|
+
stack: cdk.Stack,
|
|
1702
|
+
args: { instance: Instance; functionName: string; alarmAction: cloudwatch.IAlarmAction },
|
|
1703
|
+
): cloudwatch.Alarm[] {
|
|
1704
|
+
const logGroupName = `/aws/lambda/${args.functionName}`;
|
|
1705
|
+
const ensureLogGroup = ensureLogGroupExists(stack, {
|
|
1706
|
+
id: "CrmProxyLogGroup",
|
|
1707
|
+
sid: "CreateCrmProxyLogGroup",
|
|
1708
|
+
resourceType: "Custom::FoundationLambdaLogGroup",
|
|
1709
|
+
description: `${args.instance.displayName} creates the CRM proxy's log group when missing`,
|
|
1710
|
+
logGroupName,
|
|
1711
|
+
});
|
|
1712
|
+
const logGroup = logs.LogGroup.fromLogGroupName(stack, "CrmProxyLogs", logGroupName);
|
|
1713
|
+
return CRM_PROVIDER_ALARMS.map((entry) => {
|
|
1714
|
+
const filter = new logs.MetricFilter(stack, `CrmProxy${entry.name}MetricFilter`, {
|
|
1715
|
+
logGroup,
|
|
1716
|
+
filterPattern: logs.FilterPattern.all(
|
|
1717
|
+
logs.FilterPattern.stringValue("$.event", "=", entry.event),
|
|
1718
|
+
logs.FilterPattern.stringValue("$.code", "=", entry.code),
|
|
1719
|
+
),
|
|
1720
|
+
metricNamespace: `Foundation/${args.instance.naming.prefix}`,
|
|
1721
|
+
metricName: `CrmProxy${entry.name}`,
|
|
1722
|
+
metricValue: "1",
|
|
1723
|
+
});
|
|
1724
|
+
filter.node.addDependency(ensureLogGroup);
|
|
1725
|
+
const alarm = new cloudwatch.Alarm(stack, `CrmProxy${entry.name}Alarm`, {
|
|
1726
|
+
alarmDescription: `${args.instance.displayName} CRM proxy logged ${entry.code}. ${entry.description}`,
|
|
1727
|
+
metric: filter.metric({
|
|
1728
|
+
statistic: "Sum",
|
|
1729
|
+
period: cdk.Duration.minutes(entry.code === "policy_mismatch" ? 60 : 5),
|
|
1730
|
+
}),
|
|
1731
|
+
threshold: 1,
|
|
1732
|
+
evaluationPeriods: 1,
|
|
1733
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
1734
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
1735
|
+
});
|
|
1736
|
+
alarm.addAlarmAction(args.alarmAction);
|
|
1737
|
+
return alarm;
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* The partition holding the outreach cap ledger, the only items the CRM
|
|
1743
|
+
* proxy's role may touch with the `http` provider. Kept equal to
|
|
1744
|
+
* `outreachLedgerPartition` in the CRM capability's `proxy/ledger.ts` (a test
|
|
1745
|
+
* pins it); not imported, so the CDK app does not load the proxy's AWS SDK.
|
|
1746
|
+
*/
|
|
1747
|
+
export function crmOutreachLedgerPartition(tenantId: string): string {
|
|
1748
|
+
return `TENANT#${tenantId}#OUTREACH`;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* The release a synth deploys, for the CRM proxy's User-Agent:
|
|
1753
|
+
* `-c foundationVersion`, which the deploy tool passes, else the release
|
|
1754
|
+
* being deployed. Absent on a bare synth, which the proxy tolerates.
|
|
1755
|
+
*/
|
|
1756
|
+
function releaseVersionContext(scope: Construct): string | undefined {
|
|
1757
|
+
const explicit = String(scope.node.tryGetContext("foundationVersion") ?? "");
|
|
1758
|
+
const version = explicit !== "" ? explicit : releaseSource(scope)?.version;
|
|
1759
|
+
return version !== undefined && FOUNDATION_VERSION_RE.test(version) ? version : undefined;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1515
1762
|
interface ProvisionedCrmConfig {
|
|
1516
1763
|
channels: string[];
|
|
1517
|
-
statuses:
|
|
1764
|
+
statuses: CrmStatusesByType;
|
|
1518
1765
|
activityTypes: string[];
|
|
1519
1766
|
maxBatchSize: number;
|
|
1767
|
+
statusPolicy: CrmStatusPolicy;
|
|
1768
|
+
deletes: boolean;
|
|
1520
1769
|
policyFingerprint: string;
|
|
1521
1770
|
/** Absent when no status is eligible for outreach. */
|
|
1522
1771
|
outreach?: CrmOutreachConfig;
|
|
@@ -1526,10 +1775,12 @@ interface ProvisionedCrmConfig {
|
|
|
1526
1775
|
function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
|
|
1527
1776
|
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
1528
1777
|
const crm = config.capabilities.crm;
|
|
1778
|
+
// Each record type needs a status for the records an upsert creates.
|
|
1529
1779
|
const missing = [
|
|
1530
1780
|
...(crm.enabled ? [] : ["capabilities.crm.enabled"]),
|
|
1531
1781
|
...(crm.channels.length > 0 ? [] : ["capabilities.crm.channels"]),
|
|
1532
|
-
...(crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"]),
|
|
1782
|
+
...(crm.statuses.company.length > 0 ? [] : ["capabilities.crm.statuses.company"]),
|
|
1783
|
+
...(crm.statuses.contact.length > 0 ? [] : ["capabilities.crm.statuses.contact"]),
|
|
1533
1784
|
...(crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]),
|
|
1534
1785
|
];
|
|
1535
1786
|
if (missing.length > 0)
|
|
@@ -1538,9 +1789,11 @@ function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
|
|
|
1538
1789
|
);
|
|
1539
1790
|
return {
|
|
1540
1791
|
channels: [...crm.channels],
|
|
1541
|
-
statuses: [...crm.statuses],
|
|
1792
|
+
statuses: { company: [...crm.statuses.company], contact: [...crm.statuses.contact] },
|
|
1542
1793
|
activityTypes: [...crm.activityTypes],
|
|
1543
1794
|
maxBatchSize: crm.maxBatchSize,
|
|
1795
|
+
statusPolicy: crm.statusPolicy,
|
|
1796
|
+
deletes: crm.deletes,
|
|
1544
1797
|
policyFingerprint: crmPolicyFingerprint(crm),
|
|
1545
1798
|
...(crmOutreachConfigured(crm.outreach) ? { outreach: crm.outreach } : {}),
|
|
1546
1799
|
timezone: config.timezone,
|
|
@@ -1672,7 +1925,7 @@ export function assertLambdaEnvironmentBudget(values: Readonly<Record<string, st
|
|
|
1672
1925
|
}
|
|
1673
1926
|
if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
|
|
1674
1927
|
throw new Error(
|
|
1675
|
-
`
|
|
1928
|
+
`A Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
|
|
1676
1929
|
);
|
|
1677
1930
|
}
|
|
1678
1931
|
}
|