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
  • Loading branch information
ayush987goyal committed Apr 27, 2021
1 parent 8831916 commit 9da5d98
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 27 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: 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,15 @@ 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;

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 +67,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 Down Expand Up @@ -97,7 +98,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 +173,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 9da5d98

Please sign in to comment.