From 572ee4083968735b645ceab098059ce82e81b44d Mon Sep 17 00:00:00 2001 From: Ayush Goyal Date: Tue, 27 Apr 2021 16:56:23 +0530 Subject: [PATCH] feat(route53): add support for parentHostedZoneName for CrossAccountZoneDelegationRecord (#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* --- packages/@aws-cdk/aws-route53/README.md | 31 +++++-- .../index.ts | 24 +++++- .../@aws-cdk/aws-route53/lib/hosted-zone.ts | 16 ++++ .../@aws-cdk/aws-route53/lib/record-set.ts | 20 ++++- .../index.test.ts | 85 ++++++++++++++++++- .../aws-route53/test/hosted-zone.test.ts | 24 ++++++ ...ross-account-zone-delegation.expected.json | 63 +++++++++++--- .../integ.cross-account-zone-delegation.ts | 17 +++- .../aws-route53/test/record-set.test.ts | 82 +++++++++++++++++- 9 files changed, 333 insertions(+), 29 deletions(-) diff --git a/packages/@aws-cdk/aws-route53/README.md b/packages/@aws-cdk/aws-route53/README.md index 0213665ff2b65..1c4affa83e3a0 100644 --- a/packages/@aws-cdk/aws-route53/README.md +++ b/packages/@aws-cdk/aws-route53/README.md @@ -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, }); ``` diff --git a/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts b/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts index 3c711d283d7e5..62c3e957d1ff4 100644 --- a/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts +++ b/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts @@ -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, @@ -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', @@ -64,3 +71,14 @@ async function getCrossAccountCredentials(roleArn: string): Promise sessionToken: assumedCredentials.SessionToken, }); } + +async function getHostedZoneIdByName(name: string, route53: Route53): Promise { + 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; +} diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts index 1ca835cb258d9..e426d619fe044 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts @@ -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; } /** @@ -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({ @@ -254,6 +266,10 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone { actions: ['route53:ChangeResourceRecordSets'], resources: [this.hostedZoneArn], }), + new iam.PolicyStatement({ + actions: ['route53:ListHostedZonesByName'], + resources: ['*'], + }), ], }), }, diff --git a/packages/@aws-cdk/aws-route53/lib/record-set.ts b/packages/@aws-cdk/aws-route53/lib/record-set.ts index b880100ea60a4..57f93931c2097 100644 --- a/packages/@aws-cdk/aws-route53/lib/record-set.ts +++ b/packages/@aws-cdk/aws-route53/lib/record-set.ts @@ -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 @@ -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, @@ -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!, diff --git a/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts b/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts index 8c8dcafcbd9c7..04f7a54b5f1a1 100644 --- a/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts +++ b/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts @@ -6,6 +6,7 @@ const mockStsClient = { }; const mockRoute53Client = { changeResourceRecordSets: jest.fn().mockReturnThis(), + listHostedZonesByName: jest.fn().mockReturnThis(), promise: jest.fn(), }; @@ -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 }); @@ -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({}); @@ -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({}); @@ -97,7 +110,72 @@ test('calls create resouce record set with DELETE for Delete event', async () => }); }); -function getCfnEvent(event?: Partial): Partial { +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, + resourceProps?: any, +): Partial { return { RequestType: 'Create', ResourceProperties: { @@ -107,6 +185,7 @@ function getCfnEvent(event?: Partial { + new PublicHostedZone(stack, 'HostedZone', { + zoneName: 'testZone', + crossAccountZoneDelegationRoleName: 'myrole', + }); + }, /crossAccountZoneDelegationRoleName property is not supported without crossAccountZoneDelegationPrincipal/); + + test.done(); + }, }); diff --git a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json index 919a54f8b5051..281fc984d0756 100644 --- a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json +++ b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json @@ -58,6 +58,11 @@ ] ] } + }, + { + "Action": "route53:ListHostedZonesByName", + "Effect": "Allow", + "Resource": "*" } ], "Version": "2012-10-17" @@ -67,13 +72,13 @@ ] } }, - "ChildHostedZone4B14AC71": { + "ChildHostedZoneWithZoneId729259E6": { "Type": "AWS::Route53::HostedZone", "Properties": { "Name": "sub.myzone.com." } }, - "DelegationCrossAccountZoneDelegationCustomResourceFADC27F0": { + "DelegationWithZoneIdCrossAccountZoneDelegationCustomResourceFFD766E7": { "Type": "Custom::CrossAccountZoneDelegation", "Properties": { "ServiceToken": { @@ -94,7 +99,7 @@ "DelegatedZoneName": "sub.myzone.com", "DelegatedZoneNameServers": { "Fn::GetAtt": [ - "ChildHostedZone4B14AC71", + "ChildHostedZoneWithZoneId729259E6", "NameServers" ] }, @@ -150,7 +155,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3Bucket8B462894" + "Ref": "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602S3Bucket200D9216" }, "S3Key": { "Fn::Join": [ @@ -163,7 +168,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D" + "Ref": "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602S3VersionKey0E5C26F0" } ] } @@ -176,7 +181,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D" + "Ref": "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602S3VersionKey0E5C26F0" } ] } @@ -200,20 +205,54 @@ "DependsOn": [ "CustomCrossAccountZoneDelegationCustomResourceProviderRoleED64687B" ] + }, + "ChildHostedZoneWithZoneNameBC2C15F6": { + "Type": "AWS::Route53::HostedZone", + "Properties": { + "Name": "anothersub.myzone.com." + } + }, + "DelegationWithZoneNameCrossAccountZoneDelegationCustomResourceA1A1C94A": { + "Type": "Custom::CrossAccountZoneDelegation", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCrossAccountZoneDelegationCustomResourceProviderHandler44A84265", + "Arn" + ] + }, + "AssumeRoleArn": { + "Fn::GetAtt": [ + "ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E", + "Arn" + ] + }, + "ParentZoneName": "myzone.com", + "DelegatedZoneName": "anothersub.myzone.com", + "DelegatedZoneNameServers": { + "Fn::GetAtt": [ + "ChildHostedZoneWithZoneNameBC2C15F6", + "NameServers" + ] + }, + "TTL": 172800 + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" } }, "Parameters": { - "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3Bucket8B462894": { + "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602S3Bucket200D9216": { "Type": "String", - "Description": "S3 bucket for asset \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + "Description": "S3 bucket for asset \"d17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602\"" }, - "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D": { + "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602S3VersionKey0E5C26F0": { "Type": "String", - "Description": "S3 key for asset version \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + "Description": "S3 key for asset version \"d17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602\"" }, - "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aArtifactHash4F367D8C": { + "AssetParametersd17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602ArtifactHash37FB4D0C": { "Type": "String", - "Description": "Artifact hash for asset \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + "Description": "Artifact hash for asset \"d17df4f90e07a972e8f7b00dddbae8e3eba45a212226d2b714dcd28dded69602\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts index 75f9e86152eb0..8ec89ab3ec343 100644 --- a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts +++ b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts @@ -11,13 +11,24 @@ const parentZone = new PublicHostedZone(stack, 'ParentHostedZone', { crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal(cdk.Aws.ACCOUNT_ID), }); -const childZone = new PublicHostedZone(stack, 'ChildHostedZone', { +// with zoneId +const childZoneWithZoneId = new PublicHostedZone(stack, 'ChildHostedZoneWithZoneId', { zoneName: 'sub.myzone.com', }); -new CrossAccountZoneDelegationRecord(stack, 'Delegation', { - delegatedZone: childZone, +new CrossAccountZoneDelegationRecord(stack, 'DelegationWithZoneId', { + delegatedZone: childZoneWithZoneId, parentHostedZoneId: parentZone.hostedZoneId, delegationRole: parentZone.crossAccountZoneDelegationRole!, }); +// with zoneName +const childZoneWithZoneName = new PublicHostedZone(stack, 'ChildHostedZoneWithZoneName', { + zoneName: 'anothersub.myzone.com', +}); +new CrossAccountZoneDelegationRecord(stack, 'DelegationWithZoneName', { + delegatedZone: childZoneWithZoneName, + parentHostedZoneName: 'myzone.com', + delegationRole: parentZone.crossAccountZoneDelegationRole!, +}); + app.synth(); diff --git a/packages/@aws-cdk/aws-route53/test/record-set.test.ts b/packages/@aws-cdk/aws-route53/test/record-set.test.ts index 136f88a27d426..a9d5446185f66 100644 --- a/packages/@aws-cdk/aws-route53/test/record-set.test.ts +++ b/packages/@aws-cdk/aws-route53/test/record-set.test.ts @@ -570,7 +570,7 @@ nodeunitShim({ test.done(); }, - 'Cross account zone delegation record'(test: Test) { + 'Cross account zone delegation record with parentHostedZoneId'(test: Test) { // GIVEN const stack = new Stack(); const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', { @@ -617,4 +617,84 @@ nodeunitShim({ })); test.done(); }, + + 'Cross account zone delegation record with parentHostedZoneName'(test: Test) { + // GIVEN + const stack = new Stack(); + const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', { + zoneName: 'myzone.com', + crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('123456789012'), + }); + + // WHEN + const childZone = new route53.PublicHostedZone(stack, 'ChildHostedZone', { + zoneName: 'sub.myzone.com', + }); + new route53.CrossAccountZoneDelegationRecord(stack, 'Delegation', { + delegatedZone: childZone, + parentHostedZoneName: 'myzone.com', + delegationRole: parentZone.crossAccountZoneDelegationRole!, + ttl: Duration.seconds(60), + }); + + // THEN + expect(stack).to(haveResource('Custom::CrossAccountZoneDelegation', { + ServiceToken: { + 'Fn::GetAtt': [ + 'CustomCrossAccountZoneDelegationCustomResourceProviderHandler44A84265', + 'Arn', + ], + }, + AssumeRoleArn: { + 'Fn::GetAtt': [ + 'ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E', + 'Arn', + ], + }, + ParentZoneName: 'myzone.com', + DelegatedZoneName: 'sub.myzone.com', + DelegatedZoneNameServers: { + 'Fn::GetAtt': [ + 'ChildHostedZone4B14AC71', + 'NameServers', + ], + }, + TTL: 60, + })); + test.done(); + }, + + 'Cross account zone delegation record throws when parent id and name both/nither are supplied'(test: Test) { + // GIVEN + const stack = new Stack(); + const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', { + zoneName: 'myzone.com', + crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('123456789012'), + }); + + // THEN + const childZone = new route53.PublicHostedZone(stack, 'ChildHostedZone', { + zoneName: 'sub.myzone.com', + }); + + test.throws(() => { + new route53.CrossAccountZoneDelegationRecord(stack, 'Delegation1', { + delegatedZone: childZone, + delegationRole: parentZone.crossAccountZoneDelegationRole!, + ttl: Duration.seconds(60), + }); + }, /At least one of parentHostedZoneName or parentHostedZoneId is required/); + + test.throws(() => { + new route53.CrossAccountZoneDelegationRecord(stack, 'Delegation2', { + delegatedZone: childZone, + parentHostedZoneId: parentZone.hostedZoneId, + parentHostedZoneName: parentZone.zoneName, + delegationRole: parentZone.crossAccountZoneDelegationRole!, + ttl: Duration.seconds(60), + }); + }, /Only one of parentHostedZoneName and parentHostedZoneId is supported/); + + test.done(); + }, });