@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.
@@ -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(
@@ -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 { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
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: `${display} CRM proxy Lambda role (self-hosted CRM table access only)`,
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
- crmProxyRole.addToPolicy(
1155
- new iam.PolicyStatement({
1156
- sid: "CrmTableData",
1157
- actions: [
1158
- "dynamodb:GetItem",
1159
- "dynamodb:PutItem",
1160
- "dynamodb:UpdateItem",
1161
- "dynamodb:DeleteItem",
1162
- "dynamodb:BatchGetItem",
1163
- "dynamodb:BatchWriteItem",
1164
- "dynamodb:TransactWriteItems",
1165
- // A transaction's ConditionCheck is its own IAM action; the
1166
- // repository guards every upsert and activity with one.
1167
- "dynamodb:ConditionCheckItem",
1168
- ],
1169
- resources: [crmTable.tableArn],
1170
- }),
1171
- );
1172
- crmProxyRole.addToPolicy(
1173
- new iam.PolicyStatement({
1174
- sid: "CrmRecordsIndex",
1175
- actions: ["dynamodb:Query"],
1176
- resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
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
- crmProxyRole.addToPolicy(
1183
- new iam.PolicyStatement({
1184
- sid: "CrmTableKey",
1185
- actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
1186
- resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
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 role = new iam.Role(stack, "AgentLiveLogGroupRole", {
1455
- assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1456
- description: `${args.instance.displayName} creates the runtime's live log group when missing`,
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
- properties: { LogGroupName: logGroupName },
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: string[];
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
- `Gateway Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
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
  }