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.
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.
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.
Custom resources have 3 event types:
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.
Now let’s explore the Python Lambda code responsible for achieving the desired functionality.
There are two modes built into this custom resource Lambda:
An Alias record is created a bit different to any other record, thus the need for a specific feature flag.
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.
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.
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: [
'*',
],
}),
],
}),
},
});
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',
},
});
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.