AWS IoTの証明書を使って別アカウントのLambdaを叩く処理ができたがこれでいいのかモヤッとする。

2022.07.14

せーのでございます。

現在作っているシステムでIoTのエッジデバイスからAWSのLambdaを叩く、というものがあるのですが

  • 設計ポリシーとしてエッジ上にAPI KEYを持ってはいけない。IoT証明書のみOK
  • IoTがつながっているAWSアカウントは共通
  • 対象となるLambdaは別アカウントにあり、PRD、STG、DEV環境と3つに分かれており、エッジから環境を切り替える

という制約があります。

となると必要な処理はまず

  • AWS IoTの証明書からAWSリソースを叩く

となります。これは「Authorizing Direct Calls」という機能があり、AWS IoTをプロバイダとして一時クレデンシャルを引いてくることができます。

次に必要なのは

  • AWSリソースからクロスアカウントで別のリソースを叩く

ですね。これもresource baseとかidentity baseとか色々あったりします。

AWSはポリシーがとても細かく分かれていて、書く場所や書き方(何がResourceで何がPrincipalなの?とか)によって動き方が随分変わるので注意が必要です。

そしてこの2つをあわせて

  • AWS IoTの証明書にポリシーをつける
  • ポリシーにはロールエイリアスをプロバイダとして認証を与える許可(iot:AssumeRoleWithCertificate)をつける
  • ロールエイリアスの元となるIAM Roleをつくる
  • IAM Roleには別アカウントのIAM RoleをAssume Roleする許可をつける
  • 別アカウントのIAM Roleには「信頼するエンティティ」にAWS IoTのあるアカウントをつける
  • 別アカウントのIAM Roleの許可ポリシーとしてLambdaをInvokeする許可をつける

としました。

これでイメージ的には

こうなり、Lambdaが叩ける、はずでした。

実際にこんなコードを書き

import requests
import json
import boto3
from boto3.session import Session

endpoint = "https://xxxxxxxxxxxxxx.credentials.iot.ap-northeast-1.amazonaws.com"
role_alias = 'ADCCrossAccountTestAlias'

result = requests.get(
    f'{endpoint}/role-aliases/{role_alias}/credentials',                                                                                                                                                        
    cert=(f'./cert.pem.crt', f'./private.pem.key')
)

if(result.status_code != 200):
    exit()

body = json.loads(result.text)
access_key = body["credentials"]["accessKeyId"]
secret_key = body["credentials"]["secretAccessKey"]
token = body["credentials"]["sessionToken"]

session = Session(aws_access_key_id=access_key,
                aws_secret_access_key=secret_key,
                aws_session_token=token)

lambdaclient = session.client('lambda')


response = lambdaclient.invoke(
  FunctionName='arn:aws:lambda:ap-northeast-1:111111111111:function:ADCCrossAccountTest',
  InvocationType='RequestResponse',
  LogType='Tail',
  Payload='{\"test\":\"test1\"}'
)

output = response['Payload'].read().decode('utf-8')

print(output)

流してみた所、

botocore.exceptions.ClientError: An error occurred (AccessDeniedException) when calling the Invoke operation: Cross-account log access is not allowed

というエラーになり、きれいに叩くことができませんでした。

そこで色々調べたところ「一度STSで一時クレデンシャルを取得し、そこからリソースを叩くとうまくいく」というような記事が出ていました。

既に一回証明書をプロバイダとして一時クレデンシャルを引いているので、そこから更にSTSで一時クレデンシャルを引くのは冗長な気がしてとても気が引けましたが、結果的にこれでうまくいきました。

イメージ

実装

アカウント000000000000にあるAWS IoTから証明書を引いてきてアカウント111111111111にあるLambdaを叩く、とします。

IAM Roleを作る

まずはAWS IoTのある000000000000から。 IAM Roleを作ります。 AWS IoTの一時クレデンシャル作成で使われるのでクレデンシャルからのアクセスを許可しておきます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "credentials.iot.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

許可ポリシーは111111111111からのAssume Roleになるのですが、111111111111のRoleはまだ作っていないので今は空のままで良いです。
そのまま作成ボタンを押し、IAM Roleを作ります。

できたIAM Roleを元にAWS IoTからロールエイリアスを作ってIoT Policyにアタッチします。ここらへんの細かい処理は別エントリを参考にしてください。

Lambdaを作る

111111111111上にLambdaを作ります。サンプルなので適当に。

import json

def lambda_handler(event, context):
    # TODO implement
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda cross account test!')
    }

Lambdaの実行ロールを開き、「信頼関係」と「許可」をそれぞれ追加します。

信頼関係。ここには呼び出し元となる000000000000からのAssume Roleを許可してください。
元々LambdaのサービスからのAssume Roleも入っているので、別のステートメントで設定します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::000000000000:root"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

許可ポリシーには自らのLambdaの実行許可が入っているので、追加でLambdaのInvoke処理も許可します。このロールの権限をエッジがAssume Roleで引き受けて実行するので、ここにLambdaのInvoke権限が必要になります。

LambdaのInvokeはAWSにて「AWSLambdaRole」というポリシーが用意されているので、そのまま使います。

これで準備はOKです。

コーディング

それではエッジ側のコードを書きます。

import requests
import json
import boto3
from boto3.session import Session

endpoint = "https://xxxxxxxxxxxxx.credentials.iot.ap-northeast-1.amazonaws.com"
role_alias = 'ADCCrossAccountTestAlias'

# 証明書を元に一時クレデンシャルを引く
result = requests.get(
    f'{endpoint}/role-aliases/{role_alias}/credentials',                                                                                                                                                        
    cert=(f'./cert.pem.crt', f'./private.pem.key')
)

if(result.status_code != 200):
    exit()

body = json.loads(result.text)
access_key = body["credentials"]["accessKeyId"]
secret_key = body["credentials"]["secretAccessKey"]
token = body["credentials"]["sessionToken"]

session1 = Session(aws_access_key_id=access_key,
                aws_secret_access_key=secret_key,
                aws_session_token=token)

# 一時クレデンシャルを元にSTSにアクセス
sts_connection = session1.client('sts')

# STSから111111111111のIAM RoleをAssume Roleする
acct_b = sts_connection.assume_role(
        RoleArn="arn:aws:iam::111111111111:role/service-role/ADCCrossAccountTest-role-3omc0uko",
        RoleSessionName="cross_acct_lambda"
    )

# 一時クレデンシャルを再び取得
ACCESS_KEY = acct_b['Credentials']['AccessKeyId']
SECRET_KEY = acct_b['Credentials']['SecretAccessKey']
SESSION_TOKEN = acct_b['Credentials']['SessionToken']


client = boto3.client(
        'lambda',
        aws_access_key_id=ACCESS_KEY,
        aws_secret_access_key=SECRET_KEY,
        aws_session_token=SESSION_TOKEN,
    )

# 再び取得した一時クレデンシャルでLambdaを叩く
response = client.invoke(
  FunctionName='arn:aws:lambda:ap-northeast-1:111111111111:function:ADCCrossAccountTest',
  InvocationType='RequestResponse',
  LogType='Tail',
  Payload='{\"test\":\"test1\"}'
)

output = response['Payload'].read().decode('utf-8')

print(output)

結果は

{"statusCode": 200, "body": "\"Hello from Lambda cross account test!\""}

となり、きちんと実行できました。

まとめ

ということで、IoT証明書を使って別アカウントのLambdaを叩くことができました。
Authorizing Direct CallsとAssume Roleによるクロスアカウントアクセスのあわせ技なわけですが、一時クレデンシャルを2回引いてくるところがちょっと気持ち悪いですね。
もっとスマートな方法を御存知の方がいればぜひツイッターででも教えて下さい。