Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(route53): add support for parentHostedZoneName for CrossAccountZoneDelegationRecord #14097

Merged
merged 3 commits into from
Apr 27, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
njlynch marked this conversation as resolved.
Show resolved Hide resolved
});
```

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);
ayush987goyal marked this conversation as resolved.
Show resolved Hide resolved

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