Skip to content

Commit 861774b

Browse files
authored
refactor(route53): favor grant method over precreated role (#23083)
`PublicHostedZone` allows creating exactly one role for cross-account delegation. Because of limitations of trust policy size, some teams inside Amazon are creating a role per opt-in region, in which case the "one precreated role" feature doesn't help at all. Instead, add a `grantDelegation()` helper which works for both cases. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4fbc182 commit 861774b

File tree

6 files changed

+185
-17
lines changed

6 files changed

+185
-17
lines changed

packages/@aws-cdk/aws-iam/lib/grant.ts

+51-3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface CommonGrantOptions {
2424
* The resource ARNs to grant to
2525
*/
2626
readonly resourceArns: string[];
27+
28+
/**
29+
* Any conditions to attach to the grant
30+
*
31+
* @default - No conditions
32+
*/
33+
readonly conditions?: Record<string, Record<string, unknown>>;
2734
}
2835

2936
/**
@@ -160,6 +167,7 @@ export class Grant implements IDependable {
160167
const statement = new PolicyStatement({
161168
actions: options.actions,
162169
resources: options.resourceArns,
170+
conditions: options.conditions,
163171
});
164172

165173
const addedToPrincipal = options.grantee.grantPrincipal.addToPrincipalPolicy(statement);
@@ -224,17 +232,27 @@ export class Grant implements IDependable {
224232
/**
225233
* The statement that was added to the principal's policy
226234
*
227-
* Can be accessed to (e.g.) add additional conditions to the statement.
235+
* @deprecated Use `principalStatements` instead
228236
*/
229237
public readonly principalStatement?: PolicyStatement;
230238

239+
/**
240+
* The statements that were added to the principal's policy
241+
*/
242+
public readonly principalStatements = new Array<PolicyStatement>();
243+
231244
/**
232245
* The statement that was added to the resource policy
233246
*
234-
* Can be accessed to (e.g.) add additional conditions to the statement.
247+
* @deprecated Use `resourceStatements` instead
235248
*/
236249
public readonly resourceStatement?: PolicyStatement;
237250

251+
/**
252+
* The statements that were added to the principal's policy
253+
*/
254+
public readonly resourceStatements = new Array<PolicyStatement>();
255+
238256
/**
239257
* The options originally used to set this result
240258
*
@@ -243,14 +261,26 @@ export class Grant implements IDependable {
243261
*/
244262
private readonly options: CommonGrantOptions;
245263

264+
private readonly dependables = new Array<IDependable>();
265+
246266
private constructor(props: GrantProps) {
247267
this.options = props.options;
248268
this.principalStatement = props.principalStatement;
249269
this.resourceStatement = props.resourceStatement;
270+
if (this.principalStatement) {
271+
this.principalStatements.push(this.principalStatement);
272+
}
273+
if (this.resourceStatement) {
274+
this.resourceStatements.push(this.resourceStatement);
275+
}
276+
if (props.policyDependable) {
277+
this.dependables.push(props.policyDependable);
278+
}
250279

280+
const self = this;
251281
Dependable.implement(this, {
252282
get dependencyRoots() {
253-
return props.policyDependable ? Dependable.of(props.policyDependable).dependencyRoots : [];
283+
return Array.from(new Set(self.dependables.flatMap(d => Dependable.of(d).dependencyRoots)));
254284
},
255285
});
256286
}
@@ -282,6 +312,24 @@ export class Grant implements IDependable {
282312
construct.node.addDependency(this);
283313
}
284314
}
315+
316+
/**
317+
* Combine two grants into a new one
318+
*/
319+
public combine(rhs: Grant) {
320+
const combinedPrinc = [...this.principalStatements, ...rhs.principalStatements];
321+
const combinedRes = [...this.resourceStatements, ...rhs.resourceStatements];
322+
323+
const ret = new Grant({
324+
options: this.options,
325+
principalStatement: combinedPrinc[0],
326+
resourceStatement: combinedRes[0],
327+
});
328+
ret.principalStatements.splice(0, ret.principalStatements.length, ...combinedPrinc);
329+
ret.resourceStatements.splice(0, ret.resourceStatements.length, ...combinedRes);
330+
ret.dependables.push(...this.dependables, ...rhs.dependables);
331+
return ret;
332+
}
285333
}
286334

287335
function describeGrant(options: CommonGrantOptions) {

packages/@aws-cdk/aws-iam/test/grant.test.ts

+31-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Template } from '@aws-cdk/assertions';
1+
import { Template, Match } from '@aws-cdk/assertions';
22
import { CfnResource, Resource, Stack } from '@aws-cdk/core';
33
import { Construct } from 'constructs';
44
import * as iam from '../lib';
@@ -97,6 +97,34 @@ describe('Grant dependencies', () => {
9797
expectDependencyOn('RoleDefaultPolicy5FFB7DAB');
9898
expectDependencyOn('BuckarooPolicy74174DA4');
9999
});
100+
101+
test('can combine two grants', () => {
102+
// GIVEN
103+
const role1 = new iam.Role(stack, 'Role1', {
104+
assumedBy: new iam.ServicePrincipal('bla.amazonaws.com'),
105+
});
106+
const role2 = new iam.Role(stack, 'Role2', {
107+
assumedBy: new iam.ServicePrincipal('bla.amazonaws.com'),
108+
});
109+
110+
// WHEN
111+
const g1 = iam.Grant.addToPrincipal({
112+
actions: ['service:DoAThing'],
113+
grantee: role1,
114+
resourceArns: ['*'],
115+
});
116+
const g2 = iam.Grant.addToPrincipal({
117+
actions: ['service:DoAThing'],
118+
grantee: role2,
119+
resourceArns: ['*'],
120+
});
121+
122+
(g1.combine(g2)).applyBefore(resource);
123+
124+
// THEN
125+
expectDependencyOn('Role1DefaultPolicyD3EF4D0A');
126+
expectDependencyOn('Role2DefaultPolicy3A7A0A1B');
127+
});
100128
});
101129

102130
function applyGrantWithDependencyTo(principal: iam.IPrincipal) {
@@ -108,8 +136,8 @@ function applyGrantWithDependencyTo(principal: iam.IPrincipal) {
108136
}
109137

110138
function expectDependencyOn(id: string) {
111-
Template.fromStack(stack).hasResource('CDK::Test::SomeResource', (props: any) => {
112-
return (props?.DependsOn ?? []).includes(id);
139+
Template.fromStack(stack).hasResource('CDK::Test::SomeResource', {
140+
DependsOn: Match.arrayWith([id]),
113141
});
114142
}
115143

packages/@aws-cdk/aws-route53/README.md

+10-3
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,23 @@ new route53.ARecord(this, 'ARecord', {
152152

153153
### Cross Account Zone Delegation
154154

155-
To add a NS record to a HostedZone in different account you can do the following:
155+
If you want to have your root domain hosted zone in one account and your subdomain hosted
156+
zone in a diferent one, you can use `CrossAccountZoneDelegationRecord` to set up delegation
157+
between them.
156158

157159
In the account containing the parent hosted zone:
158160

159161
```ts
160162
const parentZone = new route53.PublicHostedZone(this, 'HostedZone', {
161163
zoneName: 'someexample.com',
162-
crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('12345678901'),
163-
crossAccountZoneDelegationRoleName: 'MyDelegationRole',
164164
});
165+
const crossAccountRole = new iam.Role(this, 'CrossAccountRole', {
166+
// The role name must be predictable
167+
roleName: 'MyDelegationRole',
168+
// The other account
169+
assumedBy: new iam.AccountPrincipal('12345678901'),
170+
});
171+
parentZone.grantDelegation(crossAccountRole);
165172
```
166173

167174
In the account containing the child zone to be delegated:

packages/@aws-cdk/aws-route53/lib/hosted-zone.ts

+26
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,15 @@ export interface PublicHostedZoneProps extends CommonHostedZoneProps {
213213
* with appropriate permissions for every opt-in region instead.
214214
*
215215
* @default - No delegation configuration
216+
* @deprecated Create the Role yourself and call `hostedZone.grantDelegation()`.
216217
*/
217218
readonly crossAccountZoneDelegationPrincipal?: iam.IPrincipal;
218219

219220
/**
220221
* The name of the role created for cross account delegation
221222
*
222223
* @default - A role name is generated automatically
224+
* @deprecated Create the Role yourself and call `hostedZone.grantDelegation()`.
223225
*/
224226
readonly crossAccountZoneDelegationRoleName?: string;
225227
}
@@ -342,6 +344,30 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
342344
ttl: opts.ttl,
343345
});
344346
}
347+
348+
/**
349+
* Grant permissions to add delegation records to this zone
350+
*/
351+
public grantDelegation(grantee: iam.IGrantable) {
352+
const g1 = iam.Grant.addToPrincipal({
353+
grantee,
354+
actions: ['route53:ChangeResourceRecordSets'],
355+
resourceArns: [this.hostedZoneArn],
356+
conditions: {
357+
'ForAllValues:StringEquals': {
358+
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
359+
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
360+
},
361+
},
362+
});
363+
const g2 = iam.Grant.addToPrincipal({
364+
grantee,
365+
actions: ['route53:ListHostedZonesByName'],
366+
resourceArns: ['*'],
367+
});
368+
369+
return g1.combine(g2);
370+
}
345371
}
346372

347373
/**

packages/@aws-cdk/aws-route53/test/hosted-zone.test.ts

+60-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Match, Template } from '@aws-cdk/assertions';
22
import * as ec2 from '@aws-cdk/aws-ec2';
33
import * as iam from '@aws-cdk/aws-iam';
4+
import { testDeprecated } from '@aws-cdk/cdk-build-tools';
45
import * as cdk from '@aws-cdk/core';
56
import { HostedZone, PrivateHostedZone, PublicHostedZone } from '../lib';
67

@@ -59,7 +60,7 @@ describe('hosted zone', () => {
5960
});
6061
});
6162

62-
test('with crossAccountZoneDelegationPrincipal', () => {
63+
testDeprecated('with crossAccountZoneDelegationPrincipal', () => {
6364
// GIVEN
6465
const stack = new cdk.Stack(undefined, 'TestStack', {
6566
env: { account: '123456789012', region: 'us-east-1' },
@@ -154,7 +155,7 @@ describe('hosted zone', () => {
154155
});
155156
});
156157

157-
test('with crossAccountZoneDelegationPrincipal, throws if name provided without principal', () => {
158+
testDeprecated('with crossAccountZoneDelegationPrincipal, throws if name provided without principal', () => {
158159
// GIVEN
159160
const stack = new cdk.Stack(undefined, 'TestStack', {
160161
env: { account: '123456789012', region: 'us-east-1' },
@@ -229,3 +230,60 @@ describe('Vpc', () => {
229230
});
230231
});
231232
});
233+
234+
test('grantDelegation', () => {
235+
// GIVEN
236+
const stack = new cdk.Stack(undefined, 'TestStack', {
237+
env: { account: '123456789012', region: 'us-east-1' },
238+
});
239+
240+
const role = new iam.Role(stack, 'Role', {
241+
assumedBy: new iam.AccountPrincipal('22222222222222'),
242+
});
243+
244+
const zone = new PublicHostedZone(stack, 'Zone', {
245+
zoneName: 'banana.com',
246+
});
247+
248+
// WHEN
249+
zone.grantDelegation(role);
250+
251+
// THEN
252+
const template = Template.fromStack(stack);
253+
template.hasResourceProperties('AWS::IAM::Policy', {
254+
PolicyDocument: {
255+
Statement: [
256+
{
257+
Action: 'route53:ChangeResourceRecordSets',
258+
Effect: 'Allow',
259+
Resource: {
260+
'Fn::Join': [
261+
'',
262+
[
263+
'arn:',
264+
{
265+
Ref: 'AWS::Partition',
266+
},
267+
':route53:::hostedzone/',
268+
{
269+
Ref: 'ZoneA5DE4B68',
270+
},
271+
],
272+
],
273+
},
274+
Condition: {
275+
'ForAllValues:StringEquals': {
276+
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
277+
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
278+
},
279+
},
280+
},
281+
{
282+
Action: 'route53:ListHostedZonesByName',
283+
Effect: 'Allow',
284+
Resource: '*',
285+
},
286+
],
287+
},
288+
});
289+
});

packages/@aws-cdk/aws-route53/test/record-set.test.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Template } from '@aws-cdk/assertions';
22
import * as iam from '@aws-cdk/aws-iam';
3+
import { testDeprecated } from '@aws-cdk/cdk-build-tools';
34
import { Duration, RemovalPolicy, Stack } from '@aws-cdk/core';
45
import * as route53 from '../lib';
56

@@ -578,7 +579,7 @@ describe('record set', () => {
578579
});
579580
});
580581

581-
test('Cross account zone delegation record with parentHostedZoneId', () => {
582+
testDeprecated('Cross account zone delegation record with parentHostedZoneId', () => {
582583
// GIVEN
583584
const stack = new Stack();
584585
const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', {
@@ -630,7 +631,7 @@ describe('record set', () => {
630631
});
631632
});
632633

633-
test('Cross account zone delegation record with parentHostedZoneName', () => {
634+
testDeprecated('Cross account zone delegation record with parentHostedZoneName', () => {
634635
// GIVEN
635636
const stack = new Stack();
636637
const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', {
@@ -675,7 +676,7 @@ describe('record set', () => {
675676
});
676677
});
677678

678-
test('Cross account zone delegation record throws when parent id and name both/nither are supplied', () => {
679+
testDeprecated('Cross account zone delegation record throws when parent id and name both/nither are supplied', () => {
679680
// GIVEN
680681
const stack = new Stack();
681682
const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', {
@@ -707,7 +708,7 @@ describe('record set', () => {
707708
}).toThrow(/Only one of parentHostedZoneName and parentHostedZoneId is supported/);
708709
});
709710

710-
test('Multiple cross account zone delegation records', () => {
711+
testDeprecated('Multiple cross account zone delegation records', () => {
711712
// GIVEN
712713
const stack = new Stack();
713714
const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', {
@@ -773,7 +774,7 @@ describe('record set', () => {
773774
}
774775
});
775776

776-
test('Cross account zone delegation policies', () => {
777+
testDeprecated('Cross account zone delegation policies', () => {
777778
// GIVEN
778779
const stack = new Stack();
779780
const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', {
@@ -845,7 +846,7 @@ describe('record set', () => {
845846
}
846847
});
847848

848-
test('Cross account zone context flag', () => {
849+
testDeprecated('Cross account zone context flag', () => {
849850
// GIVEN
850851
const stack = new Stack();
851852
stack.node.setContext('@aws-cdk/aws-route53:useRegionalStsEndpoint', true);

0 commit comments

Comments
 (0)