@deployfoundation/foundation-deploy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/agent-image/Dockerfile +254 -0
- package/agent-image/bin/aws +36 -0
- package/agent-image/bin/gh +193 -0
- package/agent-image/bin/git-credential-sky +89 -0
- package/agent-image/security-overlay.yml +176 -0
- package/cdk.json +6 -0
- package/dist/bin/app.js +112 -0
- package/dist/bin/foundation-deploy.js +1906 -0
- package/dist/bin/release-account.js +154 -0
- package/dist/chunk-4aye5cee.js +2416 -0
- package/dist/chunk-9ddxyvq2.js +1455 -0
- package/dist/chunk-v7tz8g50.js +428 -0
- package/dist/src/index.js +88 -0
- package/package.json +38 -0
- package/pipeline/buildspec.yml +34 -0
- package/src/artifacts.ts +318 -0
- package/src/deploy/assets/github-app-manifest.yml +29 -0
- package/src/deploy/assets/slack-app-manifest.yml +95 -0
- package/src/deploy/aws.ts +265 -0
- package/src/deploy/cli.ts +212 -0
- package/src/deploy/config-sync.ts +93 -0
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy.ts +566 -0
- package/src/deploy/endpoint.ts +242 -0
- package/src/deploy/github-app-create.ts +154 -0
- package/src/deploy/github-app-manifest.ts +53 -0
- package/src/deploy/image.ts +80 -0
- package/src/deploy/instance.ts +87 -0
- package/src/deploy/license-cache.ts +47 -0
- package/src/deploy/license.ts +272 -0
- package/src/deploy/paths.ts +65 -0
- package/src/deploy/post-deploy.ts +97 -0
- package/src/deploy/release.ts +282 -0
- package/src/deploy/runtime-secret.ts +241 -0
- package/src/deploy/setup.ts +393 -0
- package/src/deploy/sh.ts +74 -0
- package/src/deploy/slack-manifest.ts +112 -0
- package/src/deploy/stage-customization.ts +224 -0
- package/src/deploy/tracing.ts +243 -0
- package/src/deploy-permissions.ts +165 -0
- package/src/index.ts +60 -0
- package/src/lambda-bundle-context.ts +64 -0
- package/src/names.ts +170 -0
- package/src/release/kms.ts +86 -0
- package/src/release/manifest.ts +265 -0
- package/src/stacks/agent-stack.ts +938 -0
- package/src/stacks/api-stack.ts +1005 -0
- package/src/stacks/ci-stack.ts +96 -0
- package/src/stacks/data-stack.ts +446 -0
- package/src/stacks/network-stack.ts +282 -0
- package/src/stacks/newsletter-stack.ts +572 -0
- package/src/stacks/pipeline-stack.ts +242 -0
- package/src/stacks/release-account-stack.ts +229 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
import * as cdk from "aws-cdk-lib";
|
|
2
|
+
import type * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import type * as ec2 from "aws-cdk-lib/aws-ec2";
|
|
4
|
+
import * as ecr from "aws-cdk-lib/aws-ecr";
|
|
5
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import type * as kms from "aws-cdk-lib/aws-kms";
|
|
7
|
+
import type * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
|
+
import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
|
9
|
+
import type { Construct } from "constructs";
|
|
10
|
+
import { agentImage, agentImageTagRequired, releaseImageRepositoryArn } from "../artifacts.ts";
|
|
11
|
+
import {
|
|
12
|
+
type Instance,
|
|
13
|
+
type InstanceNames,
|
|
14
|
+
WEB_SEARCH_TARGET,
|
|
15
|
+
namesFor,
|
|
16
|
+
provisionsIntegration,
|
|
17
|
+
requiresAuthenticatedQueue,
|
|
18
|
+
} from "../names.ts";
|
|
19
|
+
|
|
20
|
+
export interface FoundationAgentProps extends cdk.StackProps {
|
|
21
|
+
/** The deployment this stack belongs to; every name below comes from it. */
|
|
22
|
+
instance: Instance;
|
|
23
|
+
vpc: ec2.IVpc;
|
|
24
|
+
egressSubnets: ec2.SubnetSelection;
|
|
25
|
+
agentSecurityGroup: ec2.ISecurityGroup;
|
|
26
|
+
/** SG on the S3 Files mount targets (NFS from `agentSecurityGroup`). */
|
|
27
|
+
mountTargetSecurityGroup: ec2.ISecurityGroup;
|
|
28
|
+
/** `FileSystemId` of the FoundationData S3 Files file system. */
|
|
29
|
+
fileSystemId: string;
|
|
30
|
+
/** ARN of its access point — mounted at `/mnt/sky`. */
|
|
31
|
+
dataKey: kms.IKey;
|
|
32
|
+
bucket: s3.IBucket;
|
|
33
|
+
/** Dedicated bucket for durable per-channel documents. */
|
|
34
|
+
documentBucket: s3.IBucket;
|
|
35
|
+
table: dynamodb.ITable;
|
|
36
|
+
/** The `pk`+`sk` table holding todos and routines. */
|
|
37
|
+
itemsTable: dynamodb.ITable;
|
|
38
|
+
/** Deployment-owned CRM table; never directly readable by the shell-capable agent. */
|
|
39
|
+
crmTable?: dynamodb.ITable;
|
|
40
|
+
/** Proxy-only proposal drafts; explicitly denied to every agent-assumable role. */
|
|
41
|
+
upworkApprovalTable?: dynamodb.ITable;
|
|
42
|
+
secrets: {
|
|
43
|
+
runtime: secretsmanager.ISecret;
|
|
44
|
+
codex: secretsmanager.ISecret;
|
|
45
|
+
googleAiStudio: secretsmanager.ISecret;
|
|
46
|
+
googleDrive: secretsmanager.ISecret;
|
|
47
|
+
googleCalendar: secretsmanager.ISecret;
|
|
48
|
+
googleOauth: secretsmanager.ISecret;
|
|
49
|
+
githubApp: secretsmanager.ISecret;
|
|
50
|
+
/** Read-only Atlas connection string; a placeholder until someone fills it. */
|
|
51
|
+
mongodbReadonly: secretsmanager.ISecret;
|
|
52
|
+
/** Read-only Otter Enterprise Public API key, provisioned only for opted-in instances. */
|
|
53
|
+
otterApi?: secretsmanager.ISecret;
|
|
54
|
+
/** Knock public client and credential; the shell-capable role is denied both. */
|
|
55
|
+
knockOauthClient?: secretsmanager.ISecret;
|
|
56
|
+
knockCredential?: secretsmanager.ISecret;
|
|
57
|
+
/** Combined Upwork OAuth client and token secret, explicitly denied to the agent role. */
|
|
58
|
+
upwork?: secretsmanager.ISecret;
|
|
59
|
+
/** The connected mailbox. The agent role is explicitly DENIED read on it. */
|
|
60
|
+
googleEmail: secretsmanager.ISecret;
|
|
61
|
+
slackApp: secretsmanager.ISecret;
|
|
62
|
+
signing: secretsmanager.ISecret;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* FoundationAgent — the ECR repository holding the agent image and the AgentCore
|
|
68
|
+
* Runtime that runs it.
|
|
69
|
+
*
|
|
70
|
+
* There is no L2 construct for AgentCore, so the runtime is declared as the
|
|
71
|
+
* `AWS::BedrockAgentCore::Runtime` L1 resource.
|
|
72
|
+
*
|
|
73
|
+
* Two-phase bootstrap: the runtime references an image in the repo this same
|
|
74
|
+
* stack creates, and AgentCore validates the container URI at *create* time,
|
|
75
|
+
* so the first deploy must run with `-c deployRuntime=false` (repo + role
|
|
76
|
+
* only), push the image, then re-deploy with the flag on (the default).
|
|
77
|
+
* During phase one `agentRuntimeArn` is a syntactically valid `…-pending`
|
|
78
|
+
* placeholder so FoundationApi still synthesizes.
|
|
79
|
+
*
|
|
80
|
+
* AgentCore VPC mode supports only some AZs (us-east-1: use1-az1/az2/az4).
|
|
81
|
+
* CDK cannot check that at synth time, so `-c agentSubnetIds=subnet-a,subnet-b`
|
|
82
|
+
* is the escape hatch when the VPC's default egress subnets land in an
|
|
83
|
+
* unsupported zone.
|
|
84
|
+
*/
|
|
85
|
+
export class FoundationAgent extends cdk.Stack {
|
|
86
|
+
public readonly repository: ecr.Repository;
|
|
87
|
+
public readonly executionRole: iam.Role;
|
|
88
|
+
/** The S3 Files access point the runtime mounts at /mnt/sky. */
|
|
89
|
+
public readonly accessPoint: cdk.CfnResource;
|
|
90
|
+
private readonly accessPointArn: string;
|
|
91
|
+
/** Real `AgentRuntimeArn` when deployed, else the `-pending` placeholder. */
|
|
92
|
+
public readonly agentRuntimeArn: string;
|
|
93
|
+
/** MCP endpoint of the tool gateway; rides the runtime secret to the container. */
|
|
94
|
+
public readonly webSearchUrl: string;
|
|
95
|
+
/** The instance's OWN account expressed as a role `aws-readonly` assumes. */
|
|
96
|
+
public readonly readOnlyRole: iam.Role;
|
|
97
|
+
|
|
98
|
+
/** `namesFor(props.instance)`; the private helpers below read it. */
|
|
99
|
+
private readonly names: InstanceNames;
|
|
100
|
+
|
|
101
|
+
constructor(scope: Construct, id: string, props: FoundationAgentProps) {
|
|
102
|
+
super(scope, id, props);
|
|
103
|
+
const names = namesFor(props.instance);
|
|
104
|
+
this.names = names;
|
|
105
|
+
const display = props.instance.displayName;
|
|
106
|
+
|
|
107
|
+
const deployRuntime = String(this.node.tryGetContext("deployRuntime") ?? "true") !== "false";
|
|
108
|
+
const imageTag: string | undefined = this.node.tryGetContext("agentImageTag");
|
|
109
|
+
|
|
110
|
+
this.repository = new ecr.Repository(this, "AgentRepository", {
|
|
111
|
+
repositoryName: names.ecrRepo,
|
|
112
|
+
imageScanOnPush: true,
|
|
113
|
+
// Immutable tags: every build pushes a fresh tag (the short commit sha)
|
|
114
|
+
// and passes it as `-c agentImageTag=<tag>`.
|
|
115
|
+
imageTagMutability: ecr.TagMutability.IMMUTABLE,
|
|
116
|
+
encryption: ecr.RepositoryEncryption.KMS,
|
|
117
|
+
encryptionKey: props.dataKey,
|
|
118
|
+
// Only one tag is ever referenced; five keeps the live image plus four
|
|
119
|
+
// builds' worth of rollback.
|
|
120
|
+
lifecycleRules: [{ maxImageCount: 5 }],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The access point is runtime-specific (POSIX ids of the container user,
|
|
124
|
+
// root directory) and its ARN changes when those change — keeping it here
|
|
125
|
+
// avoids a cross-stack export that CloudFormation would refuse to update
|
|
126
|
+
// while the runtime imports it (2026-09-05).
|
|
127
|
+
const accessPoint = new cdk.CfnResource(this, "FoundationMountAccessPoint", {
|
|
128
|
+
type: "AWS::S3Files::AccessPoint",
|
|
129
|
+
properties: {
|
|
130
|
+
FileSystemId: props.fileSystemId,
|
|
131
|
+
PosixUser: { Uid: "10001", Gid: "10001" },
|
|
132
|
+
// A dedicated root that S3 Files CREATES with these POSIX ids: the file
|
|
133
|
+
// system root itself is root-owned (0755), so a 10001 client could
|
|
134
|
+
// mount but not write (probe 2026-09-05: EACCES on /mnt/sky).
|
|
135
|
+
RootDirectory: {
|
|
136
|
+
Path: "/sky",
|
|
137
|
+
CreationPermissions: { OwnerUid: "10001", OwnerGid: "10001", Permissions: "0755" },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
accessPoint.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
|
|
142
|
+
this.accessPointArn = accessPoint.getAtt("AccessPointArn").toString();
|
|
143
|
+
new cdk.CfnOutput(this, "FoundationMountAccessPointArn", { value: this.accessPointArn });
|
|
144
|
+
this.accessPoint = accessPoint;
|
|
145
|
+
this.executionRole = this.buildExecutionRole(props);
|
|
146
|
+
this.readOnlyRole = this.buildReadOnlyRole(props);
|
|
147
|
+
|
|
148
|
+
// The gateway is independent of the runtime: it can (and does) exist
|
|
149
|
+
// during the phase-one bootstrap deploy.
|
|
150
|
+
const gateway = this.buildToolGateway(display);
|
|
151
|
+
this.webSearchUrl = gateway.getAtt("GatewayUrl").toString();
|
|
152
|
+
this.executionRole.addToPolicy(
|
|
153
|
+
new iam.PolicyStatement({
|
|
154
|
+
sid: "InvokeToolGateway",
|
|
155
|
+
actions: ["bedrock-agentcore:InvokeGateway"],
|
|
156
|
+
resources: [gateway.getAtt("GatewayArn").toString()],
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (!deployRuntime) {
|
|
161
|
+
// Phase one. FoundationApi needs a syntactically valid ARN to put in the
|
|
162
|
+
// gateway's environment; nothing can invoke this one.
|
|
163
|
+
this.agentRuntimeArn = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}-pending`;
|
|
164
|
+
this.outputs();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A release names the image by digest, so there is no tag to pass and
|
|
169
|
+
// nothing local to have built first.
|
|
170
|
+
if (!imageTag && agentImageTagRequired(this)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${id}: -c agentImageTag=<tag> is required when deploying the runtime. Pass the tag of an image already pushed to the ${names.ecrRepo} repository, deploy a release with -c release=<version>, or run the bootstrap phase with -c deployRuntime=false.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const explicitSubnets = String(this.node.tryGetContext("agentSubnetIds") ?? "")
|
|
177
|
+
.split(",")
|
|
178
|
+
.map((s) => s.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
const runtimeSubnets =
|
|
181
|
+
explicitSubnets.length > 0
|
|
182
|
+
? explicitSubnets
|
|
183
|
+
: props.vpc.selectSubnets(props.egressSubnets).subnetIds;
|
|
184
|
+
|
|
185
|
+
// One mount target per egress subnet: the runtime schedules session ENIs
|
|
186
|
+
// across both AZs and a mount only resolves in its own AZ.
|
|
187
|
+
const mountTargets = runtimeSubnets.map(
|
|
188
|
+
(subnetId, index) =>
|
|
189
|
+
new cdk.CfnResource(this, `MountTarget${index}`, {
|
|
190
|
+
type: "AWS::S3Files::MountTarget",
|
|
191
|
+
properties: {
|
|
192
|
+
FileSystemId: props.fileSystemId,
|
|
193
|
+
SubnetId: subnetId,
|
|
194
|
+
SecurityGroups: [props.mountTargetSecurityGroup.securityGroupId],
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const runtime = new cdk.CfnResource(this, "AgentRuntime", {
|
|
200
|
+
type: "AWS::BedrockAgentCore::Runtime",
|
|
201
|
+
properties: {
|
|
202
|
+
AgentRuntimeName: names.runtimeName,
|
|
203
|
+
Description: `${display} agent runtime (Codex + Slack, single workspace)`,
|
|
204
|
+
AgentRuntimeArtifact: {
|
|
205
|
+
ContainerConfiguration: {
|
|
206
|
+
ContainerUri: agentImage(this, this.repository, imageTag),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
NetworkConfiguration: {
|
|
210
|
+
NetworkMode: "VPC",
|
|
211
|
+
NetworkModeConfig: {
|
|
212
|
+
Subnets: runtimeSubnets,
|
|
213
|
+
SecurityGroups: [props.agentSecurityGroup.securityGroupId],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
// BYO mounts require VPC mode (above) and are attached at
|
|
217
|
+
// *invocation* time, not container init — anything reading /mnt/sky
|
|
218
|
+
// must do so lazily, per request.
|
|
219
|
+
FilesystemConfigurations: [
|
|
220
|
+
{
|
|
221
|
+
S3FilesAccessPoint: {
|
|
222
|
+
AccessPointArn: this.accessPointArn,
|
|
223
|
+
MountPath: "/mnt/sky",
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
LifecycleConfiguration: {
|
|
228
|
+
IdleRuntimeSessionTimeout: 3600,
|
|
229
|
+
MaxLifetime: 28800,
|
|
230
|
+
},
|
|
231
|
+
RoleArn: this.executionRole.roleArn,
|
|
232
|
+
EnvironmentVariables: {
|
|
233
|
+
// The whole env map lives in this one secret, read at boot by
|
|
234
|
+
// ts/agent/src/server.ts — so changing it needs no redeploy.
|
|
235
|
+
RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
|
|
236
|
+
AWS_REGION: this.region,
|
|
237
|
+
FOUNDATION_WORKSPACE_ROOT: "/workspace",
|
|
238
|
+
},
|
|
239
|
+
// The App-level tag aspect cannot reach a raw CfnResource.
|
|
240
|
+
Tags: { project: props.instance.naming.secretPrefix },
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
runtime.node.addDependency(this.executionRole);
|
|
244
|
+
runtime.node.addDependency(accessPoint);
|
|
245
|
+
// The mount must exist in both AZs before a session can attach it.
|
|
246
|
+
for (const mountTarget of mountTargets) runtime.node.addDependency(mountTarget);
|
|
247
|
+
|
|
248
|
+
this.agentRuntimeArn = runtime.getAtt("AgentRuntimeArn").toString();
|
|
249
|
+
this.outputs();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private outputs(): void {
|
|
253
|
+
new cdk.CfnOutput(this, "AgentRuntimeArn", { value: this.agentRuntimeArn });
|
|
254
|
+
new cdk.CfnOutput(this, "RepositoryUri", { value: this.repository.repositoryUri });
|
|
255
|
+
new cdk.CfnOutput(this, "WebSearchGatewayUrl", { value: this.webSearchUrl });
|
|
256
|
+
new cdk.CfnOutput(this, "ReadOnlyRoleArn", { value: this.readOnlyRole.roleArn });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The AgentCore Gateway that fronts managed tool connectors, plus its one
|
|
261
|
+
* target: Amazon's `web-search` connector.
|
|
262
|
+
*
|
|
263
|
+
* Inbound is `AWS_IAM` — the container signs its MCP calls with the runtime
|
|
264
|
+
* execution role's own SigV4 credentials, so there is no OAuth issuer and no
|
|
265
|
+
* token to store. Outbound is `GATEWAY_IAM_ROLE`: the gateway calls the
|
|
266
|
+
* search tool as the service role built here, which can reach nothing else.
|
|
267
|
+
*
|
|
268
|
+
* There is no L2 for either resource, so both are raw L1s with the
|
|
269
|
+
* CloudFormation property names verbatim.
|
|
270
|
+
*/
|
|
271
|
+
private buildToolGateway(display: string): cdk.CfnResource {
|
|
272
|
+
const gatewayArnPattern = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:gateway/*`;
|
|
273
|
+
const role = new iam.Role(this, "ToolGatewayRole", {
|
|
274
|
+
description: "AgentCore tool gateway service role (web search only)",
|
|
275
|
+
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com", {
|
|
276
|
+
// Confused-deputy guard: only this account's gateways may assume it.
|
|
277
|
+
conditions: {
|
|
278
|
+
StringEquals: { "aws:SourceAccount": this.account },
|
|
279
|
+
ArnLike: { "aws:SourceArn": gatewayArnPattern },
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
role.addToPolicy(
|
|
284
|
+
new iam.PolicyStatement({
|
|
285
|
+
sid: "GatewayInvoke",
|
|
286
|
+
actions: ["bedrock-agentcore:InvokeGateway"],
|
|
287
|
+
resources: [gatewayArnPattern],
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
role.addToPolicy(
|
|
291
|
+
new iam.PolicyStatement({
|
|
292
|
+
sid: "WebSearchTool",
|
|
293
|
+
actions: ["bedrock-agentcore:InvokeWebSearch"],
|
|
294
|
+
// The managed tool is an AWS-owned resource, not one of ours. It is
|
|
295
|
+
// regional, so it follows the instance's region rather than naming one.
|
|
296
|
+
resources: [`arn:aws:bedrock-agentcore:${this.region}:aws:tool/web-search.v1`],
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const gateway = new cdk.CfnResource(this, "ToolGateway", {
|
|
301
|
+
type: "AWS::BedrockAgentCore::Gateway",
|
|
302
|
+
properties: {
|
|
303
|
+
Name: this.names.gatewayName,
|
|
304
|
+
Description: `${display} managed tool gateway (web search)`,
|
|
305
|
+
ProtocolType: "MCP",
|
|
306
|
+
AuthorizerType: "AWS_IAM",
|
|
307
|
+
RoleArn: role.roleArn,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
gateway.node.addDependency(role);
|
|
311
|
+
|
|
312
|
+
const target = new cdk.CfnResource(this, "WebSearchTarget", {
|
|
313
|
+
type: "AWS::BedrockAgentCore::GatewayTarget",
|
|
314
|
+
properties: {
|
|
315
|
+
GatewayIdentifier: gateway.getAtt("GatewayIdentifier"),
|
|
316
|
+
Name: WEB_SEARCH_TARGET,
|
|
317
|
+
TargetConfiguration: {
|
|
318
|
+
Mcp: {
|
|
319
|
+
Connector: {
|
|
320
|
+
// The schema for ConnectorSource carries only ConnectorId, so
|
|
321
|
+
// the connector's current default version is what runs.
|
|
322
|
+
Source: { ConnectorId: "web-search" },
|
|
323
|
+
Configurations: [{ Name: "WebSearch", ParameterValues: {} }],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
CredentialProviderConfigurations: [{ CredentialProviderType: "GATEWAY_IAM_ROLE" }],
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
target.node.addDependency(gateway);
|
|
331
|
+
|
|
332
|
+
return gateway;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private denyDirectCrmTableAccess(
|
|
336
|
+
role: iam.Role,
|
|
337
|
+
instance: Instance,
|
|
338
|
+
crmTable: dynamodb.ITable | undefined,
|
|
339
|
+
): void {
|
|
340
|
+
if (!instance.integrations.crmStorage) return;
|
|
341
|
+
// CRM tables are RETAINed. Match every historical prefix so a supported
|
|
342
|
+
// prefix migration cannot make an older retained table readable.
|
|
343
|
+
const retainedTableArn = cdk.Stack.of(this).formatArn({
|
|
344
|
+
service: "dynamodb",
|
|
345
|
+
resource: "table",
|
|
346
|
+
resourceName: "*-CrmTable*",
|
|
347
|
+
});
|
|
348
|
+
const resources = [
|
|
349
|
+
retainedTableArn,
|
|
350
|
+
`${retainedTableArn}/index/*`,
|
|
351
|
+
...(crmTable === undefined ? [] : [crmTable.tableArn, `${crmTable.tableArn}/index/*`]),
|
|
352
|
+
];
|
|
353
|
+
role.addToPolicy(
|
|
354
|
+
new iam.PolicyStatement({
|
|
355
|
+
sid: "DenyDirectCrmTableAccess",
|
|
356
|
+
effect: iam.Effect.DENY,
|
|
357
|
+
actions: [
|
|
358
|
+
"dynamodb:GetItem",
|
|
359
|
+
"dynamodb:BatchGetItem",
|
|
360
|
+
"dynamodb:TransactGetItems",
|
|
361
|
+
"dynamodb:Query",
|
|
362
|
+
"dynamodb:Scan",
|
|
363
|
+
"dynamodb:PartiQLSelect",
|
|
364
|
+
"dynamodb:ExecuteStatement",
|
|
365
|
+
"dynamodb:BatchExecuteStatement",
|
|
366
|
+
"dynamodb:ExportTableToPointInTime",
|
|
367
|
+
],
|
|
368
|
+
resources,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Deny remains attached to both execution and assumed read-only roles. */
|
|
374
|
+
private denyDirectKnockSecretAccess(role: iam.Role, props: FoundationAgentProps): void {
|
|
375
|
+
if (!provisionsIntegration(props.instance, "knock")) return;
|
|
376
|
+
const { knockOauthClient, knockCredential } = props.secrets;
|
|
377
|
+
if (knockOauthClient === undefined || knockCredential === undefined)
|
|
378
|
+
throw new Error(
|
|
379
|
+
`${this.stackName}: integrations.knock is enabled but FoundationData supplied no Knock secrets`,
|
|
380
|
+
);
|
|
381
|
+
role.addToPolicy(
|
|
382
|
+
new iam.PolicyStatement({
|
|
383
|
+
sid: "DenyKnockSecrets",
|
|
384
|
+
effect: iam.Effect.DENY,
|
|
385
|
+
actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
|
|
386
|
+
resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)],
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private denyDirectUpworkApprovalAccess(
|
|
392
|
+
role: iam.Role,
|
|
393
|
+
upworkApprovalTable: dynamodb.ITable | undefined,
|
|
394
|
+
): void {
|
|
395
|
+
if (upworkApprovalTable === undefined) return;
|
|
396
|
+
role.addToPolicy(
|
|
397
|
+
new iam.PolicyStatement({
|
|
398
|
+
sid: "DenyDirectUpworkApprovalAccess",
|
|
399
|
+
effect: iam.Effect.DENY,
|
|
400
|
+
// Cover reads today and any future managed-policy or inline write grant.
|
|
401
|
+
actions: ["dynamodb:*"],
|
|
402
|
+
resources: [upworkApprovalTable.tableArn, `${upworkApprovalTable.tableArn}/index/*`],
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* `<Prefix>ReadOnlyRole` — how the agent reads its OWN account.
|
|
409
|
+
*
|
|
410
|
+
* The instance's account is not a special case: `aws-readonly` reaches
|
|
411
|
+
* every account through an assumed role, and this is the one for this one.
|
|
412
|
+
* That keeps the execution role itself narrow (it can write exactly the
|
|
413
|
+
* handful of resources it owns, and reads nothing else), and it makes every
|
|
414
|
+
* read the agent performs land in CloudTrail under a session named for the
|
|
415
|
+
* instance and the label rather than under the runtime's own identity.
|
|
416
|
+
*
|
|
417
|
+
* `ReadOnlyAccess` is AWS's own managed read-only policy. The inline
|
|
418
|
+
* additions are the two things it does NOT cover which the observability
|
|
419
|
+
* tools need: starting a Logs Insights query, and reading trace summaries.
|
|
420
|
+
*/
|
|
421
|
+
private buildReadOnlyRole(props: FoundationAgentProps): iam.Role {
|
|
422
|
+
const role = new iam.Role(this, "AgentReadOnlyRole", {
|
|
423
|
+
roleName: `${props.instance.naming.prefix}ReadOnlyRole`,
|
|
424
|
+
description: `${props.instance.displayName} read-only role assumed by the agent (aws-readonly)`,
|
|
425
|
+
// Only this instance's own execution role may assume it. Nothing else
|
|
426
|
+
// in the account can, and it grants no write anywhere.
|
|
427
|
+
assumedBy: new iam.ArnPrincipal(this.executionRole.roleArn),
|
|
428
|
+
maxSessionDuration: cdk.Duration.hours(1),
|
|
429
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")],
|
|
430
|
+
});
|
|
431
|
+
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
432
|
+
this.denyDirectKnockSecretAccess(role, props);
|
|
433
|
+
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
434
|
+
role.addToPolicy(
|
|
435
|
+
new iam.PolicyStatement({
|
|
436
|
+
sid: "DenyAgentCoreBrowserMetadata",
|
|
437
|
+
effect: iam.Effect.DENY,
|
|
438
|
+
actions: ["bedrock-agentcore:*Browser*"],
|
|
439
|
+
resources: ["*"],
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
role.addToPolicy(
|
|
443
|
+
new iam.PolicyStatement({
|
|
444
|
+
sid: "LogsInsights",
|
|
445
|
+
// StartQuery is a WRITE action in IAM's taxonomy, so ReadOnlyAccess
|
|
446
|
+
// omits it — without this the log tools can only ever list groups.
|
|
447
|
+
actions: [
|
|
448
|
+
"logs:StartQuery",
|
|
449
|
+
"logs:StopQuery",
|
|
450
|
+
"logs:GetQueryResults",
|
|
451
|
+
"logs:FilterLogEvents",
|
|
452
|
+
"logs:DescribeLogGroups",
|
|
453
|
+
"logs:DescribeLogStreams",
|
|
454
|
+
],
|
|
455
|
+
resources: ["*"],
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
role.addToPolicy(
|
|
459
|
+
new iam.PolicyStatement({
|
|
460
|
+
sid: "TraceRead",
|
|
461
|
+
actions: ["xray:GetTraceSummaries", "xray:BatchGetTraces"],
|
|
462
|
+
resources: ["*"],
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
this.executionRole.addToPolicy(
|
|
466
|
+
new iam.PolicyStatement({
|
|
467
|
+
sid: "AssumeReadOnlyRole",
|
|
468
|
+
actions: ["sts:AssumeRole"],
|
|
469
|
+
resources: [role.roleArn],
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
return role;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private buildExecutionRole(props: FoundationAgentProps): iam.Role {
|
|
476
|
+
const role = new iam.Role(this, "AgentExecutionRole", {
|
|
477
|
+
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
|
|
478
|
+
description: `${props.instance.displayName} AgentCore execution role (least privilege)`,
|
|
479
|
+
});
|
|
480
|
+
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
481
|
+
this.denyDirectKnockSecretAccess(role, props);
|
|
482
|
+
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
483
|
+
|
|
484
|
+
// Foundation's own repository when a release names the image, because the
|
|
485
|
+
// runtime then pulls across accounts. The pull also needs Foundation's
|
|
486
|
+
// repository POLICY to allow this account (`release-account-stack.ts`);
|
|
487
|
+
// this side is the half the instance owns.
|
|
488
|
+
const releaseRepositoryArn = releaseImageRepositoryArn(this);
|
|
489
|
+
role.addToPolicy(
|
|
490
|
+
new iam.PolicyStatement({
|
|
491
|
+
sid: "EcrPull",
|
|
492
|
+
actions: [
|
|
493
|
+
"ecr:BatchGetImage",
|
|
494
|
+
"ecr:GetDownloadUrlForLayer",
|
|
495
|
+
"ecr:BatchCheckLayerAvailability",
|
|
496
|
+
],
|
|
497
|
+
resources: [
|
|
498
|
+
this.repository.repositoryArn,
|
|
499
|
+
...(releaseRepositoryArn === undefined ? [] : [releaseRepositoryArn]),
|
|
500
|
+
],
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
role.addToPolicy(
|
|
504
|
+
new iam.PolicyStatement({
|
|
505
|
+
sid: "EcrAuth",
|
|
506
|
+
// GetAuthorizationToken has no resource-level scoping.
|
|
507
|
+
actions: ["ecr:GetAuthorizationToken"],
|
|
508
|
+
resources: ["*"],
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
role.addToPolicy(
|
|
512
|
+
new iam.PolicyStatement({
|
|
513
|
+
sid: "AgentLogs",
|
|
514
|
+
actions: [
|
|
515
|
+
"logs:CreateLogGroup",
|
|
516
|
+
"logs:CreateLogStream",
|
|
517
|
+
"logs:PutLogEvents",
|
|
518
|
+
"logs:DescribeLogStreams",
|
|
519
|
+
],
|
|
520
|
+
resources: [
|
|
521
|
+
`arn:${this.partition}:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/${this.names.runtimeName}-*`,
|
|
522
|
+
],
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
// Spans: the runtime SigV4-posts OTLP to the X-Ray endpoint, which routes
|
|
526
|
+
// them into the log group `AgentLogs` above already covers. None of these
|
|
527
|
+
// actions take a resource ARN — X-Ray scopes ingestion by account.
|
|
528
|
+
role.addToPolicy(
|
|
529
|
+
new iam.PolicyStatement({
|
|
530
|
+
sid: "TraceIngest",
|
|
531
|
+
actions: [
|
|
532
|
+
"xray:PutTraceSegments",
|
|
533
|
+
"xray:PutTelemetryRecords",
|
|
534
|
+
"xray:PutSpans",
|
|
535
|
+
"xray:PutSpansForIndexing",
|
|
536
|
+
],
|
|
537
|
+
resources: ["*"],
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
role.addToPolicy(
|
|
541
|
+
new iam.PolicyStatement({
|
|
542
|
+
sid: "AgentSecrets",
|
|
543
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
544
|
+
resources: [
|
|
545
|
+
secretArnPattern(props.secrets.runtime),
|
|
546
|
+
secretArnPattern(props.secrets.codex),
|
|
547
|
+
secretArnPattern(props.secrets.googleAiStudio),
|
|
548
|
+
secretArnPattern(props.secrets.googleDrive),
|
|
549
|
+
secretArnPattern(props.secrets.googleCalendar),
|
|
550
|
+
secretArnPattern(props.secrets.googleOauth),
|
|
551
|
+
secretArnPattern(props.secrets.githubApp),
|
|
552
|
+
secretArnPattern(props.secrets.slackApp),
|
|
553
|
+
secretArnPattern(props.secrets.mongodbReadonly),
|
|
554
|
+
],
|
|
555
|
+
}),
|
|
556
|
+
);
|
|
557
|
+
role.addToPolicy(
|
|
558
|
+
new iam.PolicyStatement({
|
|
559
|
+
sid: "CodexCredentialWriteback",
|
|
560
|
+
// SharedAuthCredentialStore writes the rotated Codex token back.
|
|
561
|
+
actions: ["secretsmanager:PutSecretValue"],
|
|
562
|
+
resources: [secretArnPattern(props.secrets.codex)],
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
// The signing secret proves inbound Slack authenticity. If the agent
|
|
566
|
+
// could read it, a prompt injection could forge Slack events past
|
|
567
|
+
// verification. Removing the grant is what closes that today; an explicit
|
|
568
|
+
// Deny beats any future Allow, so it stays closed.
|
|
569
|
+
role.addToPolicy(
|
|
570
|
+
new iam.PolicyStatement({
|
|
571
|
+
sid: "DenySlackSigningSecret",
|
|
572
|
+
effect: iam.Effect.DENY,
|
|
573
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
574
|
+
resources: [secretArnPattern(props.secrets.signing)],
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
// Email is the sharpest version of the same argument as the signing
|
|
578
|
+
// secret, and the reason the proxy is a separate Lambda. The scope Google
|
|
579
|
+
// grants the mailbox (`gmail.compose`) permits SENDING, so a container
|
|
580
|
+
// that could read this secret could send mail as the company — and the
|
|
581
|
+
// model's shell can reach the execution role's credentials. Denying the
|
|
582
|
+
// read is what makes "the teammate cannot send" true rather than merely intended.
|
|
583
|
+
role.addToPolicy(
|
|
584
|
+
new iam.PolicyStatement({
|
|
585
|
+
sid: "DenyEmailSecret",
|
|
586
|
+
effect: iam.Effect.DENY,
|
|
587
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
588
|
+
resources: [secretArnPattern(props.secrets.googleEmail)],
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
// The whole of the agent's email permission: invoke the proxy. Not the
|
|
592
|
+
// Gmail API, not the secret, not the identity list.
|
|
593
|
+
// Built from the NAME rather than from FoundationApi's function object: FoundationApi
|
|
594
|
+
// already depends on this stack for the runtime ARN, so referring to its
|
|
595
|
+
// output here would close a cycle. The function name is derived from the
|
|
596
|
+
// instance file, so both stacks compute the same string.
|
|
597
|
+
role.addToPolicy(
|
|
598
|
+
new iam.PolicyStatement({
|
|
599
|
+
sid: "EmailProxyInvoke",
|
|
600
|
+
actions: ["lambda:InvokeFunction"],
|
|
601
|
+
resources: [
|
|
602
|
+
cdk.Stack.of(this).formatArn({
|
|
603
|
+
service: "lambda",
|
|
604
|
+
resource: "function",
|
|
605
|
+
resourceName: namesFor(props.instance).emailProxyFunctionName,
|
|
606
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
607
|
+
}),
|
|
608
|
+
],
|
|
609
|
+
}),
|
|
610
|
+
);
|
|
611
|
+
// Defense in depth: the model shell shares this role. A future broad grant must not let it
|
|
612
|
+
// bypass the proxy's domain, operation, or user/profile checks through the Browser APIs.
|
|
613
|
+
role.addToPolicy(
|
|
614
|
+
new iam.PolicyStatement({
|
|
615
|
+
sid: "DenyDirectAgentCoreBrowser",
|
|
616
|
+
effect: iam.Effect.DENY,
|
|
617
|
+
actions: ["bedrock-agentcore:*Browser*"],
|
|
618
|
+
resources: ["*"],
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
// AgentCore Browser follows the same isolation boundary: the agent can invoke this one
|
|
622
|
+
// policy-enforcing function, but it cannot start a browser session or connect to CDP itself.
|
|
623
|
+
// The deny above is unconditional; this ALLOW exists only where the instance file opted the
|
|
624
|
+
// browser capability's infrastructure in, so an instance without it names no function.
|
|
625
|
+
if (provisionsIntegration(props.instance, "browser")) {
|
|
626
|
+
role.addToPolicy(
|
|
627
|
+
new iam.PolicyStatement({
|
|
628
|
+
sid: "BrowserProxyInvoke",
|
|
629
|
+
actions: ["lambda:InvokeFunction"],
|
|
630
|
+
resources: [
|
|
631
|
+
cdk.Stack.of(this).formatArn({
|
|
632
|
+
service: "lambda",
|
|
633
|
+
resource: "function",
|
|
634
|
+
resourceName: this.names.browserProxyFunctionName,
|
|
635
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
636
|
+
}),
|
|
637
|
+
],
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
if (provisionsIntegration(props.instance, "otter")) {
|
|
642
|
+
const otterApi = props.secrets.otterApi;
|
|
643
|
+
if (otterApi === undefined)
|
|
644
|
+
throw new Error(
|
|
645
|
+
`${this.stackName}: integrations.otter is enabled but FoundationData supplied no Otter secret`,
|
|
646
|
+
);
|
|
647
|
+
// Defense in depth: even a future broad Secrets Manager Allow cannot
|
|
648
|
+
// expose the API key to the shell-capable agent execution role.
|
|
649
|
+
role.addToPolicy(
|
|
650
|
+
new iam.PolicyStatement({
|
|
651
|
+
sid: "DenyOtterSecret",
|
|
652
|
+
effect: iam.Effect.DENY,
|
|
653
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
654
|
+
resources: [secretArnPattern(otterApi)],
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
// The entire Otter permission surface: invoke the fixed read-only
|
|
658
|
+
// proxy. The name is derived locally to avoid a FoundationAgent ↔ FoundationApi cycle.
|
|
659
|
+
role.addToPolicy(
|
|
660
|
+
new iam.PolicyStatement({
|
|
661
|
+
sid: "OtterProxyInvoke",
|
|
662
|
+
actions: ["lambda:InvokeFunction"],
|
|
663
|
+
resources: [
|
|
664
|
+
cdk.Stack.of(this).formatArn({
|
|
665
|
+
service: "lambda",
|
|
666
|
+
resource: "function",
|
|
667
|
+
resourceName: this.names.otterProxyFunctionName,
|
|
668
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
669
|
+
}),
|
|
670
|
+
],
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (provisionsIntegration(props.instance, "knock")) {
|
|
675
|
+
// Knock's entire agent-facing surface is one proxy invocation. Neither
|
|
676
|
+
// the public client nor the connected OAuth credential reaches the shell.
|
|
677
|
+
role.addToPolicy(
|
|
678
|
+
new iam.PolicyStatement({
|
|
679
|
+
sid: "KnockProxyInvoke",
|
|
680
|
+
actions: ["lambda:InvokeFunction"],
|
|
681
|
+
resources: [
|
|
682
|
+
cdk.Stack.of(this).formatArn({
|
|
683
|
+
service: "lambda",
|
|
684
|
+
resource: "function",
|
|
685
|
+
resourceName: this.names.knockProxyFunctionName,
|
|
686
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
687
|
+
}),
|
|
688
|
+
],
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (provisionsIntegration(props.instance, "upwork")) {
|
|
693
|
+
const upwork = props.secrets.upwork;
|
|
694
|
+
if (upwork === undefined)
|
|
695
|
+
throw new Error(
|
|
696
|
+
`${this.stackName}: integrations.upwork is enabled but FoundationData supplied no Upwork secret`,
|
|
697
|
+
);
|
|
698
|
+
// The full Upwork credential (including a refresh token) is never
|
|
699
|
+
// readable in the shell-capable runtime. The one Lambda invocation below
|
|
700
|
+
// is the entire agent-facing Upwork authority.
|
|
701
|
+
role.addToPolicy(
|
|
702
|
+
new iam.PolicyStatement({
|
|
703
|
+
sid: "DenyUpworkSecret",
|
|
704
|
+
effect: iam.Effect.DENY,
|
|
705
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
706
|
+
resources: [secretArnPattern(upwork)],
|
|
707
|
+
}),
|
|
708
|
+
);
|
|
709
|
+
role.addToPolicy(
|
|
710
|
+
new iam.PolicyStatement({
|
|
711
|
+
sid: "UpworkProxyInvoke",
|
|
712
|
+
actions: ["lambda:InvokeFunction"],
|
|
713
|
+
resources: [
|
|
714
|
+
cdk.Stack.of(this).formatArn({
|
|
715
|
+
service: "lambda",
|
|
716
|
+
resource: "function",
|
|
717
|
+
resourceName: this.names.upworkProxyFunctionName,
|
|
718
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
719
|
+
}),
|
|
720
|
+
],
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (provisionsIntegration(props.instance, "crm")) {
|
|
725
|
+
// The CRM permission surface is one named proxy Lambda. Derive the ARN
|
|
726
|
+
// locally so FoundationAgent never depends on FoundationApi's output and closes a
|
|
727
|
+
// stack cycle through the runtime ARN.
|
|
728
|
+
role.addToPolicy(
|
|
729
|
+
new iam.PolicyStatement({
|
|
730
|
+
sid: "CrmProxyInvoke",
|
|
731
|
+
actions: ["lambda:InvokeFunction"],
|
|
732
|
+
resources: [
|
|
733
|
+
cdk.Stack.of(this).formatArn({
|
|
734
|
+
service: "lambda",
|
|
735
|
+
resource: "function",
|
|
736
|
+
resourceName: this.names.crmProxyFunctionName,
|
|
737
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
738
|
+
}),
|
|
739
|
+
],
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
// Config and curated skills are deployment-owned: direct S3 API writes are
|
|
744
|
+
// limited to memory/*. The separate fs/* persistent mount remains writable
|
|
745
|
+
// through S3 Files for workspace state and learned skills.
|
|
746
|
+
role.addToPolicy(
|
|
747
|
+
new iam.PolicyStatement({
|
|
748
|
+
sid: "ConfigAndSkills",
|
|
749
|
+
actions: ["s3:GetObject"],
|
|
750
|
+
resources: [props.bucket.arnForObjects("*")],
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
role.addToPolicy(
|
|
754
|
+
new iam.PolicyStatement({
|
|
755
|
+
sid: "MemoryWrite",
|
|
756
|
+
actions: ["s3:PutObject"],
|
|
757
|
+
resources: [props.bucket.arnForObjects("memory/*")],
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
role.addToPolicy(
|
|
761
|
+
new iam.PolicyStatement({
|
|
762
|
+
sid: "ConfigList",
|
|
763
|
+
actions: ["s3:ListBucket"],
|
|
764
|
+
resources: [props.bucket.bucketArn],
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
role.addToPolicy(
|
|
768
|
+
new iam.PolicyStatement({
|
|
769
|
+
sid: "DocumentsObjects",
|
|
770
|
+
actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
771
|
+
resources: [props.documentBucket.arnForObjects("*")],
|
|
772
|
+
}),
|
|
773
|
+
);
|
|
774
|
+
role.addToPolicy(
|
|
775
|
+
new iam.PolicyStatement({
|
|
776
|
+
sid: "DocumentsList",
|
|
777
|
+
actions: ["s3:ListBucket"],
|
|
778
|
+
resources: [props.documentBucket.bucketArn],
|
|
779
|
+
}),
|
|
780
|
+
);
|
|
781
|
+
role.addToPolicy(
|
|
782
|
+
new iam.PolicyStatement({
|
|
783
|
+
sid: "PersistentMount",
|
|
784
|
+
actions: ["s3files:ClientMount", "s3files:ClientWrite"],
|
|
785
|
+
resources: [
|
|
786
|
+
`arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`,
|
|
787
|
+
],
|
|
788
|
+
// The file system carries exactly one access point today; the
|
|
789
|
+
// condition keeps that true from the role's side, so a second one
|
|
790
|
+
// added later is not silently mountable by the agent.
|
|
791
|
+
conditions: { ArnEquals: { "s3files:AccessPointArn": this.accessPointArn } },
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
794
|
+
// AgentCore validates the access point at runtime create/update time and
|
|
795
|
+
// checks GetAccessPoint against the ACCESS POINT ARN itself (2026-09-05:
|
|
796
|
+
// "Execution role is missing required permissions. Ensure the role has
|
|
797
|
+
// s3files:GetAccessPoint" when it was granted only on the file system).
|
|
798
|
+
role.addToPolicy(
|
|
799
|
+
new iam.PolicyStatement({
|
|
800
|
+
sid: "PersistentMountDescribe",
|
|
801
|
+
// Read-only describe set: the validator also asks for ListMountTargets
|
|
802
|
+
// (and may add more); none of these grant data access.
|
|
803
|
+
actions: ["s3files:Get*", "s3files:List*", "s3files:Describe*"],
|
|
804
|
+
resources: [
|
|
805
|
+
this.accessPointArn,
|
|
806
|
+
`arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`,
|
|
807
|
+
],
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
role.addToPolicy(
|
|
811
|
+
new iam.PolicyStatement({
|
|
812
|
+
sid: "StateTable",
|
|
813
|
+
// DeleteItem releases the Codex credential lease.
|
|
814
|
+
actions: [
|
|
815
|
+
"dynamodb:GetItem",
|
|
816
|
+
"dynamodb:PutItem",
|
|
817
|
+
"dynamodb:UpdateItem",
|
|
818
|
+
"dynamodb:DeleteItem",
|
|
819
|
+
"dynamodb:Query",
|
|
820
|
+
],
|
|
821
|
+
resources: [props.table.tableArn],
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
role.addToPolicy(
|
|
825
|
+
new iam.PolicyStatement({
|
|
826
|
+
sid: "ItemsTable",
|
|
827
|
+
actions: [
|
|
828
|
+
"dynamodb:GetItem",
|
|
829
|
+
"dynamodb:PutItem",
|
|
830
|
+
"dynamodb:UpdateItem",
|
|
831
|
+
"dynamodb:DeleteItem",
|
|
832
|
+
"dynamodb:Query",
|
|
833
|
+
],
|
|
834
|
+
resources: [props.itemsTable.tableArn],
|
|
835
|
+
}),
|
|
836
|
+
);
|
|
837
|
+
role.addToPolicy(
|
|
838
|
+
new iam.PolicyStatement({
|
|
839
|
+
sid: "DataKeyUsage",
|
|
840
|
+
actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
841
|
+
resources: [props.dataKey.keyArn],
|
|
842
|
+
}),
|
|
843
|
+
);
|
|
844
|
+
role.addToPolicy(
|
|
845
|
+
new iam.PolicyStatement({
|
|
846
|
+
sid: "RoutineSchedules",
|
|
847
|
+
actions: [
|
|
848
|
+
"scheduler:CreateSchedule",
|
|
849
|
+
"scheduler:UpdateSchedule",
|
|
850
|
+
"scheduler:DeleteSchedule",
|
|
851
|
+
"scheduler:GetSchedule",
|
|
852
|
+
],
|
|
853
|
+
// Only inside the instance's own group: the agent cannot touch a schedule it
|
|
854
|
+
// did not create, and cannot create the group either.
|
|
855
|
+
resources: [
|
|
856
|
+
`arn:${this.partition}:scheduler:${this.region}:${this.account}:schedule/${this.names.routineGroup}/*`,
|
|
857
|
+
],
|
|
858
|
+
}),
|
|
859
|
+
);
|
|
860
|
+
role.addToPolicy(
|
|
861
|
+
new iam.PolicyStatement({
|
|
862
|
+
sid: "PassRoutineSchedulerRole",
|
|
863
|
+
actions: ["iam:PassRole"],
|
|
864
|
+
resources: [
|
|
865
|
+
`arn:${this.partition}:iam::${this.account}:role/${this.names.routineSchedulerRole}`,
|
|
866
|
+
],
|
|
867
|
+
// Passing it anywhere but Scheduler would be privilege escalation.
|
|
868
|
+
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
|
|
869
|
+
}),
|
|
870
|
+
);
|
|
871
|
+
if (requiresAuthenticatedQueue(props.instance)) {
|
|
872
|
+
role.addToPolicy(
|
|
873
|
+
new iam.PolicyStatement({
|
|
874
|
+
sid: "RoutineIngressInvoke",
|
|
875
|
+
actions: ["lambda:InvokeFunction"],
|
|
876
|
+
resources: [
|
|
877
|
+
cdk.Stack.of(this).formatArn({
|
|
878
|
+
service: "lambda",
|
|
879
|
+
resource: "function",
|
|
880
|
+
resourceName: this.names.slackGatewayFunctionName,
|
|
881
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
882
|
+
}),
|
|
883
|
+
],
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
} else {
|
|
887
|
+
role.addToPolicy(
|
|
888
|
+
new iam.PolicyStatement({
|
|
889
|
+
sid: "InvokeQueueSend",
|
|
890
|
+
// Legacy instances still let a fired `turn` routine re-enqueue.
|
|
891
|
+
actions: ["sqs:SendMessage"],
|
|
892
|
+
resources: [
|
|
893
|
+
`arn:${this.partition}:sqs:${this.region}:${this.account}:${this.names.api}-InvokeQueue*`,
|
|
894
|
+
],
|
|
895
|
+
}),
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
role.addToPolicy(
|
|
899
|
+
new iam.PolicyStatement({
|
|
900
|
+
sid: "SessionEni",
|
|
901
|
+
// VPC-mode session ENIs; these actions do not support scoping.
|
|
902
|
+
actions: [
|
|
903
|
+
"ec2:CreateNetworkInterface",
|
|
904
|
+
"ec2:DescribeNetworkInterfaces",
|
|
905
|
+
"ec2:DeleteNetworkInterface",
|
|
906
|
+
"ec2:DescribeSubnets",
|
|
907
|
+
"ec2:DescribeSecurityGroups",
|
|
908
|
+
"ec2:DescribeVpcs",
|
|
909
|
+
],
|
|
910
|
+
resources: ["*"],
|
|
911
|
+
}),
|
|
912
|
+
);
|
|
913
|
+
role.addToPolicy(
|
|
914
|
+
new iam.PolicyStatement({
|
|
915
|
+
sid: "WorkloadIdentity",
|
|
916
|
+
actions: [
|
|
917
|
+
"bedrock-agentcore:GetWorkloadAccessToken",
|
|
918
|
+
"bedrock-agentcore:GetWorkloadAccessTokenForJWT",
|
|
919
|
+
"bedrock-agentcore:GetWorkloadAccessTokenForUserId",
|
|
920
|
+
],
|
|
921
|
+
resources: [
|
|
922
|
+
`arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default*`,
|
|
923
|
+
],
|
|
924
|
+
}),
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
return role;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Secrets imported by name carry a partial ARN (no service-generated
|
|
933
|
+
* 6-character suffix), which IAM will not match — append the wildcard the
|
|
934
|
+
* same way CDK's own `grantRead` does.
|
|
935
|
+
*/
|
|
936
|
+
function secretArnPattern(secret: secretsmanager.ISecret): string {
|
|
937
|
+
return secret.secretFullArn ?? `${secret.secretArn}-??????`;
|
|
938
|
+
}
|