Skip to content

Commit

Permalink
feat(route53): add support for parentHostedZoneName for CrossAccountZ…
Browse files Browse the repository at this point in the history
…oneDelegationRecord (#14097)

feat(route53): add support for parentHostedZoneName for CrossAccountZoneDelegationRecord

1. Zone name lookup: 

The current implementation of `CrossAccountZoneDelegationRecord` requires users to pass the `parentHostedZoneId` which is not so easy since we cannot pass references cross account.

This implementation lets them pass the zone name and queries the route53 API to get the zoneId.

2. Delegation role name addition

The ARN of the delegation role cannot be passed around to stacks since they will have different environments (this is cross account delegation and CDK does not allow passing references across environments).

This means that users need to import the delegation role in the stack containing the child zone. Since it needs to be imported, the users would need the role ARN. Now this ARN can either be hard-coded or constructed. Hard-coding the ARN is not entirely feasible since that would require deploying the parent stack first, then making code change and then deploying child stack in pipelines.

With role name, the ARN can be easily constructed (refer readme for importing role) and it provides a better user experience.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
ayush987goyal authored Apr 27, 2021
1 parent 6c99963 commit 572ee40
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 29 deletions.
31 changes: 25 additions & 6 deletions packages/@aws-cdk/aws-route53/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,26 +125,45 @@ Constructs are available for A, AAAA, CAA, CNAME, MX, NS, SRV and TXT records.
Use the `CaaAmazonRecord` construct to easily restrict certificate authorities
allowed to issue certificates for a domain to Amazon only.

To add a NS record to a HostedZone in different account
To add a NS record to a HostedZone in different account you can do the following:

In the account containing the parent hosted zone:

```ts
import * as route53 from '@aws-cdk/aws-route53';

// In the account containing the HostedZone
const parentZone = new route53.PublicHostedZone(this, 'HostedZone', {
zoneName: 'someexample.com',
crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('12345678901')
crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('12345678901'),
crossAccountZoneDelegationRoleName: 'MyDelegationRole',
});
```

In the account containing the child zone to be delegated:

```ts
import * as iam from '@aws-cdk/aws-iam';
import * as route53 from '@aws-cdk/aws-route53';

// In this account
const subZone = new route53.PublicHostedZone(this, 'SubZone', {
zoneName: 'sub.someexample.com'
});

// import the delegation role by constructing the roleArn
const delegationRoleArn = Stack.of(this).formatArn({
region: '', // IAM is global in each partition
service: 'iam',
account: 'parent-account-id',
resource: 'role',
resourceName: 'MyDelegationRole',
});
const delegationRole = iam.Role.fromRoleArn(this, 'DelegationRole', delegationRoleArn);

// create the record
new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {
delegatedZone: subZone,
parentHostedZoneId: parentZone.hostedZoneId,
delegationRole: parentZone.crossAccountDelegationRole
parentHostedZoneName: 'someexample.com', // or you can use parentHostedZoneId
delegationRole,
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Credentials, Route53, STS } from 'aws-sdk';

interface ResourceProperties {
AssumeRoleArn: string,
ParentZoneId: string,
ParentZoneName?: string,
ParentZoneId?: string,
DelegatedZoneName: string,
DelegatedZoneNameServers: string[],
TTL: number,
Expand All @@ -22,13 +23,19 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent
}

async function cfnEventHandler(props: ResourceProperties, isDeleteEvent: boolean) {
const { AssumeRoleArn, ParentZoneId, DelegatedZoneName, DelegatedZoneNameServers, TTL } = props;
const { AssumeRoleArn, ParentZoneId, ParentZoneName, DelegatedZoneName, DelegatedZoneNameServers, TTL } = props;

if (!ParentZoneId && !ParentZoneName) {
throw Error('One of ParentZoneId or ParentZoneName must be specified');
}

const credentials = await getCrossAccountCredentials(AssumeRoleArn);
const route53 = new Route53({ credentials });

const parentZoneId = ParentZoneId ?? await getHostedZoneIdByName(ParentZoneName!, route53);

await route53.changeResourceRecordSets({
HostedZoneId: ParentZoneId,
HostedZoneId: parentZoneId,
ChangeBatch: {
Changes: [{
Action: isDeleteEvent ? 'DELETE' : 'UPSERT',
Expand Down Expand Up @@ -64,3 +71,14 @@ async function getCrossAccountCredentials(roleArn: string): Promise<Credentials>
sessionToken: assumedCredentials.SessionToken,
});
}

async function getHostedZoneIdByName(name: string, route53: Route53): Promise<string> {
const zones = await route53.listHostedZonesByName({ DNSName: name }).promise();
const matchedZones = zones.HostedZones.filter(zone => zone.Name === `${name}.`);

if (matchedZones.length !== 1) {
throw Error(`Expected one hosted zone to match the given name but found ${matchedZones.length}`);
}

return matchedZones[0].Id;
}
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-route53/lib/hosted-zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ export interface PublicHostedZoneProps extends CommonHostedZoneProps {
* @default - No delegation configuration
*/
readonly crossAccountZoneDelegationPrincipal?: iam.IPrincipal;

/**
* The name of the role created for cross account delegation
*
* @default - A role name is generated automatically
*/
readonly crossAccountZoneDelegationRoleName?: string;
}

/**
Expand Down Expand Up @@ -244,8 +251,13 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
});
}

if (!props.crossAccountZoneDelegationPrincipal && props.crossAccountZoneDelegationRoleName) {
throw Error('crossAccountZoneDelegationRoleName property is not supported without crossAccountZoneDelegationPrincipal');
}

if (props.crossAccountZoneDelegationPrincipal) {
this.crossAccountZoneDelegationRole = new iam.Role(this, 'CrossAccountZoneDelegationRole', {
roleName: props.crossAccountZoneDelegationRoleName,
assumedBy: props.crossAccountZoneDelegationPrincipal,
inlinePolicies: {
delegation: new iam.PolicyDocument({
Expand All @@ -254,6 +266,10 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone {
actions: ['route53:ChangeResourceRecordSets'],
resources: [this.hostedZoneArn],
}),
new iam.PolicyStatement({
actions: ['route53:ListHostedZonesByName'],
resources: ['*'],
}),
],
}),
},
Expand Down
20 changes: 19 additions & 1 deletion packages/@aws-cdk/aws-route53/lib/record-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,10 +602,19 @@ export interface CrossAccountZoneDelegationRecordProps {
*/
readonly delegatedZone: IHostedZone;

/**
* The hosted zone name in the parent account
*
* @default - no zone name
*/
readonly parentHostedZoneName?: string;

/**
* The hosted zone id in the parent account
*
* @default - no zone id
*/
readonly parentHostedZoneId: string;
readonly parentHostedZoneId?: string;

/**
* The delegation role in the parent account
Expand All @@ -627,6 +636,14 @@ export class CrossAccountZoneDelegationRecord extends CoreConstruct {
constructor(scope: Construct, id: string, props: CrossAccountZoneDelegationRecordProps) {
super(scope, id);

if (!props.parentHostedZoneName && !props.parentHostedZoneId) {
throw Error('At least one of parentHostedZoneName or parentHostedZoneId is required');
}

if (props.parentHostedZoneName && props.parentHostedZoneId) {
throw Error('Only one of parentHostedZoneName and parentHostedZoneId is supported');
}

const serviceToken = CustomResourceProvider.getOrCreate(this, CROSS_ACCOUNT_ZONE_DELEGATION_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'cross-account-zone-delegation-handler'),
runtime: CustomResourceProviderRuntime.NODEJS_12_X,
Expand All @@ -638,6 +655,7 @@ export class CrossAccountZoneDelegationRecord extends CoreConstruct {
serviceToken,
properties: {
AssumeRoleArn: props.delegationRole.roleArn,
ParentZoneName: props.parentHostedZoneName,
ParentZoneId: props.parentHostedZoneId,
DelegatedZoneName: props.delegatedZone.zoneName,
DelegatedZoneNameServers: props.delegatedZone.hostedZoneNameServers!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const mockStsClient = {
};
const mockRoute53Client = {
changeResourceRecordSets: jest.fn().mockReturnThis(),
listHostedZonesByName: jest.fn().mockReturnThis(),
promise: jest.fn(),
};

Expand All @@ -20,12 +21,24 @@ jest.mock('aws-sdk', () => {
beforeEach(() => {
mockStsClient.assumeRole.mockReturnThis();
mockRoute53Client.changeResourceRecordSets.mockReturnThis();
mockRoute53Client.listHostedZonesByName.mockReturnThis();
});

afterEach(() => {
jest.clearAllMocks();
});

test('throws error if both ParentZoneId and ParentZoneName are not provided', async () => {
// WHEN
const event = getCfnEvent({}, {
ParentZoneId: undefined,
ParentZoneName: undefined,
});

// THEN
await expect(invokeHandler(event)).rejects.toThrow(/One of ParentZoneId or ParentZoneName must be specified/);
});

test('throws error if getting credentials fails', async () => {
// GIVEN
mockStsClient.promise.mockResolvedValueOnce({ Credentials: undefined });
Expand All @@ -43,7 +56,7 @@ test('throws error if getting credentials fails', async () => {
});
});

test('calls create resouce record set with Upsert for Create event', async () => {
test('calls create resource record set with Upsert for Create event', async () => {
// GIVEN
mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } });
mockRoute53Client.promise.mockResolvedValueOnce({});
Expand All @@ -70,7 +83,7 @@ test('calls create resouce record set with Upsert for Create event', async () =>
});
});

test('calls create resouce record set with DELETE for Delete event', async () => {
test('calls create resource record set with DELETE for Delete event', async () => {
// GIVEN
mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } });
mockRoute53Client.promise.mockResolvedValueOnce({});
Expand All @@ -97,7 +110,72 @@ test('calls create resouce record set with DELETE for Delete event', async () =>
});
});

function getCfnEvent(event?: Partial<AWSLambda.CloudFormationCustomResourceEvent>): Partial<AWSLambda.CloudFormationCustomResourceEvent> {
test('calls listHostedZonesByName to get zoneId if ParentZoneId is not provided', async () => {
// GIVEN
const parentZoneName = 'some.zone';
const parentZoneId = 'zone-id';

mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } });
mockRoute53Client.promise.mockResolvedValueOnce({ HostedZones: [{ Name: `${parentZoneName}.`, Id: parentZoneId }] });
mockRoute53Client.promise.mockResolvedValueOnce({});

// WHEN
const event = getCfnEvent({}, {
ParentZoneId: undefined,
ParentZoneName: parentZoneName,
});
await invokeHandler(event);

// THEN
expect(mockRoute53Client.listHostedZonesByName).toHaveBeenCalledTimes(1);
expect(mockRoute53Client.listHostedZonesByName).toHaveBeenCalledWith({ DNSName: parentZoneName });

expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledTimes(1);
expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledWith({
HostedZoneId: parentZoneId,
ChangeBatch: {
Changes: [{
Action: 'UPSERT',
ResourceRecordSet: {
Name: 'recordName',
Type: 'NS',
TTL: 172800,
ResourceRecords: [{ Value: 'one' }, { Value: 'two' }],
},
}],
},
});
});

test('throws if more than one HostedZones are returnd for the provided ParentHostedZone', async () => {
// GIVEN
const parentZoneName = 'some.zone';
const parentZoneId = 'zone-id';

mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } });
mockRoute53Client.promise.mockResolvedValueOnce({
HostedZones: [
{ Name: `${parentZoneName}.`, Id: parentZoneId },
{ Name: `${parentZoneName}.`, Id: parentZoneId },
],
});

// WHEN
const event = getCfnEvent({}, {
ParentZoneId: undefined,
ParentZoneName: parentZoneName,
});

// THEN
await expect(invokeHandler(event)).rejects.toThrow(/Expected one hosted zone to match the given name but found 2/);
expect(mockRoute53Client.listHostedZonesByName).toHaveBeenCalledTimes(1);
expect(mockRoute53Client.listHostedZonesByName).toHaveBeenCalledWith({ DNSName: parentZoneName });
});

function getCfnEvent(
event?: Partial<AWSLambda.CloudFormationCustomResourceEvent>,
resourceProps?: any,
): Partial<AWSLambda.CloudFormationCustomResourceEvent> {
return {
RequestType: 'Create',
ResourceProperties: {
Expand All @@ -107,6 +185,7 @@ function getCfnEvent(event?: Partial<AWSLambda.CloudFormationCustomResourceEvent
DelegatedZoneName: 'recordName',
DelegatedZoneNameServers: ['one', 'two'],
TTL: 172800,
...resourceProps,
},
...event,
};
Expand Down
24 changes: 24 additions & 0 deletions packages/@aws-cdk/aws-route53/test/hosted-zone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ nodeunitShim({
new PublicHostedZone(stack, 'HostedZone', {
zoneName: 'testZone',
crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('223456789012'),
crossAccountZoneDelegationRoleName: 'myrole',
});

// THEN
Expand All @@ -87,6 +88,7 @@ nodeunitShim({
HostedZoneCrossAccountZoneDelegationRole685DF755: {
Type: 'AWS::IAM::Role',
Properties: {
RoleName: 'myrole',
AssumeRolePolicyDocument: {
Statement: [
{
Expand Down Expand Up @@ -133,6 +135,11 @@ nodeunitShim({
],
},
},
{
Action: 'route53:ListHostedZonesByName',
Effect: 'Allow',
Resource: '*',
},
],
Version: '2012-10-17',
},
Expand All @@ -146,4 +153,21 @@ nodeunitShim({

test.done();
},

'with crossAccountZoneDelegationPrincipal, throws if name provided without principal'(test: Test) {
// GIVEN
const stack = new cdk.Stack(undefined, 'TestStack', {
env: { account: '123456789012', region: 'us-east-1' },
});

// THEN
test.throws(() => {
new PublicHostedZone(stack, 'HostedZone', {
zoneName: 'testZone',
crossAccountZoneDelegationRoleName: 'myrole',
});
}, /crossAccountZoneDelegationRoleName property is not supported without crossAccountZoneDelegationPrincipal/);

test.done();
},
});
Loading

0 comments on commit 572ee40

Please sign in to comment.