Control Towerでアカウント作成時に管理アカウントからStep Functions(Lambda)を実行する

2022.02.28

はじめに

AWS Control Towerを利用してマルチアカウントを管理する際、メンバーアカウントへの初期セットアップとしてCfCTやStackSetsを用いてリソースを展開するケースがあります。

これらはCloudFormationをアカウントが発行された際に自動で展開できるので非常に便利ではあるのですが、CloudFormationが未対応のリソースや既存設定の変更(パスワードポリシーの変更等)についてはLambdaのカスタムリソースを活用する必要があります。

しかし、この場合には各メンバーアカウントにLambdaを作成する必要があるため、ランタイムの管理などが少し煩雑になる懸念があります。

そこで今回は、各アカウントにLambdaを作成しないで済むように、管理アカウント側にセットアップ用のStep FunctionsとLambdaを作成し、新規作成されたアカウントへのアクセスを試してみました。

StepFunctionsからLambdaを実行しているのは、複数のLambda処理を入れたくなった時に順序性を持たせることを想定しているためです。

構成

今回作成したのは以下の構成です。

アカウント作成時にControl TowerからCreateManagedAccountというイベントが発行されるので、それをEventBridgeで取得しています。EventBridgeのターゲットにStep Functionsを指定して、Lambda内でメンバーアカウントにあるAWSControlTowerExecutionへAssumeRoleします。今回は実行できることを確認するため、sts:get_caller_identityを実行してみます。

AWSControlTowerExecution

このロールはControl Towerが個々のアカウントを管理できるように、登録時に作成されているロールのため個別に作成する必要はありません。Control Tower配下のアカウントには必ず作成されており、以下のように管理アカウントからAssumeRoleが許可されています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<管理アカウントID>:root"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

このロールはAdministratorAccess権限を持っているため、管理アカウントからAssumeRoleすることで自由にAPIを実行できます。

より詳細に知りたい方は以下ドキュメントをご参照下さい。

AWS Control Tower がロールと連携してアカウントを作成および管理する方法 - AWS Control Tower

Lambdaロール(account-setup-lambda-role)

Lambdaにアタッチするロールは単純にAssumeRoleとログ出力を行う権限のみをつけます。

アタッチするポリシーは以下の通りです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "sts:AssumeRole"
            ],
            "Resource": "*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*",
            "Effect": "Allow"
        }
    ]
}

Lambdaで実行するコード

実行するコードは、ほぼ同じことを監査アカウントから実施していたブログがあったのでお借りしました。(川原さんに感謝)

ランタイムは Python 3.8 で作成しました。

import boto3
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def _sts_client(target_account_id):
    logger.info('[START] _sts_client')
    sts_connection = boto3.client('sts')
    try:
        # Assume Role
        role_arn = "arn:aws:iam::%s:role/AWSControlTowerExecution" % target_account_id
        role_session_name = "CROSS_ACCOUNT_ACCESS_FROM_CTAUDIT"
        logger.info("- RoleArn=%s" % role_arn)
        logger.info("- RoleSessionName=%s" % role_session_name)
        target = sts_connection.assume_role(
            RoleArn=role_arn,
            RoleSessionName=role_session_name,
        )
    except Exception as e:
        logger.error(e)
        exit()
    else:
        client = boto3.client(
            'sts',
            aws_access_key_id=target['Credentials']['AccessKeyId'],
            aws_secret_access_key=target['Credentials']['SecretAccessKey'],
            aws_session_token=target['Credentials']['SessionToken']
        )
        logger.info('[END] _sts_client')
        return client

def check(sts_client):
    logger.info('[START] check')
    try:
        resp = sts_client.get_caller_identity()
        logger.info("- Account=%s" % resp['Account'])
        logger.info("- Arn=%s" % resp['Arn'])
    except Exception as e:
        logger.error(e)
        exit()
    else:
        logger.info('[END] check')
        return {"status": "success"}

def lambda_handler(event, context):
    logger.info('[START] lambda_handler')
    member_account_id=event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["account"]["accountId"]
    # Get Client
    sts_client = _sts_client(member_account_id)
    # Run remediation(check)
    logger.info('# running check')
    results = check(sts_client)
    # End
    logger.info('[END] lambda_handler')
    return results

メンバーアカウントのIDを取得する部分とAssumeRole先のロールを変更しています。

Lambdaへ送られてくるイベントCreateManagedAccountの詳細については以下をご参照ください。

AWS Control Tower でのライフサイクルイベント - AWS Control Tower

Step Functions State Machine

ただLambdaを実行するだけのステートマシンを作成します。

定義情報は以下の通りです。

{
  "Comment": "A description of my state machine",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "test-account-setup-Lambda:$LATEST"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "End": true
    }
  }
}

また今回の場合、ステートマシンにアタッチするIAM Role にはLambdaを呼び出す権限が必要です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": "<呼び出したい Lambda の ARN>:*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": "<呼び出したい Lambda の ARN>",
            "Effect": "Allow"
        }
    ]
}

EventBridge Rule

最後にControl Towerからのライフサイクルイベントを取得するEventBridge Ruleを作成します。ここは以下のブログを大いに参考にさせて頂きました。(大前さんに感謝)

CreateManagedAccountを取得できればいいので、以下のイベントパターンを定義します。

{
  "source": ["aws.controltower"],
  "detail-type": ["AWS Service Event via CloudTrail"],
  "detail": {
    "eventName": ["CreateManagedAccount"]
  }
}

コンソールからサービスをControl Towerで指定すると、CreateManagedAccountのイベントパターンを自動でも生成できます。

ターゲットには先ほど作成したStep Functionsのステートマシンを指定する必要があります。

既存のロールを使用していますが、ステートマシンを実行する権限を付与したロールを選択しています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "states:StartExecution"
            ],
            "Resource": [
                ""
            ],
            "Effect": "Allow"
        }
    ]
}

やってみる

それでは実際にアカウントを発行して動作するか確認してみます。Account Factory からアカウントを発行して登録されると、作成したStep Functions が実行されたことを確認できます。

実行されたLambdaのログを確認してみると、実行先のアカウントIDとAWSControlTowerExecutionに AssumeRole できていることが確認できました。

おわりに

管理アカウントのLambdaからメンバーアカウント上のAWSControlTowerExecutionに AssumeRole して動かしてみました。今回はアクセスできるかを確認するため、sts:get_caller_identityのみ実行しただけですが、コードさえ書けばいいのでセットアップの幅は非常に広いです。

もしControl Tower環境でCloudFormationでは実現しにくいものセットアップ処理が必要になった際は、Step Functionsを自由に編集して活用頂ければ嬉しいです。

CloudFormationテンプレート

今回の構成を一発で作成できるテンプレートを置いておきます。実際にStep FunctionsとLambdaを作り込む場合は SAM や Serverless Framework を使うといい感じに管理できるかと思います。あくまでご参考程度にご利用ください。

AWSTemplateFormatVersion: "2010-09-09"
Description: "Cross-account setup template"
Parameters:
  # 作成リソースに付与する接頭語
  Prefix:
    Description: "Prefix of each resource"
    Type: "String"
    Default: "test"

Resources:
  # Step Functions StateMachine
  StateMachine:
    Type: "AWS::StepFunctions::StateMachine"
    Properties:
      StateMachineName: !Sub "${Prefix}-account-setup-statemachine"
      DefinitionString: !Sub |
        {
          "Comment": "A description of my state machine",
          "StartAt": "Lambda Invoke",
          "States": {
            "Lambda Invoke": {
              "Type": "Task",
              "Resource": "arn:aws:states:::lambda:invoke",
              "OutputPath": "$.Payload",
              "Parameters": {
                "Payload.$": "$",
                "FunctionName": "${LambdaFunction}:$LATEST"
              },
              "Retry": [
                {
                  "ErrorEquals": [
                    "Lambda.ServiceException",
                    "Lambda.AWSLambdaException",
                    "Lambda.SdkClientException"
                  ],
                  "IntervalSeconds": 2,
                  "MaxAttempts": 6,
                  "BackoffRate": 2
                }
              ],
              "End": true
            }
          }
        }
      RoleArn: !GetAtt StateMachineRole.Arn
      Tags:
        - Key: "Name"
          Value: !Sub "${Prefix}-account-setup-statemachine"
  # Lambda Function
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${Prefix}-account-setup-Lambda"
      Role: !GetAtt "LambdaExecutionRole.Arn"
      Runtime: "python3.8"
      Handler: index.lambda_handler
      Timeout: "180"
      Code:
        ZipFile: |
          import boto3
          import logging

          logger = logging.getLogger()
          logger.setLevel(logging.INFO)

          def _sts_client(target_account_id):
              logger.info('[START] _sts_client')
              sts_connection = boto3.client('sts')
              try:
                  # Assume Role
                  role_arn = "arn:aws:iam::%s:role/AWSControlTowerExecution" % target_account_id
                  role_session_name = "CROSS_ACCOUNT_ACCESS_FROM_CTAUDIT"
                  logger.info("- RoleArn=%s" % role_arn)
                  logger.info("- RoleSessionName=%s" % role_session_name)
                  target = sts_connection.assume_role(
                      RoleArn=role_arn,
                      RoleSessionName=role_session_name,
                  )
              except Exception as e:
                  logger.error(e)
                  exit()
              else:
                  client = boto3.client(
                      'sts',
                      aws_access_key_id=target['Credentials']['AccessKeyId'],
                      aws_secret_access_key=target['Credentials']['SecretAccessKey'],
                      aws_session_token=target['Credentials']['SessionToken']
                  )
                  logger.info('[END] _sts_client')
                  return client

          def check(sts_client):
              logger.info('[START] check')
              try:
                  resp = sts_client.get_caller_identity()
                  logger.info("- Account=%s" % resp['Account'])
                  logger.info("- Arn=%s" % resp['Arn'])
              except Exception as e:
                  logger.error(e)
                  exit()
              else:
                  logger.info('[END] check')
                  return {"status": "success"}

          def lambda_handler(event, context):
              logger.info('[START] lambda_handler')
              member_account_id=event["detail"]["serviceEventDetails"]["createManagedAccountStatus"]["account"]["accountId"]
              # Get Client
              sts_client = _sts_client(member_account_id)
              # Run remediation(check)
              logger.info('# running check')
              results = check(sts_client)
              # End
              logger.info('[END] lambda_handler')
              return results
  ## IAM Role for Lambda
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${Prefix}-account-setup-lambda-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/service-role/"
      Policies:
        - PolicyName: !Sub "${Prefix}-account-setup-lambda-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "sts:AssumeRole"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"

  ## IAM Role for StateMachine
  StateMachineRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${Prefix}-account-setup-statemachine-role"
      Path: "/service-role/"
      AssumeRolePolicyDocument:
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "states.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Policies:
        - PolicyName: !Sub "${Prefix}-account-setup-statemachine-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "lambda:InvokeFunction"
                Resource: !Join
                  - ""
                  - - !GetAtt LambdaFunction.Arn
                    - ":*"
              - Effect: Allow
                Action:
                  - "lambda:InvokeFunction"
                Resource: !GetAtt "LambdaFunction.Arn"

  # EventRule for CreateManagedAccount
  EventRule:
    Type: "AWS::Events::Rule"
    Properties:
      Description: "Send CreateManagedAccount event to StateMachine"
      EventPattern: |-
        {
          "source": ["aws.controltower"],
          "detail-type": ["AWS Service Event via CloudTrail"],
          "detail": {
            "eventName": ["CreateManagedAccount"]
          }
        }
      Name: !Sub "${Prefix}-catch-CreateManagedAccount"
      State: "ENABLED"
      Targets:
        - Arn: !Ref StateMachine
          Id: !Sub "${Prefix}-target-account-setup-statemachine"
          RoleArn: !GetAtt EventBridgeRole.Arn
  ## IAM Role for EventBridge
  EventBridgeRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${Prefix}-catch-CreateManagedAccount-role"
      Path: "/service-role/"
      AssumeRolePolicyDocument:
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "events.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
        - !Ref InvokeStepFunctionsPolicy
  ## IAM Policy to invoke Step Functions
  InvokeStepFunctionsPolicy:
    Type: "AWS::IAM::ManagedPolicy"
    Properties:
      ManagedPolicyName: !Sub "${Prefix}-invoke-step-functions-from-eventbridge-policy"
      Path: "/service-role/"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Resource:
              - !Ref StateMachine
            Effect: "Allow"
            Action:
              - "states:StartExecution"

参考