Create a Cross-Account IAM Authorized APIGateway with CDK
Tags: aws, serverless, lambda, apigateway
Published: 2022-06-09 17:32:36

I have a use case where I need a lambda in AWS account A to privately request information from an APIGateway in AWS account B.  Normally we do this using Auth0 Machine-to-Machine (m2m) JWTs and a lambda authorizer that verifies these using the public JWKS... but that's clunky, requires secrets for signing and all of my architecture is AWS native.  Since these requests are trusted (m2m) I don't need all of the complexity that using Cognito provides... so let's use APIGateway's built-in IAM Authorization with lambdas!  This post will set that up (and it's actually surprisingly easy! 🤩)

We'll create a CDK App with two lambdas and an apigateway... both with the right policies to make cross-account requests.  This example will deploy everything in a single stack, but creating additional lambdas outside of the stack/env is just as easy.


![Simple Architecture Diagram with an lambda making a request to an apigateway which invokes a lambda](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/osre9d7ptsty5z8d8xn9.png)

Obligatory link to code: https://github.com/martzcodes/cdk-iam-apigw

# Creating the CDK App
To get started with the CDK App, we'll initialize it with the following commands.
```bash
npx cdk@2.x init --language typescript
npm i @aws-sdk/signature-v4 @aws-crypto/sha256-js @aws-sdk/protocol-http node-fetch --save
npm i @types/node-fetch esbuild cdk-app-cli --save-dev
```

The `npm i` commands will install dependencies that we'll need for the rest of the post.

Let's get our main architecture up and running and then we'll start making requests.

In the [stack file](https://github.com/martzcodes/cdk-iam-apigw/blob/main/lib/iam-apigateway-lambda-stack.ts) we'll create the lambda that the API gateway will invoke.  Here this is just a dummy "hello world" lambda:

```typescript
const apiFn = new NodejsFunction(this, `iamApiFn`, {
  runtime: Runtime.NODEJS_16_X,
  entry: `${__dirname}/api-lambda.ts`,
});
```

and the corresponding [lambda code](https://github.com/martzcodes/cdk-iam-apigw/blob/main/lib/api-lambda.ts):

```typescript
export const handler = async () => {
  return {
    statusCode: 200,
    body: JSON.stringify({ hello: "world" }),
  };
};
```

Back in the [stack file](https://github.com/martzcodes/cdk-iam-apigw/blob/main/lib/iam-apigateway-lambda-stack.ts) we'll create the policy we need to allow lambda's to invoke the api using IAM.  To enable cross-account access you would add additional `AccountPrincipal`s here with access to your own accounts.

```typescript
const apiPolicy = new PolicyDocument({
  statements: [
    new PolicyStatement({
      actions: ["execute-api:Invoke"],
      principals: [
        new AccountPrincipal(this.account),
        // for cross-account access add other account principals here
      ],
    }),
  ],
});
```

Now we'll create the RestApi, provide the default integration and attach the policy.  We'll also make sure the api's root has a GET method allowed...

```typescript
const api = new RestApi(this, `iam-backed-api`, {
  defaultMethodOptions: {
    authorizationType: AuthorizationType.IAM, // IAM-based authorization
  },
  policy: apiPolicy, // our API policy that allows cross-account access
  defaultIntegration: new LambdaIntegration(apiFn)
});
api.root.addMethod("GET");
```

Finally, let's make the "requestor" lambda.  This is what will make an http request to the apigateway.

```typescript
// This could be a construct!
const requestorFn = new NodejsFunction(this, `requestorFn`, {
  runtime: Runtime.NODEJS_16_X,
  entry: `${__dirname}/requestor.ts`,
});
requestorFn.addEnvironment("IAM_API", api.url);
requestorFn.addToRolePolicy(
  new PolicyStatement({
    effect: Effect.ALLOW,
    actions: ["execute-api:Invoke"],
    resources: [api.arnForExecuteApi()],
  })
);
```

If you do this frequently you could make this a construct!

For the "requestor" we'll initially set this up by making a simple http request without the signed iam authorization header (which won't work... spoilers):

```typescript
import { HttpRequest } from "@aws-sdk/protocol-http";
import fetch from "node-fetch";
import { URL } from "url"

export const handler = async (): Promise<any> => {
    const url = new URL(`${process.env.IAM_API}`);
    const request = new HttpRequest({
        hostname: url.host,
        method: "GET",
        headers: {
            host: url.host,
        },
        path: url.pathname,
    });
    const output: Record<string, any> = {
        without: {},
        with: {},
    };
    try {
        const withoutRes = await fetch(url.href, request);
        const withoutJson = await withoutRes.json();
        output.without = withoutJson;
    } catch (e) {
        output.without = e;
    }
    return output;
}
```

The output we're returning in this code is just to showcase the result from sending "with" the iam authorization header and "without" it.

At this point you could `npx cdk deploy` the project and invoke the lambda using the AWS Console (🤢... we'll fix that later) ... what you'd see in the response is:

```json
{"without":{"message":"Missing Authentication Token"},"with":{}}{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
```

That's because even though we provided the correct policies to the lambda, the lambda still needs to do an [AWS version 4 signed authorization header](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) as part of the http request to APIGateway.  This is how the APIGateway checks the IAM authorization.

# Signing the Lambda requests

Let's create the [code to sign the requests](https://github.com/martzcodes/cdk-iam-apigw/blob/main/lib/sign-request.ts).  ***If you're using an inner source model you could put a method like this in a library and share it between projects.***

First we need to initialize aws-sdk version 3's SignatureV4 signer.
```typescript
const v4 = new SignatureV4({
  service: "execute-api",
  region: process.env.AWS_DEFAULT_REGION || "",
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
    sessionToken: process.env.AWS_SESSION_TOKEN || "",
  },
  sha256: Sha256,
});
```

*All of the environment variables used above are reserved / pre-defined in the lambda and have access to whatever permissions are in the lambda's execution role.*

From there we create the signing method itself:

```typescript
export const signRequest = async (
  request: HttpRequest
): Promise<HttpRequest> => {
  const signedRequest = await v4.sign(request);
  return signedRequest;
};
```

This simply takes the HttpRequest we already created, signs it and returns it.

Now we can add the import to our [requestor lambda](https://github.com/martzcodes/cdk-iam-apigw/blob/main/lib/requestor.ts), and add the code to return the response from the "with" request.

```typescript
try {
    const signedRequest = await signRequest(request);
    const withRes = await fetch(url.href, signedRequest);
    const withJson = await withRes.json();
    output.with = withJson;
} catch (e) {
    output.with = e;
}
```

Don't forget to `npx cdk deploy` 😉

# Invoking the Lambda using cdklabs cdk-app-cli

CDKLabs has a new experimental library called [cdk-app-cli](https://github.com/cdklabs/cdk-app-cli) which infers aws cli commands from what's in your stack and lets you interact with them by the resource id you provide (pretty useful).

*What is slightly less useful in this case is the cli looks for vanilla lambda functions, not NodejsFunction (I've opened an [issue on their repo](https://github.com/cdklabs/cdk-app-cli/issues/33) for this).*

In the meantime, we can add a [construct-commands.json](https://github.com/martzcodes/cdk-iam-apigw/blob/main/construct-commands.json) file to our project with the same commands the lambda has (but with the correct key):

```json
{
  "aws-cdk-lib.aws_lambda_nodejs.NodejsFunction": {
    "describe": {
      "exec": "aws lambda get-function --function-name ${PHYSICAL_RESOURCE_ID}"
    },
    "invoke": {
      "exec": "aws lambda invoke --function-name ${PHYSICAL_RESOURCE_ID} /dev/stdout"
    },
    "visit-console": {
      "open": "https://console.aws.amazon.com/lambda/home?region=${AWS_REGION}#/functions/${URL_ENCODED_PHYSICAL_RESOURCE_ID}?tab=code"
    },
    "audit": {
      "exec": "aws cloudtrail lookup-events --lookup-attributes AttributeKey=ResourceName,AttributeValue=${PHYSICAL_RESOURCE_ID}"
    },
    "audit-console": {
      "open": "https://console.aws.amazon.com/cloudtrail/home?region=${AWS_REGION}#/events?ResourceName=${PHYSICAL_RESOURCE_ID}"
    }
  }
}
```

now we can run `npx cdk-app-cli requestorFn` and see the commands available:

```bash
$ npx cdk-app-cli requestorFn    
Refreshing stack metadata...
Commands:
  describe
  invoke
  visit-console
  audit
  audit-console
```

and subsequently run `npx cdk-app-cli requestorFn invoke` to invoke our requestor lambda:

```bash
$ npx cdk-app-cli requestorFn invoke
Refreshing stack metadata...
> aws lambda invoke --function-name IamApigatewayLambdaStack-requestorFn15B8813B-gbQjVZ1VqDge /dev/stdout
{"without":{"message":"Missing Authentication Token"},"with":{"hello":"world"}}{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
Time: 0h:00m:04s
```

Pretty neat! And here we can see that our http request *without* the IAM header continues to have the `Missing Authentication Token` while the request `with` the IAM has the response from the APIGateway (`{"hello":"world"}`)! 🚀

Now we have a nice pattern for making IAM-backed machine-to-machine requests across account boundaries!