Cross Account Route 53 records custom resource in CDK

Cross Account Route 53 records custom resource in CDK

Recently, I encountered a scenario where I needed to create a public SSL certificate (AWS ACM) in account B and validate it within a hosted zone in account A.

Additionally, I had to utilize this certificate for an Application Load Balancer (ALB) and create an Alias record for the ALB in account A.

In this blog post, I will demonstrate the Custom Resource i have created that enabled me to achieve this.

Table of contents

Architecture

AWS Architecture

AWS Architecture

AWS Architecture

Why is this necessary?

In AWS CDK, there is currently no built-in functionality to create resources in two separate accounts from the same stack. While there are a few exceptions, such as the CrossAccountZoneDelegationRecord, which enables the creation of an NS record in a different account, these are limited and rely on custom resources provided by AWS.

Due to the scarcity of these built-in custom resource solutions, it often becomes necessary to develop your own solution when you require resource creation across multiple accounts.

What are custom resources?

When working with CDK, you typically utilize constructs provided by the CDK library to define and provision AWS resources. However, there may be cases where you need to interact with a service or resource that doesn’t have a corresponding construct available in CDK or CloudFormation. This is where custom resources come into play.

Custom resources enable you to define and manage any AWS resource using a Lambda function as a backend for handling resource operations.

In this example, I will be using Python and the AWS SDK (Boto3) to create public certificates and assume a role in the target account to create the necessary CNAME or A records.

Event Types

Custom resources have 3 event types:

  • Create
  • Update
  • Delete

The custom resource code needs to account for each type of event so the infrastructure cleanly destroys itself, and updates itself based off changes triggered by cloudformation.

The Lambda Code only covers the Create event. For a full view of the Lambda code, check it out on my GitHub.

Lambda

Now let’s explore the Python Lambda code responsible for achieving the desired functionality.

Lambda Logic

Lambda Logic

Lambda Logic

Lambda Logic

There are two modes built into this custom resource Lambda:

  • AWS ACM Certificate (Account B) + CNAME (Account A)
  • Route53 Record
    • Alias record (Account A)
    • Any other record (Account A)

Certificate Mode

  • Create public AWS ACM certificate in Account B.
  • Authenticate ACM certificate in Account A Hosted Zone (CNAME Record)
  • Return Certificate ARN for use within CDK (i.e. imported into the CDK code and used for downstream resources, such as HTTPS target group for Application Load Balancer).

Route 53 Record Mode

Any non-alias record
  • Create record in Account A
Alias Record

An Alias record is created a bit different to any other record, thus the need for a specific feature flag.

  • Create record in Account A

Lambda Code

First, let’s check out the the certificate mode:

Notice the else for when the lambda is executing as alias mode.

    if certificate_flag:
        domain_name = event['ResourceProperties']['DomainName']
        cert_name = event['ResourceProperties']['CertName']
        record_ttl = int(event['ResourceProperties']['RecordTTL'])
        acm_client = boto3.client('acm')
        new_certificate = acm_client.request_certificate(
            DomainName=cert_name,
            ValidationMethod='DNS',
            DomainValidationOptions=[
                {
                    'DomainName': cert_name,
                    'ValidationDomain': domain_name
                },
            ],
        )
        time.sleep(10)
        describe_certificate = acm_client.describe_certificate(
            CertificateArn=new_certificate['CertificateArn']
        )
        record_value = describe_certificate['Certificate']['DomainValidationOptions'][0]['ResourceRecord']['Value']
        record_name = describe_certificate['Certificate']['DomainValidationOptions'][0]['ResourceRecord']['Name']

    elif alias_flag:
        aws_hosted_zone_id = event['ResourceProperties']['AwsHostedZoneId']
        record_name = event['ResourceProperties']['RecordName']
        record_value = event['ResourceProperties']['RecordValue']

    else:
        record_name = event['ResourceProperties']['RecordName']
        record_value = event['ResourceProperties']['RecordValue']
        record_type = event['ResourceProperties']['RecordType']

Following setting up the record_name, record_value and creating the ACM cert (if in cert mode), we assume the AccountB role.

    # Assume a role in the target account
    sts_client = boto3.client('sts')
    assumed_role = sts_client.assume_role(
        RoleArn=role_arn,
        RoleSessionName=f'cdk-cross-account-record-demo'
    )
    print(f'assumed role {role_arn}')

    # Create a Route 53 client using the assumed role credentials
    route53_client = boto3.client(
        'route53',
        aws_access_key_id=assumed_role['Credentials']['AccessKeyId'],
        aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'],
        aws_session_token=assumed_role['Credentials']['SessionToken']
    )

Now that’s done, we’re ready to create the record in Account A’s Hosted Zone.

You might have noticed the ‘aws_hosted_zone_id’ and wondered what that is?

When creating an Alias record for an AWS service, the alias target hosted zone is not the same hosted zone the record exists within, it is an AWS owned hosted zone. This hosted zone ID will change depending on what region and service you are creating the alias for.

Read more about it here: Elastic Load Balancing endpoints

    record = {}
    if certificate_flag:
        record = {
                    'Name': record_name,
                    'Type': 'CNAME',
                    'TTL': record_ttl,
                    'ResourceRecords': [{'Value': record_value}]
                }

    elif alias_flag:
        record = {
                    'Name': record_name,
                    'Type': 'A',
                    'AliasTarget': {
                        'HostedZoneId': aws_hosted_zone_id,
                        'DNSName': record_value,
                        'EvaluateTargetHealth': False
                    }
                }
    else:
        record = {
                    'Name': record_name,
                    'Type': record_type,
                    'ResourceRecords': [{'Value': record_value}]
                }
    print(record)

    # Create the Route 53 record in the target account
    response = route53_client.change_resource_record_sets(
        HostedZoneId=hosted_zone_id,
        ChangeBatch={
            'Changes': [{
                'Action': 'CREATE',
                'ResourceRecordSet': record
            }]
        }
    )
    print(f'record response: {response}')

And finally, let’s return the result.

This will include the CertificateArn if it was a certificate creation execution.

    if certificate_flag:
        return {
            'PhysicalResourceId': record_name.replace(".", ""),
            'Data': {
                'CertificateArn': new_certificate['CertificateArn'],
            }
        }
    else:
        return {
            'PhysicalResourceId': record_name.replace(".", "")
        }

The full code can be found on my GitHub.

CDK

Moving on to the CDK part of the solution, let’s examine the steps involved in creating the custom resources.

In this example, I will be creating a certificate for the domain *.example.com and an Alias record for test.example.com.

Cross-Account Role

First, we need to create the cross-account role that will be assumed by the custom resource Lambda role to perform the Route 53 changes. Here’s an example of creating the cross-account role:

    const crossAccountRecordRole = new iam.Role(this, 'CrossAccountRecordRole', {
      assumedBy: new iam.AccountPrincipal(accountIds.accountAId),
      roleName: 'CrossAccountRecordRole',
      inlinePolicies: {
        'CrossAccountRecordPolicy': new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: [
                'route53:ChangeResourceRecordSets',
                'route53:ListResourceRecordSets',
              ],
              resources: [
                '*',
              ],
            }),
          ],
        }),
      },
    });

Custom Resource

Next, we create the Lambda function and custom resource provider to be used for both executions of the custom resource:

const lambdaFn = new lambda.Function(this, 'CustomResourceHandler', {
    runtime: lambda.Runtime.PYTHON_3_10,
    handler: 'index.lambda_handler',
    code: lambda.Code.fromAsset(path.join(__dirname, './lambda')),
    timeout: cdk.Duration.seconds(120),
    initialPolicy: [
        new iam.PolicyStatement({
        actions: [
            'acm:DescribeCertificate',
            'acm:ListCertificates',
            'acm:DeleteCertificate',
            'acm:RequestCertificate',
        ],
        resources: ['*'],
        }),
        new iam.PolicyStatement({
        actions: ['sts:AssumeRole'],
        resources: [props.crossAccountR53RoleArn],
        }),
    ],
});

const customResource = new cr.Provider(this, 'CustomResourceProvider', {
    onEventHandler: lambdaFn,
});

Now, let’s create the custom resource for the certificate validation in the route53 hosted zone of another account:

const certificateArn = new CustomResource(this, 'CrossAccountCert', {
    serviceToken: customResource.serviceToken,
    properties: {
        TargetAccountRole: props.crossAccountR53RoleArn,
        HostedZoneId: props.crossAccountHostedZoneId,
        RecordTTL: 300,
        Certificate: true,
        DomainName: 'example.com',
        CertName: '*.example.com',
    },
});

After creating the certificate, we can import it into CDK as an ICertificate class. This allows us to use it in downstream resources, such as an HTTPS target group for an Application Load Balancer. Don’t forget to add an explicit dependency to avoid errors caused by the certificate not being fully validated:

const certificate = acm.Certificate.fromCertificateArn(this, 'CrossAccountCertCDK', certificateArn.getAttString('CertificateArn'));
certificate.node.addDependency(certificateArn);

// ...
// Downstream resource
// httpsListener.node.addDependency(certificate);

Moving on to the Alias record, let’s take a look at the custom resource:

In my example, this is an Alias record for a public application load balancer. I want an A record of test.example.com to route to the public ALB.

Let’s see what that custom resource would look like:

new CustomResource(this, 'CrossAccountAlias', {
    serviceToken: customResource.serviceToken,
    properties: {
        TargetAccountRole: props.crossAccountR53RoleArn,
        HostedZoneId: props.crossAccountHostedZoneId,
        RecordName: 'test.example.com',
        RecordValue: props.loadBalancerDnsName,
        RecordTTL: 300,
        DomainName: 'example.com',
        CertName: '*.example.com',
    },
});

Conclusion

In this blog post, we explored the process of creating a public SSL certificate in one AWS account and validating it within a hosted zone in another account using AWS Custom Resources and AWS Route 53. We discussed the architecture, the concept of custom resources, the Lambda code, and the CDK implementation. By leveraging these tools and techniques, you can achieve cross-account resource management and automate complex scenarios within your AWS infrastructure.

You can find all the code on github here.

comments powered by Disqus