@deployfoundation/foundation-deploy 0.2.50 → 0.2.52

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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * The operator's side of a company's own CRM (`crm.provider` kind `http`,
3
+ * docs/crm-provider-contract.md):
4
+ *
5
+ * foundation-deploy crm-key --instance <path> --stdin
6
+ * foundation-deploy crm-check --instance <path> [--allow-skips]
7
+ * foundation-deploy crm-check --base-url <url> --tenant <id> --key-stdin [--write] [--allow-skips]
8
+ * foundation-deploy crm-replay --instance <path> --from <writes.jsonl> [--dry-run]
9
+ *
10
+ * - `crm-key` writes the `crm/1` key into the secret FoundationData created
11
+ * empty. The key arrives on a pipe and goes to the AWS CLI through a 0600
12
+ * temp file: never argv, never shell history, never printed.
13
+ * - `crm-check` runs the `crm/1` conformance suite. Against the instance it
14
+ * uses the deployment's base URL, tenant and stored key and runs only the
15
+ * read cases, since the write cases create records and spend the week's
16
+ * outreach allowance. With `--base-url` (no instance at all) it is what a
17
+ * CRM's own CI runs against a sandbox tenant, `--write` included. Strict
18
+ * unless `--allow-skips`: a case only a person can set up counts as failed
19
+ * when it could not run.
20
+ * - `crm-replay` is the rollback half of a cutover: once `crm.provider` is
21
+ * back on `dynamodb`, it writes the agent's writes made in the company's CRM
22
+ * since the cutover (exported as JSONL) into the instance's own table,
23
+ * through the same DynamoDB store the proxy uses, under the deployment's
24
+ * policy. Drafts move each contact's touch clock but are not counted again:
25
+ * the shared ledger counted them when they happened.
26
+ *
27
+ * A CRM reply body never reaches this output: the suite reports fixed text,
28
+ * codes and statuses only, and a refused replay line is reported by its line
29
+ * number and refusal code.
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import type {
33
+ CrmConformanceReport,
34
+ CrmConformanceTarget,
35
+ } from "@deployfoundation/foundation-capability-crm/contract/conformance";
36
+ import {
37
+ CrmIdempotencyConflictError,
38
+ CrmRecordNotFoundError,
39
+ } from "@deployfoundation/foundation-capability-crm/proxy/errors";
40
+ import {
41
+ type CrmOutreachPolicy,
42
+ CrmPolicyRefusedError,
43
+ } from "@deployfoundation/foundation-capability-crm/proxy/policy";
44
+ import type { CrmDocumentClient } from "@deployfoundation/foundation-capability-crm/proxy/repository";
45
+ import {
46
+ CRM_MAX_ID_CHARS,
47
+ CrmInputError,
48
+ type CrmLogActivityArgs,
49
+ type CrmRecordType,
50
+ type CrmUpdateStatusArgs,
51
+ type CrmUpsertRecordsArgs,
52
+ parseCrmLogActivityArgs,
53
+ parseCrmUpdateStatusArgs,
54
+ parseCrmUpsertRecordsArgs,
55
+ } from "@deployfoundation/foundation-connectors/crm";
56
+ import { crmHttpBaseUrl, isCrmHttpKey } from "@deployfoundation/foundation-connectors/crm-http";
57
+ import {
58
+ type CrmCapabilityConfig,
59
+ type InstanceConfig,
60
+ crmOutreachConfigured,
61
+ parseInstanceConfig,
62
+ } from "@deployfoundation/foundation-core";
63
+ import { type AwsContext, aws, putSecretString, readSecretString, secretExists } from "./aws.ts";
64
+
65
+ /** What these commands reach outside the process; tests replace every one. */
66
+ export interface CrmCommandDeps {
67
+ /** Everything on stdin, or a refusal when stdin is a terminal (it would echo). */
68
+ readStdin: () => Promise<string>;
69
+ secretExists: (ctx: AwsContext, secretId: string) => Promise<boolean>;
70
+ putSecretString: (ctx: AwsContext, secretId: string, value: string) => Promise<void>;
71
+ readSecretString: (ctx: AwsContext, secretId: string) => Promise<string>;
72
+ runConformance: (target: CrmConformanceTarget) => Promise<CrmConformanceReport>;
73
+ /** The physical name of the instance's CRM table. */
74
+ crmTableName: (ctx: AwsContext) => Promise<string>;
75
+ /** A DocumentClient on the operator's own credentials. */
76
+ documentClient: (ctx: AwsContext) => Promise<CrmDocumentClient>;
77
+ readFile: (path: string) => string;
78
+ log: (line: string) => void;
79
+ }
80
+
81
+ /** The deployment's `http` provider, or a refusal naming what is missing. */
82
+ function httpProvider(ctx: AwsContext, command: string) {
83
+ const { instance } = ctx;
84
+ const provider = instance.crm.provider;
85
+ if (!instance.integrations.crm || provider.kind !== "http")
86
+ throw new Error(
87
+ `${command} needs crm.provider kind http (and integrations.crm) in ${ctx.paths.path}; the DynamoDB provider has no CRM key or base URL`,
88
+ );
89
+ return provider;
90
+ }
91
+
92
+ // --- crm-key ---------------------------------------------------------------
93
+
94
+ export interface CrmKeyResult {
95
+ secret: string;
96
+ }
97
+
98
+ /**
99
+ * Write the `crm/1` key from stdin into `<secretPrefix>/crm/http`. The secret
100
+ * must exist (FoundationData creates it on the first deploy with the `http`
101
+ * provider), so a typo in the instance cannot create a stray secret.
102
+ */
103
+ export async function crmKey(
104
+ ctx: AwsContext,
105
+ options: { stdin: boolean },
106
+ deps: CrmCommandDeps,
107
+ ): Promise<CrmKeyResult> {
108
+ httpProvider(ctx, "crm-key");
109
+ const secret = ctx.names.secretCrmHttp;
110
+ if (!options.stdin)
111
+ throw new Error(
112
+ "crm-key reads the key from stdin only, so it never lands in argv or shell history: pipe it in with --stdin, e.g. `pbpaste | foundation-deploy crm-key --instance <path> --stdin`",
113
+ );
114
+ const key = (await deps.readStdin()).trim();
115
+ if (!isCrmHttpKey(key))
116
+ throw new Error(
117
+ "stdin did not hold a usable crm/1 key: 16 to 1024 visible ASCII characters, no spaces, one per run",
118
+ );
119
+ if (!(await deps.secretExists(ctx, secret)))
120
+ throw new Error(
121
+ `secret ${secret} does not exist yet: deploy with crm.provider kind http first, which creates it empty`,
122
+ );
123
+ await deps.putSecretString(ctx, secret, JSON.stringify({ api_key: key }));
124
+ deps.log(
125
+ `wrote the crm/1 key to ${secret}. The CRM proxy reads it within 5 minutes, or at once after a 401. Check it with: foundation-deploy crm-check --instance ${ctx.paths.path}`,
126
+ );
127
+ return { secret };
128
+ }
129
+
130
+ // --- crm-check -------------------------------------------------------------
131
+
132
+ export interface CrmCheckOptions {
133
+ allowSkips: boolean;
134
+ }
135
+
136
+ /**
137
+ * The suite's read cases against the deployment's CRM, with its stored key,
138
+ * tenant and baked policy. Returns the process exit code.
139
+ */
140
+ export async function crmCheck(
141
+ ctx: AwsContext,
142
+ options: CrmCheckOptions,
143
+ deps: CrmCommandDeps,
144
+ ): Promise<number> {
145
+ const provider = httpProvider(ctx, "crm-check");
146
+ const secret = ctx.names.secretCrmHttp;
147
+ let apiKey: unknown;
148
+ try {
149
+ const raw = JSON.parse(await deps.readSecretString(ctx, secret)) as unknown;
150
+ if (typeof raw === "object" && raw !== null && "api_key" in raw) apiKey = raw.api_key;
151
+ } catch {
152
+ // Unreadable or not JSON: the same as no key, reported below.
153
+ }
154
+ if (!isCrmHttpKey(apiKey))
155
+ throw new Error(`${secret} holds no crm/1 key yet: run foundation-deploy crm-key first`);
156
+ const crm = crmCapability(instanceConfig(ctx, deps));
157
+ const report = await deps.runConformance({
158
+ baseUrl: provider.baseUrl,
159
+ apiKey,
160
+ tenantId: provider.expectedTenant,
161
+ strict: !options.allowSkips,
162
+ ...(crm === undefined
163
+ ? {}
164
+ : {
165
+ statusPolicy: crm.statusPolicy,
166
+ ...(crmOutreachConfigured(crm.outreach)
167
+ ? { suppressedStatuses: crm.outreach.suppressedStatuses }
168
+ : {}),
169
+ }),
170
+ });
171
+ return reportConformance(report, provider.baseUrl, deps.log);
172
+ }
173
+
174
+ export interface CrmCheckTargetOptions extends CrmCheckOptions {
175
+ baseUrl: string;
176
+ tenantId: string;
177
+ write: boolean;
178
+ }
179
+
180
+ /**
181
+ * The suite against any `crm/1` server, no instance involved: what a CRM's
182
+ * own CI runs from the published tool. The key comes from stdin.
183
+ */
184
+ export async function crmCheckTarget(
185
+ options: CrmCheckTargetOptions,
186
+ deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "log">,
187
+ ): Promise<number> {
188
+ let baseUrl: string;
189
+ try {
190
+ baseUrl = crmHttpBaseUrl(options.baseUrl);
191
+ } catch {
192
+ // The connector's own message says only that it must be HTTPS.
193
+ throw new Error("--base-url must be an HTTPS URL with no userinfo, query or fragment");
194
+ }
195
+ if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
196
+ throw new Error("--tenant must be the tenant id the server's /meta names");
197
+ const apiKey = (await deps.readStdin()).trim();
198
+ if (!isCrmHttpKey(apiKey)) throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
199
+ const report = await deps.runConformance({
200
+ baseUrl,
201
+ apiKey,
202
+ tenantId: options.tenantId,
203
+ write: options.write,
204
+ strict: !options.allowSkips,
205
+ });
206
+ return reportConformance(report, baseUrl, deps.log);
207
+ }
208
+
209
+ function reportConformance(
210
+ report: CrmConformanceReport,
211
+ baseUrl: string,
212
+ log: (line: string) => void,
213
+ ): number {
214
+ log(`crm/1 conformance against ${baseUrl}`);
215
+ for (const entry of report.cases) {
216
+ const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
217
+ log(
218
+ ` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`,
219
+ );
220
+ }
221
+ log(`${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
222
+ return report.failed > 0 ? 1 : 0;
223
+ }
224
+
225
+ function instanceConfig(ctx: AwsContext, deps: Pick<CrmCommandDeps, "readFile">): InstanceConfig {
226
+ return parseInstanceConfig(deps.readFile(ctx.paths.configPath));
227
+ }
228
+
229
+ /** The instance's `capabilities.crm`, when it is on. */
230
+ function crmCapability(config: InstanceConfig): CrmCapabilityConfig | undefined {
231
+ const crm = config.capabilities.crm;
232
+ return crm.enabled ? crm : undefined;
233
+ }
234
+
235
+ // --- crm-replay ------------------------------------------------------------
236
+
237
+ /**
238
+ * One write in a replay file, one JSON object per line:
239
+ *
240
+ * {"op":"upsert_records","idempotencyKey":"…","companies":[…],"contacts":[…]}
241
+ * {"op":"update_status","idempotencyKey":"…","recordType":"contact","externalId":"…","status":"…"}
242
+ * {"op":"log_activity","idempotencyKey":"…","recordType":"contact","externalId":"…",
243
+ * "activityType":"…","summary":"…","occurredAt":"…","attributes":{…}}
244
+ *
245
+ * Records are named by `externalId`, which both providers share; the
246
+ * DynamoDB store derives its own record id from it.
247
+ */
248
+ export type CrmReplayWrite =
249
+ | { line: number; op: "upsert_records"; args: CrmUpsertRecordsArgs }
250
+ | { line: number; op: "update_status"; args: CrmUpdateStatusArgs }
251
+ | { line: number; op: "log_activity"; args: CrmLogActivityArgs };
252
+
253
+ export interface CrmReplayResult {
254
+ applied: number;
255
+ refused: { line: number; op: CrmReplayWrite["op"]; code: string }[];
256
+ }
257
+
258
+ const REPLAY_OPS = ["upsert_records", "update_status", "log_activity"] as const;
259
+
260
+ /**
261
+ * Parse and validate a whole replay file against the deployment's CRM
262
+ * policy. Nothing is written unless every line passes, so a file this cannot
263
+ * replay (a status the deployment does not have, an operation it does not
264
+ * take) is fixed before anything changes. Errors name the line and the rule,
265
+ * never its content.
266
+ */
267
+ export function parseCrmReplay(
268
+ text: string,
269
+ crm: CrmCapabilityConfig,
270
+ recordIdFor: (recordType: CrmRecordType, externalId: string) => string,
271
+ ): CrmReplayWrite[] {
272
+ const writes: CrmReplayWrite[] = [];
273
+ const statuses = {
274
+ company: new Set(crm.statuses.company),
275
+ contact: new Set(crm.statuses.contact),
276
+ };
277
+ const activityTypes = new Set(crm.activityTypes);
278
+ text.split("\n").forEach((raw, index) => {
279
+ const line = index + 1;
280
+ if (raw.trim() === "") return;
281
+ let value: unknown;
282
+ try {
283
+ value = JSON.parse(raw);
284
+ } catch {
285
+ // JSON.parse quotes the offending text; the line number is enough.
286
+ throw new Error(`line ${line} is not a JSON object`);
287
+ }
288
+ if (typeof value !== "object" || value === null || Array.isArray(value))
289
+ throw new Error(`line ${line} is not a JSON object`);
290
+ const { op, externalId, ...rest } = value as Record<string, unknown>;
291
+ if (typeof op !== "string" || !(REPLAY_OPS as readonly string[]).includes(op))
292
+ throw new Error(`line ${line}: op must be one of ${REPLAY_OPS.join(", ")}`);
293
+ try {
294
+ if (op === "upsert_records") {
295
+ if (externalId !== undefined) throw new Error("upsert_records names records inside");
296
+ writes.push({ line, op, args: parseCrmUpsertRecordsArgs(rest, crm.maxBatchSize) });
297
+ return;
298
+ }
299
+ if (
300
+ typeof externalId !== "string" ||
301
+ externalId.trim() === "" ||
302
+ externalId.length > CRM_MAX_ID_CHARS
303
+ )
304
+ throw new Error(`externalId must be 1 to ${CRM_MAX_ID_CHARS} characters`);
305
+ // An id is a provider's own; only the externalId means the same record in both.
306
+ if (Object.hasOwn(rest, "recordId"))
307
+ throw new Error("records are named by externalId, never by a provider's recordId");
308
+ const recordType = rest.recordType;
309
+ if (recordType !== "company" && recordType !== "contact")
310
+ throw new Error("recordType must be company or contact");
311
+ const named = { ...rest, recordId: recordIdFor(recordType, externalId) };
312
+ if (op === "update_status") {
313
+ const args = parseCrmUpdateStatusArgs(named);
314
+ if (!statuses[args.recordType].has(args.status))
315
+ throw new Error(`status is not a configured ${args.recordType} status`);
316
+ writes.push({ line, op, args });
317
+ return;
318
+ }
319
+ const args = parseCrmLogActivityArgs(named);
320
+ if (!activityTypes.has(args.activityType))
321
+ throw new Error("activityType is not a configured activity type");
322
+ writes.push({ line, op: "log_activity", args });
323
+ } catch (error) {
324
+ // Parser and rule messages name the rule, never the value.
325
+ throw new Error(`line ${line}: ${error instanceof Error ? error.message : String(error)}`);
326
+ }
327
+ });
328
+ return writes;
329
+ }
330
+
331
+ /**
332
+ * Replay a file of the agent's CRM writes into the instance's own table.
333
+ * Each line keeps its idempotency key, so running the same file twice writes
334
+ * nothing new. A write the policy refuses (a status change a later one made
335
+ * stale, a contact already drafted) is reported and the rest go on; any
336
+ * other failure stops at that line.
337
+ */
338
+ export async function crmReplay(
339
+ ctx: AwsContext,
340
+ options: { from: string },
341
+ deps: CrmCommandDeps,
342
+ ): Promise<CrmReplayResult> {
343
+ const { instance } = ctx;
344
+ if (!instance.integrations.crmStorage)
345
+ throw new Error(
346
+ `crm-replay needs the instance's CRM table (integrations.crm) in ${ctx.paths.path}`,
347
+ );
348
+ if (instance.crm.provider.kind !== "dynamodb")
349
+ throw new Error(
350
+ "crm-replay writes into the DynamoDB provider's table: set crm.provider back to kind dynamodb, deploy, then replay",
351
+ );
352
+ const config = instanceConfig(ctx, deps);
353
+ const crm = crmCapability(config);
354
+ if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
355
+ throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
356
+ // Loaded here, not at the top: it brings the AWS SDK's DynamoDB client,
357
+ // which no other command needs.
358
+ const { createDynamoCrmClient, deterministicRecordId } = await import(
359
+ "@deployfoundation/foundation-capability-crm/proxy/repository"
360
+ );
361
+ const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
362
+ const counts = REPLAY_OPS.map(
363
+ (op) => `${writes.filter((write) => write.op === op).length} ${op}`,
364
+ ).join(", ");
365
+ if (ctx.dryRun === true) {
366
+ deps.log(`dry run: ${writes.length} writes would be replayed (${counts})`);
367
+ return { applied: 0, refused: [] };
368
+ }
369
+ const outreach: CrmOutreachPolicy | undefined = crmOutreachConfigured(crm.outreach)
370
+ ? { ...crm.outreach, timezone: config.timezone }
371
+ : undefined;
372
+ const client = createDynamoCrmClient({
373
+ documentClient: await deps.documentClient(ctx),
374
+ tableName: await deps.crmTableName(ctx),
375
+ // The tenant the CRM proxy serves: the instance's name, as it bakes it.
376
+ tenantId: instance.name,
377
+ defaultStatuses: {
378
+ company: crm.statuses.company[0] as string,
379
+ contact: crm.statuses.contact[0] as string,
380
+ },
381
+ maxBatchSize: crm.maxBatchSize,
382
+ actor: {
383
+ teamId: instance.slack.teamId,
384
+ userId: "foundation-deploy",
385
+ channelId: "crm-replay",
386
+ sessionKey: `crm-replay:${instance.name}`,
387
+ },
388
+ statusPolicy: crm.statusPolicy,
389
+ ...(outreach === undefined ? {} : { outreach }),
390
+ countsOutreach: false,
391
+ });
392
+ deps.log(`replaying ${writes.length} writes (${counts})`);
393
+ const result: CrmReplayResult = { applied: 0, refused: [] };
394
+ for (const write of writes) {
395
+ try {
396
+ if (write.op === "upsert_records") await client.upsertRecords(write.args);
397
+ else if (write.op === "update_status") await client.updateStatus(write.args);
398
+ else await client.logActivity(write.args);
399
+ result.applied += 1;
400
+ } catch (error) {
401
+ const code = replayRefusal(error);
402
+ if (code === undefined)
403
+ throw new Error(
404
+ `line ${write.line} (${write.op}) failed: ${error instanceof Error ? error.name : "error"}; lines before it were replayed, and rerunning the file skips them`,
405
+ );
406
+ result.refused.push({ line: write.line, op: write.op, code });
407
+ }
408
+ }
409
+ deps.log(`${result.applied} applied, ${result.refused.length} refused`);
410
+ for (const refused of result.refused)
411
+ deps.log(` line ${refused.line} (${refused.op}): ${refused.code}`);
412
+ return result;
413
+ }
414
+
415
+ /** The fixed code for a write the store refused, or undefined for a failure. */
416
+ function replayRefusal(error: unknown): string | undefined {
417
+ if (error instanceof CrmPolicyRefusedError) return error.code;
418
+ if (error instanceof CrmRecordNotFoundError) return "not_found";
419
+ if (error instanceof CrmIdempotencyConflictError) return "conflict";
420
+ if (error instanceof CrmInputError) return "invalid_input";
421
+ return undefined;
422
+ }
423
+
424
+ // --- production dependencies ------------------------------------------------
425
+
426
+ /** Every byte on stdin; refuses a terminal, where the key would be echoed. */
427
+ async function readPipedStdin(): Promise<string> {
428
+ if (process.stdin.isTTY === true)
429
+ throw new Error(
430
+ "stdin is a terminal, where the key would be echoed: pipe it in instead, e.g. `pbpaste | foundation-deploy …`",
431
+ );
432
+ const chunks: Buffer[] = [];
433
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk as Uint8Array));
434
+ return Buffer.concat(chunks).toString("utf8");
435
+ }
436
+
437
+ /** The CRM table FoundationData created; its name is CloudFormation's. */
438
+ async function crmTableNameFromStack(ctx: AwsContext): Promise<string> {
439
+ const names = (
440
+ await aws(ctx, [
441
+ "cloudformation",
442
+ "describe-stack-resources",
443
+ "--stack-name",
444
+ ctx.names.data,
445
+ "--query",
446
+ "StackResources[?ResourceType=='AWS::DynamoDB::Table' && starts_with(LogicalResourceId, 'CrmTable')].PhysicalResourceId",
447
+ "--output",
448
+ "text",
449
+ ])
450
+ )
451
+ .split(/\s+/)
452
+ .filter((name) => name !== "" && name !== "None");
453
+ if (names.length !== 1)
454
+ throw new Error(
455
+ `expected one CRM table in ${ctx.names.data}, found ${names.length}: is integrations.crm deployed?`,
456
+ );
457
+ return names[0] as string;
458
+ }
459
+
460
+ async function operatorDocumentClient(ctx: AwsContext): Promise<CrmDocumentClient> {
461
+ const [{ DynamoDBClient }, { DynamoDBDocumentClient }] = await Promise.all([
462
+ import("@aws-sdk/client-dynamodb"),
463
+ import("@aws-sdk/lib-dynamodb"),
464
+ ]);
465
+ return DynamoDBDocumentClient.from(
466
+ new DynamoDBClient({
467
+ region: ctx.region,
468
+ ...(ctx.profile === "" ? {} : { profile: ctx.profile }),
469
+ }),
470
+ { marshallOptions: { removeUndefinedValues: true } },
471
+ );
472
+ }
473
+
474
+ export function defaultCrmCommandDeps(): CrmCommandDeps {
475
+ return {
476
+ readStdin: readPipedStdin,
477
+ secretExists,
478
+ putSecretString,
479
+ readSecretString,
480
+ runConformance: async (target) => {
481
+ const { runCrmConformance } = await import(
482
+ "@deployfoundation/foundation-capability-crm/contract/conformance"
483
+ );
484
+ return runCrmConformance(target);
485
+ },
486
+ crmTableName: crmTableNameFromStack,
487
+ documentClient: operatorDocumentClient,
488
+ readFile: (path) => readFileSync(path, "utf8"),
489
+ log: (line) => console.log(line),
490
+ };
491
+ }
@@ -378,26 +378,47 @@ export class FoundationAgent extends cdk.Stack {
378
378
  `${retainedTableArn}/index/*`,
379
379
  ...(crmTable === undefined ? [] : [crmTable.tableArn, `${crmTable.tableArn}/index/*`]),
380
380
  ];
381
+ // Every DynamoDB action, not only reads. The table also holds the
382
+ // proxy-owned outreach ledger (`TENANT#<name>#OUTREACH`, the weekly draft
383
+ // counters and each contact's touch clock). Until 2026-10 the deny named
384
+ // only read actions, so a future broad allow could have let the agent
385
+ // write a counter back to zero, delete a clock or restore a copy, and lift
386
+ // the weekly caps without going through the proxy.
381
387
  role.addToPolicy(
382
388
  new iam.PolicyStatement({
383
389
  sid: "DenyDirectCrmTableAccess",
384
390
  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
- ],
391
+ actions: ["dynamodb:*"],
396
392
  resources,
397
393
  }),
398
394
  );
399
395
  }
400
396
 
397
+ /**
398
+ * The key for a company's own CRM (`crm.provider` kind `http`) is the CRM
399
+ * proxy's alone. Both shell-reachable roles deny every action on it, by its
400
+ * fixed name, so no future broad grant can read it or overwrite it with a
401
+ * key of the agent's choosing.
402
+ */
403
+ private denyDirectCrmHttpSecretAccess(role: iam.Role, instance: Instance): void {
404
+ if (!provisionsIntegration(instance, "crm") || instance.crm.provider.kind !== "http") return;
405
+ role.addToPolicy(
406
+ new iam.PolicyStatement({
407
+ sid: "DenyCrmHttpSecret",
408
+ effect: iam.Effect.DENY,
409
+ actions: ["secretsmanager:*"],
410
+ resources: [
411
+ cdk.Stack.of(this).formatArn({
412
+ service: "secretsmanager",
413
+ resource: "secret",
414
+ resourceName: `${this.names.secretCrmHttp}-??????`,
415
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
416
+ }),
417
+ ],
418
+ }),
419
+ );
420
+ }
421
+
401
422
  /** Deny remains attached to both execution and assumed read-only roles. */
402
423
  private denyDirectKnockSecretAccess(role: iam.Role, props: FoundationAgentProps): void {
403
424
  if (!provisionsIntegration(props.instance, "knock")) return;
@@ -628,6 +649,7 @@ export class FoundationAgent extends cdk.Stack {
628
649
  managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")],
629
650
  });
630
651
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
652
+ this.denyDirectCrmHttpSecretAccess(role, props.instance);
631
653
  this.denyDirectKnockSecretAccess(role, props);
632
654
  this.denyDirectEmailSecretAccess(
633
655
  role,
@@ -735,6 +757,7 @@ export class FoundationAgent extends cdk.Stack {
735
757
  description: `${props.instance.displayName} AgentCore execution role (least privilege)`,
736
758
  });
737
759
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
760
+ this.denyDirectCrmHttpSecretAccess(role, props.instance);
738
761
  this.denyDirectKnockSecretAccess(role, props);
739
762
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
740
763
  this.denyDirectEmailSecretAccess(