How can I set up my HostedZone so that it delegates to a parent DNS record in another AWS account?

Viewed 2897

Introduction

I have some TypeScript code that uses CDK to create an API Gateway and a Lambda. It works and deploys to a standard AWS URL. So far so good.

I now need to transfer the API Gateway so that it operates on a custom domain, so that it can set a cookie in a web app. This is proving far harder, and I suspect I am having difficulty because I am new to TypeScript, AWS, and CDK all at the same time. There are a number of documentation resources on the web, but most would require me to rewrite the precious little working code I have, which I am reluctant to do.

I have created a certificate manually, because that requires validation and thus it does not make sense to create it in code. Other than that I want all other resources to be created by CDK code in a Stack. In my view, it defeats the purpose of CDK if I have to configure things manually.

Problem

The below code deploys everything I need to gatekeeper.d.aws.example.com - a HostedZone, an ARecord, a LambdaRestApi and a Function (lambda). However it does not work because the NS records newly assigned to gatekeeper.d.aws.example.com do not match the ones in the parent d.aws.example.com.

I think this means that although d.aws.example.com is "known", the gateway subdomain cannot delegate to it.

Here is my working code:

// Create the lambda resource
const referrerLambda = new lambda.Function(this, 'EisReferrerLambda', {
    runtime: lambda.Runtime.NODEJS_14_X,
    handler: 'index.handler',
    code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambda')),
    environment: env
});

// Set up the domain name on which the API should appear
const domainName = 'gatekeeper.d.aws.example.com';

// TODO need to fetch it with an env var? Or read from environment?
const certificateArn = 'arn:aws:acm:us-east-1:xxx:certificate/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy';

const certificate = acm.Certificate.fromCertificateArn(this, 'SslCertificate', certificateArn);

const hostedZone = new route53.HostedZone(this, 'EisReferrerHostedZone', {
    zoneName: domainName
});

// Add an A record
new route53.ARecord(this, 'DnsRecord', {
    zone: hostedZone,
    target: route53.RecordTarget.fromAlias(new targets.ApiGateway(apiGateway)),
});

// I think I need a DomainNameOptions object
const dno : DomainNameOptions = { certificate, domainName };

// Create the APIG resource
// See https://intro-to-cdk.workshop.aws/the-workshop/4-create-apigateway.html
const apiGateway = new apigw.LambdaRestApi(this, "EisReferrerApi", {
    handler: referrerLambda,
    // proxy = on means that the lambda handles all requests to the APIG,
    // instead of just explicit resource endpoints
    proxy: false,
    // deploy = on means that we get a default stage of "prod", I don't want
    // that - I'm creating a custom Deployment anyway
    deploy: false,
    // Point to a domain name options object
    domainName: dno
});

// Create an endpoint in the APIG
// https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigateway-readme.html#defining-apis
const items = apiGateway.root.addResource('gatekeeper');
items.addMethod('GET');  // GET /default/gatekeeper

// The deployment resource is just needed by the Stage system
const deployment = new apigw.Deployment(
    this,
    'EisReferrerDeployment',
    { api: apiGateway }
);

// Create a Stage (this affects the first component in the path
const stageName = 'default';
apiGateway.deploymentStage = new apigw.Stage(
    this,
    stageName,
    { deployment, stageName }
);

Question

As you can see from the code, I've found how to create an A record, but creating/modifying NS records seems harder. For a start, there does not seem to be an NSRecord class, at least based on exploring the class structure from my IDE autocomplete.

A rudimentary solution would allow me to create NS records with the fixed values that are set up elsewhere (in the AWS account that "owns" the domain). A better solution would be to read what those records are, and then use them.

Update

To see if my thinking is on the right track, I have run this deployment code, and manually modified the automatically assigned NS records in the HostedZone to match the records in the parent (in the other account). I think I have to wait for this change to seep into the DNS system, and I will update with the result.

Update 2

My manual adjustment did not work. I have therefore found a new thing to try (see "To add a NS record to a HostedZone in different account"):

// Commented out from earlier code
// const hostedZone = new route53.HostedZone(this, 'EisReferrerHostedZone', {
//     zoneName: domainName
// });

// In the account containing the HostedZone
const parentZone = new route53.PublicHostedZone(this, 'HostedZone', {
    zoneName: 'd.aws.example.com',
    crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('12345678012')
});

// In this account
const subZone = new route53.PublicHostedZone(this, 'SubZone', {
    zoneName: domainName
});

new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {
    delegatedZone: subZone,
    parentHostedZoneId: parentZone.hostedZoneId,
    delegationRole: parentZone.crossAccountDelegationRole
});

This sounds exactly what I need, but I fear the AWS documentation is out of date here - crossAccountDelegationRole is rendered in red in my IDE, and it crashes due to being undefined when cdk diff is run.

Update 3

I am assuming the property mentioned above is a typo or a reference to an outdated version of the library. I am now doing this:

new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {
    delegatedZone: subZone,
    parentHostedZoneId: parentZone.hostedZoneId,
    delegationRole: parentZone.crossAccountZoneDelegationRole
});

This feel tantalisingly close, but it crashes:

Failed to create resource. AccessDenied: User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/CustomCrossAccountZoneDelegationC-xxx is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::yyyyyyyyyyyy:role/HostedZoneCrossAccountZoneDelegat-yyy

I wonder if I need to declare the IAM creds for the other account? I do have them.

I am not sure why permissions are needed, anyway - could it not just read the NS records in the other account and copy them to the local account? The DNS in the other account is public anyway.

I am willing to research fixing the IAM error, but this doesn't half feel like shooting in the dark. I might spend another two hours inching towards solving that sub-problem, only to find that the whole thing will fail for another reason.

Update 4

I have created a "Role" in the remote account to give "AmazonRoute53FullAccess" perms to the account that I am targetting for CDK deployment. However I still get the AccessDenied error. I wonder if I need to explicitly invoke that remote role in some fashion; how can I do that?

3 Answers

Was trying to do the same thing today & your post got me 90% of the way there, thanks! I ended up getting it to work with a different IAM principal (Organization) which was ok for my use case.

The crossAccountZoneDelegationPrincipal gives access to accounts hosting subzones, to access your root zone and write delegation (NS) records for the subzones.

For my use case, all the accounts resided within the same organization, so I created my root zone like this ->

const rootZone = new route53.PublicHostedZone(this, 'rootZone', {
      zoneName: `root.zone`,
      crossAccountZoneDelegationPrincipal: new iam.OrganizationPrincipal('o-####')
    });

This sets up an IAM role with the following policy;

    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "route53:ChangeResourceRecordSets",
            "Resource": "arn:aws:route53:::hostedzone/#####",
            "Effect": "Allow"
        }
    ]
}

And the following trust policy;

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "o-####"
        }
      }
    }
  ]
}

Which effectively allows anyone with that OrgID to write records in the root zone.


In my subzones, I run with this;

const subZone = new route53.PublicHostedZone(this, 'SubZone', {
      zoneName: 'sub.root.zone'
    });

const delegationRole = iam.Role.fromRoleArn(this, 'delegationRole', 'arn:aws:iam::###:role/###')

    new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {
      delegatedZone: subZone,
      parentHostedZoneId: '###',
      delegationRole: delegationRole
    });

This ended up creating the delegation records in the root zone, for my subzone. If the organization principal doesn't fit your use case and you still need to grant multiple accounts that authority, try the composite principal https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-iam.CompositePrincipal.html


Also wanted to address the concerns raised in the other answer around it being an anti-pattern & cross-account CDK being hard. This isn't really cross account CDK. This is utilizing a pattern provided by AWS (specifically spinning up a lambda to execute the provisioning of subzone records in a root zone).

Hopefully it works for you!

I have consulted two AWS experts, and they do not favour cross-account operations. One said:

This is an anti-pattern, since it requires permissions to remain even after the stack is deployed. What happens if a cross-account operation has to roll back and that permission is revoked? It would result in the app being stuck in a middle/undefined state (the local part is rolled back, but the remote part cannot be rolled back due to an access violation).

The other advised:

Cross-account CDK is hard.

It is much better to split your stacks into two or more operations, so you can run them independently. This applies nicely to "one off" operations like DNS delegation - realistically you are not going to change the zone delegation for your Stack unless you destroy it, which you are not going to do until you actually don't need it. Thus, there is no reason for the zone information to change for the lifetime of the system.

This also works well where you have an app and a database, and you want the ability to take down your app without destroying the data.

So, this is an answer in the sense that some folks will say "don't do it". However, it looks like AWS has the ability to do it, so answers in that direction are still welcome.

I used the accepted answer to create this GitHub project which implements a proof-of-concept:

This sample project demonstrates how to use the AWS CDK serve static web content using a Cloudfront distribution. The Cloudfront distribution is given a domain name and an HTTPS certificate, even though the Route 53 hosted zone is owned by a different AWS Account (the parent account).

Related