Skip to content

Commit

Permalink
feat(route53): support for scoping down domain names in IHostedZone.g…
Browse files Browse the repository at this point in the history
…rantDelegation()

Adds a backwards compatible parameter to `IHostedZone.grantDelegation()`
in order to restrict the `NS` records with `UPSERT`/`DELETE` access.
  • Loading branch information
marcogrcr committed Nov 26, 2023
1 parent 25ee8ef commit d6136dd
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 17 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
"route53:ChangeResourceRecordSetsActions": [
"UPSERT",
"DELETE"
],
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
"sub.uniqueexample.com"
]
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class ParentStack extends cdk.Stack {
roleName: delegationRoleName,
assumedBy: new iam.AccountPrincipal(crossAccount),
});
parentZone.grantDelegation(crossAccountRole);
parentZone.grantDelegation(crossAccountRole, route53.DelegationGrantNames.ofEquals(subZoneName));
}
}

Expand Down Expand Up @@ -81,6 +81,6 @@ childStack.addDependency(parentStack);
childOptInStack.addDependency(parentStack);

new IntegTest(app, 'Route53CrossAccountInteg', {
testCases: [childStack, childOptInStack],
testCases: [childStack, childOptInStack, parentStack],
diffAssets: true,
});
19 changes: 19 additions & 0 deletions packages/aws-cdk-lib/aws-route53/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@ new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {
});
```

To restrict the domain names that can be delegated with the IAM role use the `DelegationGrantNames` class,
which enforces the `route53:ChangeResourceRecordSetsNormalizedRecordNames` condition key.

This allows you to follow the minimum privilege principle:

```ts
const parentZone = new route53.PublicHostedZone(this, 'HostedZone', {
zoneName: 'someexample.com',
});

declare const betaCrossAccountRole: iam.Role;
parentZone.grantDelegation(betaCrossAccountRole, route53.DelegationGrantNames.ofEquals('beta.someexample.com'));

declare const prodCrossAccountRole: iam.Role;
parentZone.grantDelegation(prodCrossAccountRole, route53.DelegationGrantNames.ofEquals('prod.someexample.com'));
```

> Visit [Using IAM policy conditions for fine-grained access control to manage resource record sets](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-rrset-conditions.html) for more details.
### Add Trailing Dot to Domain Names

In order to continue managing existing domain names with trailing dots using CDK, you can set `addTrailingDot: false` to prevent the Construct from adding a dot at the end of the domain name.
Expand Down
45 changes: 45 additions & 0 deletions packages/aws-cdk-lib/aws-route53/lib/delegation-grant-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Limit the delegation grant to a set of domain names using the IAM
* `route53:ChangeResourceRecordSetsNormalizedRecordNames` context key.
*/
export abstract class DelegationGrantNames {
/**
* Match the domain names using the IAM `StringEquals` condition.
*
* @param names List of allowed record names.
*/
public static ofEquals(...names: string[]): DelegationGrantNames {
return new (class extends DelegationGrantNames {
public _equals() {
return names;
}
})();
}

/**
* Match the domain names using the IAM `StringLike` condition.
*
* @param names List of allowed record names.
*/
public static ofLike(...names: string[]): DelegationGrantNames {
return new (class extends DelegationGrantNames {
public _like() {
return names;
}
})();
}

/**
* @internal
*/
public _equals(): string[] | null {
return null;
}

/**
* @internal
*/
public _like(): string[] | null {
return null;
}
}
6 changes: 5 additions & 1 deletion packages/aws-cdk-lib/aws-route53/lib/hosted-zone-ref.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DelegationGrantNames } from './delegation-grant-names';
import * as iam from '../../aws-iam';
import { IResource } from '../../core';

Expand Down Expand Up @@ -36,8 +37,11 @@ export interface IHostedZone extends IResource {

/**
* Grant permissions to add delegation records to this zone
*
* @param grantee grantee to receive the permissions
* @param names specify to restrict the delegation to a specific set of names
*/
grantDelegation(grantee: iam.IGrantable): iam.Grant;
grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant;
}

/**
Expand Down
25 changes: 13 additions & 12 deletions packages/aws-cdk-lib/aws-route53/lib/hosted-zone.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Construct } from 'constructs';
import { DelegationGrantNames } from './delegation-grant-names';
import { HostedZoneProviderProps } from './hosted-zone-provider';
import { HostedZoneAttributes, IHostedZone, PublicHostedZoneAttributes } from './hosted-zone-ref';
import { CaaAmazonRecord, ZoneDelegationRecord } from './record-set';
Expand Down Expand Up @@ -84,8 +85,8 @@ export class HostedZone extends Resource implements IHostedZone {
public get hostedZoneArn(): string {
return makeHostedZoneArn(this, this.hostedZoneId);
}
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}

Expand All @@ -108,8 +109,8 @@ export class HostedZone extends Resource implements IHostedZone {
public get hostedZoneArn(): string {
return makeHostedZoneArn(this, this.hostedZoneId);
}
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}

Expand Down Expand Up @@ -199,8 +200,8 @@ export class HostedZone extends Resource implements IHostedZone {
this.vpcs.push({ vpcId: vpc.vpcId, vpcRegion: vpc.env.region ?? Stack.of(vpc).region });
}

public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}

Expand Down Expand Up @@ -274,8 +275,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
public get hostedZoneArn(): string {
return makeHostedZoneArn(this, this.hostedZoneId);
}
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}
return new Import(scope, id);
Expand All @@ -297,8 +298,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
public get hostedZoneArn(): string {
return makeHostedZoneArn(this, this.hostedZoneId);
}
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}
return new Import(scope, id);
Expand Down Expand Up @@ -435,8 +436,8 @@ export class PrivateHostedZone extends HostedZone implements IPrivateHostedZone
public get hostedZoneArn(): string {
return makeHostedZoneArn(this, this.hostedZoneId);
}
public grantDelegation(grantee: iam.IGrantable): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn);
public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant {
return makeGrantDelegation(grantee, this.hostedZoneArn, names);
}
}
return new Import(scope, id);
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk-lib/aws-route53/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './alias-record-target';
export * from './delegation-grant-names';
export * from './hosted-zone';
export * from './hosted-zone-provider';
export * from './hosted-zone-ref';
Expand Down
11 changes: 10 additions & 1 deletion packages/aws-cdk-lib/aws-route53/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Construct } from 'constructs';
import { DelegationGrantNames } from './delegation-grant-names';
import { IHostedZone } from './hosted-zone-ref';
import * as iam from '../../aws-iam';
import { Stack } from '../../core';
Expand Down Expand Up @@ -71,7 +72,7 @@ export function makeHostedZoneArn(construct: Construct, hostedZoneId: string): s
});
}

export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string): iam.Grant {
export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string, names?: DelegationGrantNames): iam.Grant {
const g1 = iam.Grant.addToPrincipal({
grantee,
actions: ['route53:ChangeResourceRecordSets'],
Expand All @@ -80,7 +81,15 @@ export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: stri
'ForAllValues:StringEquals': {
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
...(names?._equals() ? {
'route53:ChangeResourceRecordSetsNormalizedRecordNames': names._equals(),
} : {}),
},
...(names?._like() ? {
'ForAllValues:StringLike': {
'route53:ChangeResourceRecordSetsNormalizedRecordNames': names._like(),
},
} : {}),
},
});
const g2 = iam.Grant.addToPrincipal({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DelegationGrantNames } from '../lib/delegation-grant-names';

describe('delegation-grant-names', () => {
const NAMES = ['name-1', 'name-2'];

test('ofEquals() creates instance whose _equals() is not null', () => {
// WHEN
const actual = DelegationGrantNames.ofEquals(...NAMES);

// THEN
expect(actual._equals()).toStrictEqual(NAMES);
expect(actual._like()).toBeNull();
});

test('ofLike() creates instance whose _like() is not null', () => {
// WHEN
const actual = DelegationGrantNames.ofLike(...NAMES);

// THEN
expect(actual._equals()).toBeNull();
expect(actual._like()).toStrictEqual(NAMES);
});
});
63 changes: 63 additions & 0 deletions packages/aws-cdk-lib/aws-route53/test/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as iam from '../../aws-iam';
import * as cdk from '../../core';
import { HostedZone } from '../lib';
import { DelegationGrantNames } from '../lib/delegation-grant-names';
import * as util from '../lib/util';

describe('util', () => {
Expand Down Expand Up @@ -67,4 +69,65 @@ describe('util', () => {
// THEN
expect(qualified).toEqual('test.domain.com.');
});

test('grant delegation without names returns ChangeResourceRecordSets statement with only two condition keys', () => {
// GIVEN
const stack = new cdk.Stack();
const grantee = new iam.User(stack, 'Grantee');

// WHEN
const actual = util.makeGrantDelegation(grantee, 'hosted-zone');

// WHEN
const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets'));
expect(statement).not.toBeUndefined();
expect(statement?.conditions).toEqual({
'ForAllValues:StringEquals': {
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
},
});
});

test('grant delegation with equals names returns ChangeResourceRecordSets statement with normalized record names condition', () => {
// GIVEN
const stack = new cdk.Stack();
const grantee = new iam.User(stack, 'Grantee');

// WHEN
const actual = util.makeGrantDelegation(grantee, 'hosted-zone', DelegationGrantNames.ofEquals('name-1', 'name-2'));

// WHEN
const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets'));
expect(statement).not.toBeUndefined();
expect(statement?.conditions).toEqual({
'ForAllValues:StringEquals': {
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
'route53:ChangeResourceRecordSetsNormalizedRecordNames': ['name-1', 'name-2'],
},
});
});

test('grant delegation with like names returns ChangeResourceRecordSets statement with normalized record names condition', () => {
// GIVEN
const stack = new cdk.Stack();
const grantee = new iam.User(stack, 'Grantee');

// WHEN
const actual = util.makeGrantDelegation(grantee, 'hosted-zone', DelegationGrantNames.ofLike('name-1', 'name-2'));

// WHEN
const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets'));
expect(statement).not.toBeUndefined();
expect(statement?.conditions).toEqual({
'ForAllValues:StringEquals': {
'route53:ChangeResourceRecordSetsRecordTypes': ['NS'],
'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'],
},
'ForAllValues:StringLike': {
'route53:ChangeResourceRecordSetsNormalizedRecordNames': ['name-1', 'name-2'],
},
});
});
});

0 comments on commit d6136dd

Please sign in to comment.