Calling a private API Gateway in one account through a VPC Endpoint in another account with least privilege

The Setup

You are in AWS. You cannot let any traffic from the internet, or to the internet. You want a hub-and-spoke architecture with applications connecting through the hub to access API Gateways on the spokes. The VPC Endpoint that facilitates that connection needs to be locked down with a policy in a least privilege manner, allowing only specific roles through the VPC Endpoint.

Desired state

The example endpoint policies only show a user principal, not a role principal, but this is not impossible.

Here is an example policy we want to use:

{
    "Statement": [
        {
            "Principal": {
                "AWS": [
                    "arn:aws:iam::123412341234:role/MyRole"
                ]
            },
            "Action": [
                "execute-api:Invoke"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:execute-api:us-east-1:123412341234:a1b2c3d4e5/*"
            ]
        }
    ]
}

So only MyRole is allowed across the VPC Endpoint, all other requests will receive a 403 response from the VPC Endpoint.

If you use the AWS CLI to invoke-test-method, passing in the API Gateway id and resource, and a role you have configured, it will work. However this avoids the VPC Endpoint entirely. It just calls the API Gateway in the other account directly, using the apigateway service instead of the execute-api service.

What we want is to make an HTTP request to the private DNS name provided by the VPC and traverse the VPC Endpoint to the private API Gateway.

How to call the API with a role

What we need to do is sign the request using the role that is allowed. AWS API uses a special form of signatures they call Signature Version 4. It's a bit complicated, but nothing we can't whip up in a crude bash script.

Components

The components for this ritual are

Once you have all three (perhaps from a aws sts assume-role call), you can stir them together using a series of openssl hashing functions.

After you have generated the signatures, you can finally make your curl request against the API:

curl -H "x-amz-date: ${date}" -H "x-amz-security-token: ${token}" -H "Authorization: ${authorization}" "https://${apiid}.execute-api.${region}.amazonaws.com${apiresource}"

Here is the script, modify it to your use case.

#!/bin/bash

apiid="$1"
apiresource="$2"

rolejson=`aws sts assume-role --role-arn "<role>" --role-session-name "<sessionname>"`
aws_access_key_id=`echo -en ${rolejson} | grep -Po '"AccessKeyId":.*' | cut -d':' -f2 | sed 's/[^0-9A-Z]*//g'`
aws_secret_access_key=`echo -en ${rolejson} | grep -Po '"SecretAccessKey": "[^\",]*' | cut -d':' -f2 | sed 's/[^0-9A-Za-z/+=]*//g'`
token=`echo -en ${rolejson} | grep -Po '"SessionToken": "[^\",]*' | cut -d':' -f2 | sed 's/[^0-9A-Za-z/+=]*//g'`

# To use ec2 instance profile:
# instance_profile=`curl http://169.254.169.154/latest/meta-data/iam/security-credentials/`
# aws_access_key_id=`curl http://169.254.169.154/latest/meta-data/iam/security-credentials/${instance_profile} | grep AccessKeyid | cut -d':' -f2 | sed 's/[^0-9A-Z]*//g'`
# aws_secret_access_key=`curl http://169.254.169.154/latest/meta-data/iam/security-credentials/${instance_profile} | grep SecretAccessKey | cut -d':' -f2 | sed 's/[^0-9A-Za-z/+=]*//g'`
# token=`curl http://169.254.169.154/latest/meta-data/iam/security-credentials//${instance_profile} | sed -n '/Token/{p;}' | cut -f4 -d'"'`

sdate=$(date + '%Y%m%dT%H%M%SZ')

payload=$(echo -en "" | openssl dgst -sha256 | awk '{print $2}')
canonical="GET\n${apiresource}\n\nhost:${apiid}.execute-api.us-west-2.amazonaws.com\nx-amz-date:${sdate}\nx-amz-security-token:${token}\n\naccept;host;x-amz-date;x-amz-security-token\n${payload}"
canonicalhash=$(echo -en ${canonical} | openssl dgst -sha256 | awk '${print $2}')

dateshort=$(date '+%Y%m%d')
stringtosign="AWS4-HMAC-SHA256\n${sdate}\n${dateshort}/us-west-2/execute-api/aws4-request\n${canonicalhash}"

function hmac_sha256 {
    key="$1"
    data="$2"
    echo -n "$data" | openssl dgst -sha256 -mac HMAC -macopt "$key" | sed 's/^.* //'
}

dateKey=$(hmac_sha256 key:"AWS4$aws_secret_access_key" $dateshort)
dateRegionKey=$(hmac_sha256 hexkey:$dateKey "us-west-2")
dateRegionServiceKey=$(hmac_sha256 hexkey:$dateRegionKey "execute-api")
signaturekey=$(hmac_sha256 hexkey:$dateRegionServiceKey "aws4_request")

signature=$(echo -en $stringtosign | openssl dgst -sha256 -mac HMAC -macopt hexkey:$signaturekey | awk -F ' ' '{print $2}')

authorization="AWS4-HMAC-SHA256 Credential=${aws_access_key_id}/${dateshort}/us-west-2/execute-api/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=${signature}"

curl -H "x-amz-date: ${sdate}" -H "x-amz-security-token: ${token}" -H "Authorization: ${authorization}" "https://${apiid}.execute-api.us-west-2.amazonaws.com${apiresource}"

Could there be improvements made to this script? No, it is perfect the way it is.

This took me a few days to figure out, so I hope this can save someone else some time.

The shoulders of giants

I am not a bash wizard, here are some people I copied from:

These tools might help you more than I can.