Skip to content

Commit 88ea9b1

Browse files
committed
add nameEquals option to grantDelegation
1 parent 2559e1d commit 88ea9b1

File tree

8 files changed

+111
-51
lines changed

8 files changed

+111
-51
lines changed

packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/integ.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/parent-stack.template.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,11 @@
679679
"route53:ChangeResourceRecordSetsActions": [
680680
"UPSERT",
681681
"DELETE"
682+
],
683+
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
684+
"sub.uniqueexample.com",
685+
"sub2.uniqueexample.com",
686+
"sub3.uniqueexample.com"
682687
]
683688
}
684689
},
@@ -746,4 +751,4 @@
746751
]
747752
}
748753
}
749-
}
754+
}

packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ class ParentStack extends cdk.Stack {
5858
roleName: delegationRoleName,
5959
assumedBy: new iam.AccountPrincipal(crossAccount),
6060
});
61-
parentZone.grantDelegation(crossAccountRole);
61+
parentZone.grantDelegation(crossAccountRole, {
62+
nameEquals: [
63+
'sub.uniqueexample.com',
64+
'sub2.uniqueexample.com',
65+
'sub3.uniqueexample.com',
66+
],
67+
});
6268
}
6369
}
6470

@@ -130,6 +136,6 @@ childOptInStack.addDependency(parentStack);
130136
childOptInStackWithAssumeRoleRegion.addDependency(parentStack);
131137

132138
new IntegTest(app, 'Route53CrossAccountInteg', {
133-
testCases: [childStack, childOptInStack, childOptInStackWithAssumeRoleRegion],
139+
testCases: [childStack, childOptInStack, childOptInStackWithAssumeRoleRegion, parentStack],
134140
diffAssets: true,
135141
});

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

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -343,40 +343,31 @@ const crossAccountRole = new iam.Role(this, 'CrossAccountRole', {
343343
roleName: 'MyDelegationRole',
344344
// The other account
345345
assumedBy: new iam.AccountPrincipal('12345678901'),
346-
// You can scope down this role policy to be least privileged.
347-
// If you want the other account to be able to manage specific records,
348-
// you can scope down by resource and/or normalized record names
349-
inlinePolicies: {
350-
crossAccountPolicy: new iam.PolicyDocument({
351-
statements: [
352-
new iam.PolicyStatement({
353-
sid: 'ListHostedZonesByName',
354-
effect: iam.Effect.ALLOW,
355-
actions: ['route53:ListHostedZonesByName'],
356-
resources: ['*'],
357-
}),
358-
new iam.PolicyStatement({
359-
sid: 'GetHostedZoneAndChangeResourceRecordSets',
360-
effect: iam.Effect.ALLOW,
361-
actions: ['route53:GetHostedZone', 'route53:ChangeResourceRecordSets'],
362-
// This example assumes the RecordSet subdomain.somexample.com
363-
// is contained in the HostedZone
364-
resources: ['arn:aws:route53:::hostedzone/HZID00000000000000000'],
365-
conditions: {
366-
'ForAllValues:StringLike': {
367-
'route53:ChangeResourceRecordSetsNormalizedRecordNames': [
368-
'subdomain.someexample.com',
369-
],
370-
},
371-
},
372-
}),
373-
],
374-
}),
375-
},
376346
});
377347
parentZone.grantDelegation(crossAccountRole);
378348
```
379349

350+
To restrict the domain names that can be delegated with the IAM role, use the optional `nameEquals` property in the delegation options,
351+
which enforces the `route53:ChangeResourceRecordSetsNormalizedRecordNames` condition key.
352+
353+
This allows you to follow the minimum privilege principle:
354+
355+
```ts
356+
const parentZone = new route53.PublicHostedZone(this, 'HostedZone', {
357+
zoneName: 'someexample.com',
358+
});
359+
360+
declare const betaCrossAccountRole: iam.Role;
361+
parentZone.grantDelegation(betaCrossAccountRole, {
362+
nameEquals: ['beta.someexample.com'],
363+
});
364+
365+
declare const prodCrossAccountRole: iam.Role;
366+
parentZone.grantDelegation(prodCrossAccountRole, {
367+
nameEquals: ['prod.someexample.com'],
368+
});
369+
```
370+
380371
In the account containing the child zone to be delegated:
381372

382373
```ts

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface IHostedZone extends IResource {
3737
/**
3838
* Grant permissions to add delegation records to this zone
3939
*/
40-
grantDelegation(grantee: iam.IGrantable): iam.Grant;
40+
grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant;
4141
}
4242

4343
/**
@@ -59,3 +59,15 @@ export interface HostedZoneAttributes {
5959
* Reference to a public hosted zone
6060
*/
6161
export interface PublicHostedZoneAttributes extends HostedZoneAttributes { }
62+
63+
/**
64+
* Options for the delegation permissions granted
65+
*/
66+
export interface GrantDelegationOptions {
67+
/**
68+
* List of NS record names to allowlist in the delegation permissions
69+
*
70+
* @default the grant does not restrict record name
71+
*/
72+
readonly nameEquals?: string[];
73+
}

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Construct } from 'constructs';
22
import { HostedZoneProviderProps } from './hosted-zone-provider';
3-
import { HostedZoneAttributes, IHostedZone, PublicHostedZoneAttributes } from './hosted-zone-ref';
3+
import { GrantDelegationOptions, HostedZoneAttributes, IHostedZone, PublicHostedZoneAttributes } from './hosted-zone-ref';
44
import { IKeySigningKey, KeySigningKey } from './key-signing-key';
55
import { CaaAmazonRecord, ZoneDelegationRecord } from './record-set';
66
import { CfnHostedZone, CfnDNSSEC, CfnKeySigningKey } from './route53.generated';
@@ -117,8 +117,8 @@ export class HostedZone extends Resource implements IHostedZone {
117117
public get hostedZoneArn(): string {
118118
return makeHostedZoneArn(this, this.hostedZoneId);
119119
}
120-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
121-
return makeGrantDelegation(grantee, this.hostedZoneArn);
120+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
121+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
122122
}
123123
}
124124

@@ -141,8 +141,8 @@ export class HostedZone extends Resource implements IHostedZone {
141141
public get hostedZoneArn(): string {
142142
return makeHostedZoneArn(this, this.hostedZoneId);
143143
}
144-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
145-
return makeGrantDelegation(grantee, this.hostedZoneArn);
144+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
145+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
146146
}
147147
}
148148

@@ -241,8 +241,8 @@ export class HostedZone extends Resource implements IHostedZone {
241241
}
242242

243243
@MethodMetadata()
244-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
245-
return makeGrantDelegation(grantee, this.hostedZoneArn);
244+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
245+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
246246
}
247247

248248
/**
@@ -345,8 +345,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
345345
public get hostedZoneArn(): string {
346346
return makeHostedZoneArn(this, this.hostedZoneId);
347347
}
348-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
349-
return makeGrantDelegation(grantee, this.hostedZoneArn);
348+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
349+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
350350
}
351351
}
352352
return new Import(scope, id);
@@ -368,8 +368,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
368368
public get hostedZoneArn(): string {
369369
return makeHostedZoneArn(this, this.hostedZoneId);
370370
}
371-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
372-
return makeGrantDelegation(grantee, this.hostedZoneArn);
371+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
372+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
373373
}
374374
}
375375
return new Import(scope, id);
@@ -513,8 +513,8 @@ export class PrivateHostedZone extends HostedZone implements IPrivateHostedZone
513513
public get hostedZoneArn(): string {
514514
return makeHostedZoneArn(this, this.hostedZoneId);
515515
}
516-
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
517-
return makeGrantDelegation(grantee, this.hostedZoneArn);
516+
public grantDelegation(grantee: iam.IGrantable, options?: GrantDelegationOptions): iam.Grant {
517+
return makeGrantDelegation(grantee, this.hostedZoneArn, options);
518518
}
519519
}
520520
return new Import(scope, id);

packages/aws-cdk-lib/aws-route53/lib/util.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Construct } from 'constructs';
2-
import { IHostedZone } from './hosted-zone-ref';
2+
import { GrantDelegationOptions, IHostedZone } from './hosted-zone-ref';
33
import * as iam from '../../aws-iam';
44
import { Stack } from '../../core';
55

@@ -71,7 +71,7 @@ export function makeHostedZoneArn(construct: Construct, hostedZoneId: string): s
7171
});
7272
}
7373

74-
export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string): iam.Grant {
74+
export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string, delegationOptions?: GrantDelegationOptions): iam.Grant {
7575
const g1 = iam.Grant.addToPrincipal({
7676
grantee,
7777
actions: ['route53:ChangeResourceRecordSets'],
@@ -80,6 +80,9 @@ export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: stri
8080
'ForAllValues:StringEquals': {
8181
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
8282
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
83+
...(delegationOptions?.nameEquals ? {
84+
'route53:ChangeResourceRecordSetsNormalizedRecordNames': delegationOptions.nameEquals,
85+
} : {}),
8386
},
8487
},
8588
});

packages/aws-cdk-lib/aws-route53/test/util.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as iam from '../../aws-iam';
12
import * as cdk from '../../core';
23
import { HostedZone } from '../lib';
34
import * as util from '../lib/util';
@@ -67,4 +68,45 @@ describe('util', () => {
6768
// THEN
6869
expect(qualified).toEqual('test.domain.com.');
6970
});
71+
72+
test('grant delegation without nameEquals returns ChangeResourceRecordSets statement without normalzed record names condition', () => {
73+
// GIVEN
74+
const stack = new cdk.Stack();
75+
const grantee = new iam.User(stack, 'Grantee');
76+
77+
// WHEN
78+
const actual = util.makeGrantDelegation(grantee, 'hosted-zone');
79+
80+
// THEN
81+
const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets'));
82+
expect(statement).not.toBeUndefined();
83+
expect(statement?.conditions).toEqual({
84+
'ForAllValues:StringEquals': {
85+
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
86+
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
87+
},
88+
});
89+
});
90+
91+
test('grant delegation with nameEquals returns ChangeResourceRecordSets statement with normalized record names condition', () => {
92+
// GIVEN
93+
const stack = new cdk.Stack();
94+
const grantee = new iam.User(stack, 'Grantee');
95+
96+
// WHEN
97+
const actual = util.makeGrantDelegation(grantee, 'hosted-zone', {
98+
nameEquals: ['name-1', 'name-2'],
99+
});
100+
101+
// THEN
102+
const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets'));
103+
expect(statement).not.toBeUndefined();
104+
expect(statement?.conditions).toEqual({
105+
'ForAllValues:StringEquals': {
106+
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
107+
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
108+
'route53:ChangeResourceRecordSetsNormalizedRecordNames': ['name-1', 'name-2'],
109+
},
110+
});
111+
});
70112
});

0 commit comments

Comments
 (0)