AWS CDKでLambda関数から別アカウントのS3にアクセスする

本記事はAWS CDK Advent Calendar 2021の14日目の記事です。

本記事では、AWS CDKを使って、「あるAWSアカウントにあるLambda関数から、別のAWSアカウントにあるS3バケットにオブジェクトをアップロードする」というユースケースを実現します。

ざっくりいうと、下記のナレッジに記載されているような仕組みを、CDK化してみた、というものになります(なので、出てくるロール名やアカウントIDは同じものを使って説明します)

aws.amazon.com

なお、今月の頭にAWS CDK v2.0.0がリリースされましたが、v1.124.0で動作確認した経験を元にしていますので、その点ご了承ください。

想定シナリオ

ステージング(stg)と本番環境(prd)のそれぞれでとあるワークロードを稼働させており、各環境でLambda関数内で処理を行って収集・加工したデータを、一元的に分析するために、別のアカウント(audit)のS3バケットにアップロードします。
…とまぁ、かなり濁して書いちゃっていますが、要するに、図のようなことをやりたいと想定してもらえればと思います。

stg

account (111111111111 = stg)         account (222222222222 = audit)
 Lambda(my-lambda)             =>   S3 Bucket (cdk-advent-calendar-21-14)

prd

account (333333333333 = prd)         account (222222222222 = audit)
 Lambda(my-lambda)             =>   S3 Bucket (cdk-advent-calendar-21-14)

コードをどう管理するか?

上の図の通り、111111111111(stg)と333333333333(prd)はコードが共通利用できそうですが、それらと222222222222(audit)は扱うAWSリソース群がまったく別物なので、必然的に別のスタック定義をする必要があり、すなわち2つのスタック定義が必要です。

そのために、単一のCDKプロジェクトで実現する方法と、それぞれ別々のCDKプロジェクトで実現する方法の2つがありますが、今回は前者を選択しました。
結果的に、ロールのARN(やその一部となるAWSアカウントID)を相互で橋渡しする必要があり、単一プロジェクトの方が管理上の収まりがよかったためです。
なお、その代償(?)として、デプロイを行う際にオプションや引数を間違えて間違ったスタックをデプロイしてしまう事故のリスクが生まれますが、デプロイに関しては個人のローカル環境から行うことは禁止し、Jenkins, GNU Makeによって正しくない組み合わせでの利用ができないように担保することで、このリスクを回避しています。

111111111111(stg) & 333333333333(prd) 側のスタック定義

Lambda関数を実行する側のスタック定義は以下のようにしました。

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as iam from '@aws-cdk/aws-iam';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';

export class HogeStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // #1
    const auditAccount = { accountId: '222222222222' }; // this.node.tryGetContext('auditAccount');

    // #2
    const role = new iam.Role(this, 'MyLambdaExecutionRole', {
      roleName: 'my-lambda-execution-role',
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')
      ]
    });

    // #3
    role.addToPoicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resource: [`arn:aws:iam::${auditAccount.accountId}:role/role-on-source-account`],
        actions: ['sts:AssumeRole']
      });
    );

    const fn = new NodejsFunction(this, 'MyLambdaFunction', {
      entry: 'src/index.ts',
      handler: 'fooHandler',
      functionName: 'my-lambda',
      environment: {
        //  必要に応じて環境変数
        ASSUME_ROLE_ARN: `arn:aws:iam::${auditAccount.accountId}:role/role-on-source-account`
      },
      runtime: lambda.Runtime.NODEJS_14_X,
      memorySize: 512,
      role: role // #4
    });
  }
}

ポイントは以下の通りです。

  • #1. 別アカウントの情報を取得します。今回はべた書きしていますが、実際の例ではコメントアウト部分のように、各アカウントのID等のパラメータは cdk.json で共通定義し、Contextの仕組みを使って値を参照します。
  • #2. Lambda関数の実行ロールをスクラッチで定義します。通常、CDKを使って作成されるLambda関数の実行ロールはCDKが気を利かせて作成してくれるため、必要な権限だけを後付けするだけでよいのですが、ロール名が HogeStack-myLambda-12345678ABCD のように末尾にオマケがついてしまいます。これだと、もう一方のスタックから参照させる場合に都合が悪かったので、ロール名決め打ちの実行ロールを使うようにしました。
  • #3. 別のアカウント(222222222222)でIAMロールを引き受けることを許可する権限を追加で与えます。
  • #4. 作成した実行ロールをLambda関数に割り当てます。

222222222222(audit)側のスタック定義

S3を配置する側のスタック定義は以下のようにしました。

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as iam from '@aws-cdk/aws-iam';
import * as s3from '@aws-cdk/aws-s3';

export class FugaStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const bucket = new s3.Bucket(this, 'Cdk2014Bucket', {
      bucketName: 'cdk-advent-calendar-21-14',
      removalPolicy: cdk.RemovalPolicy.RETAIN
    });

    // #5
    const role = new iam.Role(this, 'RoleOnSourceAccount', {
      roleName: 'role-on-source-account',
      assumedBy: new iam.CompositePrincipal(
        new iam.ArnPrincipal('arn:aws:iam::111111111111:role/my-lambda-execution-role'),
        new iam.ArnPrincipal('arn:aws:iam::333333333333:role/my-lambda-execution-role')
      )
    });

    // #6
    bucket.grantReadWrite(role);
  }
}

ポイントは以下の通りです。

  • #5. 111111111111(stg), 333333333333(prd)側のLambda関数がロールを引き受けることを許可するようにして、ロールを作成します。ここでの assumedBy は、マネコンでの信頼関係を指すといった方がピンとくるかもしれません。今回、信頼したい別アカウントが2つ(複数)だったので、 CompositePrincipal というものを使いました。引数は可変長なので、3つ以上のアカウントを信頼したい場合にも対応できます(はず)。ただ、この部分については、アカウントごとに別のロールを定義してもよいかもしれません。
  • #6. ロールにS3バケットへの読み書き権限を付与します。

Lambda関数

冒頭に紹介したナレッジ記事ではLambda関数のランタイムはPythonですが、プロジェクトの事情でLambda関数はNode.js(TypeScript)で実装しています。

要点だけですが、以下のようなコードになります。

export const fooHandler = async (event: any, context: any): Promise<any> => {
  const sts = new AWS.STS({ apiVersion: '2011-06-15' });

  const assumeRoleRequest: AWS.STS.AssumeRoleRequest = {
    RoleArn: process.env.ASSUME_ROLE_ARN,
    RoleSessionName: new Date().getTime().toString()
  };

  const assumeRoleResponse = await sts.assumeRole(assumeRoleRequest).promise();
  const credentials = assumeRoleResponse.Credentials;

  const s3 = new AWS.S3({
    apiVersion: '2006-03-01',
    accessKeyId: credentials.AccessKeyId,
    secretAccessKey: credentials.SecretAccessKey,
    sessionToken: credentials.SessionToken
  });

  await s3.putObject({
    Bucket: '.....',
    Key: '.....',
    Body: '.....'
  });
};

AWS STS AssumeRole API を呼び出して、サービスクライアントの作成に使用できる認証情報を取得することで、S3にアクセスするための許可を引き受けたことになって、別アカウントへのS3アカウントができる、という寸法ですね。

デプロイ

最後はデプロイですが、ここまでできていればあと一息なので簡単に。
複数スタックが定義されているため、どのスタックをデプロイするか、明示的に指定してデプロイします。

$ AWS_PROFILE=stg npx cdk deploy HogeStack
$ AWS_PROFILE=prd npx cdk deploy HogeStack
$ AWS_PROFILE=audit npx cdk deploy FugaStack

おわりに

というわけで、AWS CDKでのクロスアカウントアクセスについてでした。

改めて整理しましたが、だいぶ力技が多いなと思ってしまいました…。
実際に開発している最中は、 cdk synth で出力されるCFnテンプレートと突き合わせながら、四苦八苦した記憶が蘇ります…。
特に、今回紹介したようなスタックは、頻繁にデプロイする必要性が薄いため、たまにデプロイした時にあまりに力技・黒魔術が多いと、動かすの怖くなってしまうんですよね。

余談ですが、今回みたいなユースケースが今後どれくらい発生するかはわかりませんが、CloudTrailなどの証跡ログは、監査用のアカウント/S3バケットに集約することがセキュリティの上での一つのプラクティスとしても挙げられており、「別アカウントのS3にデータを集める」という選択肢は、多く用意しておいて損はないかなと思ったりはします。
まぁ、この場合は、このあたりの仕組みを使えば、今回紹介するような方法は不適合かもしれませんが…。

もし間違いやツッコミがあれば、コメントか、 Twitter: @tq_jappy までお願いします。