amazon-web-servicesaws-lambdaamazon-route53aws-cdk

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


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?


Solution

  • 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!