Skip to content

Commit 95b8fd6

Browse files
krokokobgagentcursoragent
authored
refactor(cdk): collapse AgentSessionRole trust+grant into admitComputeRole (#213) (#430)
Replace split addAssumingRole/grantAssumeToComputeRole with a single method so SessionRole admission always wires both IAM halves together. Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 90f97de commit 95b8fd6

5 files changed

Lines changed: 71 additions & 48 deletions

File tree

cdk/src/constructs/agent-session-role.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ export class AgentSessionRole extends Construct {
116116
);
117117
}
118118

119-
const principals = props.assumingRoles.map(
120-
(r) => new iam.ArnPrincipal(r.roleArn),
121-
);
119+
const [firstAssumingRole] = props.assumingRoles;
122120

121+
// CDK requires assumedBy; additional principals are admitted via
122+
// admitComputeRole so trust + grant always wire together.
123123
this.role = new iam.Role(this, 'Role', {
124-
assumedBy: new iam.CompositePrincipal(...principals),
124+
assumedBy: new iam.ArnPrincipal(firstAssumingRole.roleArn),
125125
description:
126126
'Per-task scoped credentials for ABCA agent tenant-data access '
127127
+ '(DynamoDB task rows + S3 trace/attachment objects), constrained by '
@@ -132,16 +132,6 @@ export class AgentSessionRole extends Construct {
132132
maxSessionDuration: Duration.hours(1),
133133
});
134134

135-
// The agent passes session tags on AssumeRole, which requires the trust
136-
// policy to also allow sts:TagSession (CDK's assumedBy only adds
137-
// sts:AssumeRole). Same principals.
138-
this.role.assumeRolePolicy?.addStatements(
139-
new iam.PolicyStatement({
140-
actions: ['sts:TagSession'],
141-
principals,
142-
}),
143-
);
144-
145135
// --- DynamoDB: item access gated by task_id leading-key ---
146136
// One statement per table keeps the resource ARNs explicit. The condition
147137
// requires the request's partition key (task_id) to equal the session's
@@ -220,19 +210,28 @@ export class AgentSessionRole extends Construct {
220210
],
221211
true,
222212
);
213+
214+
for (const computeRole of props.assumingRoles) {
215+
this.admitComputeRole(computeRole);
216+
}
223217
}
224218

225219
/**
226-
* Admit an additional compute role to the trust policy (e.g. the ECS Fargate
227-
* task role when that backend is enabled). Adds `sts:AssumeRole` +
228-
* `sts:TagSession` for the role to this SessionRole's trust policy.
220+
* Admit a compute role to assume this SessionRole with session tags. Wires
221+
* both halves AssumeRole requires: trust on this SessionRole and
222+
* `sts:AssumeRole`/`sts:TagSession` on the compute role's identity policy.
229223
*/
230-
public addAssumingRole(computeRole: iam.IRole): void {
231-
const principal = new iam.ArnPrincipal(computeRole.roleArn);
224+
public admitComputeRole(computeRole: iam.IRole): void {
225+
this.addTrustForComputeRole(computeRole);
226+
this.grantAssumeToComputeRole(computeRole);
227+
}
228+
229+
/** Add bundled AssumeRole + TagSession trust for one compute principal. */
230+
private addTrustForComputeRole(computeRole: iam.IRole): void {
232231
this.role.assumeRolePolicy?.addStatements(
233232
new iam.PolicyStatement({
234233
actions: ['sts:AssumeRole', 'sts:TagSession'],
235-
principals: [principal],
234+
principals: [new iam.ArnPrincipal(computeRole.roleArn)],
236235
}),
237236
);
238237
}
@@ -243,7 +242,7 @@ export class AgentSessionRole extends Construct {
243242
* policy (a separate IAM::Policy resource, so no dependency cycle with this
244243
* role's trust policy).
245244
*/
246-
public grantAssumeToComputeRole(computeRole: iam.IRole): void {
245+
private grantAssumeToComputeRole(computeRole: iam.IRole): void {
247246
computeRole.addToPrincipalPolicy(
248247
new iam.PolicyStatement({
249248
actions: ['sts:AssumeRole', 'sts:TagSession'],

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,7 @@ export class EcsAgentCluster extends Construct {
147147
// that tag-scoped role and the task role only needs to assume it. Without
148148
// one (isolated construct tests / legacy), grant the task role directly.
149149
if (props.agentSessionRole) {
150-
props.agentSessionRole.addAssumingRole(taskRole);
151-
props.agentSessionRole.grantAssumeToComputeRole(taskRole);
150+
props.agentSessionRole.admitComputeRole(taskRole);
152151
} else {
153152
props.taskTable.grantReadWriteData(taskRole);
154153
props.taskEventsTable.grantReadWriteData(taskRole);

cdk/src/stacks/agent.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,6 @@ export class AgentStack extends Stack {
478478
traceArtifactsBucket: traceArtifactsBucket.bucket,
479479
attachmentsBucket: attachmentsBucket.bucket,
480480
});
481-
agentSessionRole.grantAssumeToComputeRole(runtime.role);
482481
sessionRoleArnHolder = agentSessionRole.role.roleArn;
483482

484483
// X-Ray tracing disabled — requires account-level UpdateTraceSegmentDestination

cdk/test/constructs/agent-session-role.test.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,20 @@ function createStack() {
5959
traceArtifactsBucket,
6060
attachmentsBucket,
6161
});
62-
sessionRole.grantAssumeToComputeRole(computeRole);
6362

6463
return { stack, template: Template.fromStack(stack), computeRole, sessionRole };
6564
}
6665

66+
function findComputeRolePolicy(template: Template) {
67+
const policies = template.findResources('AWS::IAM::Policy');
68+
return Object.entries(policies).find(([id]) => id.includes('ComputeRole'))![1];
69+
}
70+
71+
function findSessionRoleTrust(template: Template) {
72+
const roles = template.findResources('AWS::IAM::Role');
73+
return Object.entries(roles).find(([id]) => id.includes('AgentSessionRole'))![1];
74+
}
75+
6776
describe('AgentSessionRole construct', () => {
6877
let template: Template;
6978

@@ -75,8 +84,9 @@ describe('AgentSessionRole construct', () => {
7584
template.hasResourceProperties('AWS::IAM::Role', {
7685
AssumeRolePolicyDocument: {
7786
Statement: Match.arrayWith([
78-
Match.objectLike({ Action: 'sts:AssumeRole' }),
79-
Match.objectLike({ Action: 'sts:TagSession' }),
87+
Match.objectLike({
88+
Action: Match.arrayWith(['sts:AssumeRole', 'sts:TagSession']),
89+
}),
8090
]),
8191
},
8292
// Role chaining caps at 1h regardless; documented explicitly.
@@ -163,20 +173,27 @@ describe('AgentSessionRole construct', () => {
163173
);
164174
});
165175

166-
test('compute role is granted sts:AssumeRole + sts:TagSession on the SessionRole', () => {
167-
const policies = template.findResources('AWS::IAM::Policy');
168-
const computePolicy = Object.entries(policies).find(([id]) =>
169-
id.includes('ComputeRole'),
170-
)![1];
176+
test('constructor admits assumingRoles with both trust and grant wired', () => {
177+
const computePolicy = findComputeRolePolicy(template);
171178
const statements = computePolicy.Properties.PolicyDocument.Statement;
172179
const stsStatement = statements.find((s: { Action: string | string[] }) => {
173180
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
174181
return actions.includes('sts:AssumeRole') && actions.includes('sts:TagSession');
175182
});
176183
expect(stsStatement).toBeDefined();
184+
185+
const trustDoc = findSessionRoleTrust(template).Properties.AssumeRolePolicyDocument;
186+
const bundledTrust = trustDoc.Statement.find(
187+
(s: { Action: string | string[] }) => {
188+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
189+
return actions.includes('sts:AssumeRole') && actions.includes('sts:TagSession');
190+
},
191+
);
192+
expect(bundledTrust).toBeDefined();
193+
expect(JSON.stringify(trustDoc)).toContain('ComputeRole');
177194
});
178195

179-
test('addAssumingRole admits a second compute role (ECS task role) to the trust', () => {
196+
test('admitComputeRole wires both trust and grant for an additional compute role', () => {
180197
const app = new App();
181198
const stack = new Stack(app, 'MultiPrincipalStack');
182199
const agentcoreRole = new iam.Role(stack, 'AgentCoreRole', {
@@ -194,17 +211,25 @@ describe('AgentSessionRole construct', () => {
194211
traceArtifactsBucket: new s3.Bucket(stack, 'TB'),
195212
attachmentsBucket: new s3.Bucket(stack, 'AB'),
196213
});
197-
sessionRole.addAssumingRole(ecsTaskRole);
198-
199-
const trustStatements = Template.fromStack(stack).findResources(
200-
'AWS::IAM::Role',
201-
);
202-
const sr = Object.entries(trustStatements).find(([id]) => id.includes('SR'))![1];
203-
// Trust policy now has statements for both AssumeRole and TagSession; the
204-
// ECS task role ARN appears as an additional principal.
205-
const serialized = JSON.stringify(sr.Properties.AssumeRolePolicyDocument);
206-
expect(serialized).toContain('sts:TagSession');
207-
expect(serialized).toContain('EcsTaskRole');
208-
expect(serialized).toContain('AgentCoreRole');
214+
sessionRole.admitComputeRole(ecsTaskRole);
215+
216+
const stackTemplate = Template.fromStack(stack);
217+
const trustDoc = Object.entries(stackTemplate.findResources('AWS::IAM::Role')).find(
218+
([id]) => id.includes('SR'),
219+
)![1].Properties.AssumeRolePolicyDocument;
220+
const serializedTrust = JSON.stringify(trustDoc);
221+
expect(serializedTrust).toContain('sts:TagSession');
222+
expect(serializedTrust).toContain('EcsTaskRole');
223+
expect(serializedTrust).toContain('AgentCoreRole');
224+
225+
const ecsPolicy = Object.entries(stackTemplate.findResources('AWS::IAM::Policy')).find(
226+
([id]) => id.includes('EcsTaskRole'),
227+
)![1];
228+
const ecsStatements = ecsPolicy.Properties.PolicyDocument.Statement;
229+
const stsGrant = ecsStatements.find((s: { Action: string | string[] }) => {
230+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
231+
return actions.includes('sts:AssumeRole') && actions.includes('sts:TagSession');
232+
});
233+
expect(stsGrant).toBeDefined();
209234
});
210235
});

cdk/test/constructs/ecs-agent-cluster.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,16 @@ describe('EcsAgentCluster construct', () => {
266266
// to the SessionRole's policy (which carries the conditioned DDB
267267
// statements). The task-role policy must NOT contain any unconditioned
268268
// task-table DDB grant — that access now lives only on the SessionRole.
269-
const taskRolePolicies = Object.values(policies).filter((p) =>
270-
p.Properties.PolicyDocument.Statement.some((s: { Action: string | string[] }) => {
269+
const taskRolePolicies = Object.entries(policies).filter(([id, p]) =>
270+
id.includes('TaskDefTaskRole')
271+
&& p.Properties.PolicyDocument.Statement.some((s: { Action: string | string[] }) => {
271272
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
272273
return actions.includes('sts:AssumeRole');
273274
}),
274275
);
275276
expect(taskRolePolicies).toHaveLength(1);
276277

277-
const taskRoleStatements = taskRolePolicies[0].Properties.PolicyDocument.Statement;
278+
const taskRoleStatements = taskRolePolicies[0][1].Properties.PolicyDocument.Statement;
278279
// No unconditioned dynamodb item grant on the task role (the only DDB the
279280
// task role may touch directly is UserConcurrencyTable — assert that any
280281
// DDB statement present is NOT a leading-key-less task-table grant by

0 commit comments

Comments
 (0)