Create a Cross-Account IAM Authorized APIGateway with CDK

and connect to it using lambdas

ยท

6 min read

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

Obligatory link to code: 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.

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 we'll create the lambda that the API gateway will invoke. Here this is just a dummy "hello world" lambda:

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

and the corresponding lambda code:

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

Back in the stack file 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 AccountPrincipals here with access to your own accounts.

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...

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.

// 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):

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:

{"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 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. 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.

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:

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, and add the code to return the response from the "with" request.

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 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 for this).

In the meantime, we can add a construct-commands.json file to our project with the same commands the lambda has (but with the correct key):

{
  "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:

$ 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:

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

ย