[AWS CDK]Control TowerのアカウントセットアップをStepFunctionsから実行するフローを実装してみた

Control Towerのアカウントセットアップでよく使うものをCDKから一発で展開してみたよ
2022.04.19

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Control Tower上でよく初期セットアップとして利用される機能をStepFunctionsからまとめて実行する仕組みをCDKで実装してみました。

リポジトリはこちら。あくまでサンプルなので、ご利用の環境に合わせてカスタマイズしながらご利用ください。

前提

  • Control Tower有効化済み
  • Security HubのOrganizations統合で新規アカウントの自動有効化が設定されている

構成

EventBridgeでControl Towerのアカウント発行イベント(ライフサイクルイベント)を取得して、ターゲットとして指定したステートマシンを実行します。

ステートマシン内はLambdaが複数タスクとして定義されていて、各Lambda内で新規アカウントのロールへAssumeRole、処理を実行しています。

AssumeRoleしているロールや仕組みについてはこちらをご参照ください。

セットアップされる項目

セットアップする項目としては、弊社提供のセキュアアカウントと同等のものを含める形にしました。Control Towerの機能やOrganizations統合で満たせない部分をピックアップしています。

  • デフォルトVPCの削除
    • 指定したリージョンのみ
  • EBSデフォルト暗号化の有効化
    • 指定したリージョンのみ
  • IAMパスワードポリシーの強化
    • パスワードの最小長:8文字
    • 少なくとも1つの大文字が必要:ON
    • 少なくとも1つの小文字が必要:ON
    • 少なくとも1つの数字が必要:ON
    • 少なくとも1つの英数字以外の文字が必要:ON
    • ユーザーにパスワードの変更を許可:ON
  • アカウントレベルのS3パブリックアクセスブロック有効化
  • Security Hubのチューニング
    • 指定したリージョンのみ
    • AWS 基礎セキュリティのベストプラクティス v1.0.0:有効化
      • 一部コントロール無効化
        • IAM.6
        • EC2.8
        • CloudTrail.5
    • CIS AWS Foundations Benchmark v1.2.0:無効化

「CIS AWS Foundations Benchmark v1.2.0」を無効化しているのは、Security HubのOrganizations統合では自動で有効化されるようになっているためです。

ディレクトリ構成とコード

とりあえず実行して動作確認してみたいって人は読み飛ばして頂いて構いません。

ディレクトリは以下のような構成になってます。StepFunctionsのタスクとして定義するLambda周りはlambdaのフォルダにまとめています。

.
└── aws-cdk-ct-account-setup-sfn
    ├── README.md
    ├── bin
    │   └── ct-setup.ts
    ├── cdk.json
    ├── jest.config.js
    ├── lambda
    │   ├── common
    │   │   └── get_session.py
    │   ├── constants
    │   │   └── __init__.py
    │   ├── default_vpc.py
    │   ├── ebs_encryption_by_default.py
    │   ├── password_policy.py
    │   ├── public_access_block.py
    │   └── security_hub.py
    ├── lib
    │   ├── ct-setup-stack.ts
    │   └── naming.ts
    ├── package-lock.json
    ├── package.json
    ├── test
    │   └── ct-setup.test.ts
    └── tsconfig.json

スタックは分けずに作成していて、ステートマシンやLambda、Eventルール等をまとめて以下で定義しています。

./lib/ct-setup-stack.ts

import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  Duration,
  aws_iam as iam,
  aws_lambda as lambda,
  aws_events as events,
  aws_events_targets as targets,
  aws_stepfunctions as sfn,
  aws_stepfunctions_tasks as tasks,
} from 'aws-cdk-lib';
import { Naming } from './naming';

export class CtSetupStack extends Stack {
  private LambdaDefaultRuntime = lambda.Runtime.PYTHON_3_8
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const memorySize = this.node.tryGetContext("lambda").defaultMemorySize
    const timeoutSecond = this.node.tryGetContext("lambda").defaultTimeOut

    const policyName = Naming.of("lambda-role-policy");
    const policy = new iam.ManagedPolicy(this, policyName, {
      managedPolicyName: policyName,
      statements: [
  
        new iam.PolicyStatement({
          actions: [
            "sts:AssumeRole",
          ],
          resources: ["*"]
        }),
  
      ],
    });
    const roleName =  Naming.of("lambda-role");
    const lambdaRole = new iam.Role(this, roleName, {
      roleName: roleName,
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        policy,
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')
      ],
    });

    const DefaultVpcLambdaFunction = new lambda.Function(this, "DefaultVpcLambdaFunction", {
      code: new lambda.AssetCode("lambda"),
      runtime: this.LambdaDefaultRuntime,
      handler: "default_vpc.lambda_handler",
      memorySize: memorySize,
      role: lambdaRole,
      timeout: Duration.seconds(timeoutSecond),
      functionName: Naming.of("delete-default-vpc")
    });

    const EbsEncryptionByDefaultLambdaFunction = new lambda.Function(this, "EbsEncryptionByDefaultLambdaFunction", {
      code: new lambda.AssetCode("lambda"),
      runtime: this.LambdaDefaultRuntime,
      handler: "ebs_encryption_by_default.lambda_handler",
      memorySize: memorySize,
      role: lambdaRole,
      timeout: Duration.seconds(timeoutSecond),
      functionName: Naming.of("enable-ebs-encryption-by-default")
    });
    const PasswordPolicyLambdaFunction = new lambda.Function(this, "PasswordPolicyLambdaFunction", {
      code: new lambda.AssetCode("lambda"),
      runtime: this.LambdaDefaultRuntime,
      handler: "password_policy.lambda_handler",
      memorySize: memorySize,
      role: lambdaRole,
      timeout: Duration.seconds(timeoutSecond),
      functionName: Naming.of("change-password-policy")
    });
    const PublicAccessBlockLambdaFunction = new lambda.Function(this, "PublicAccessBlockLambdaFunction", {
      code: new lambda.AssetCode("lambda"),
      runtime: this.LambdaDefaultRuntime,
      handler: "public_access_block.lambda_handler",
      memorySize: memorySize,
      role: lambdaRole,
      timeout: Duration.seconds(timeoutSecond),
      functionName: Naming.of("enable-public-access-block")
    });
    const SecurityHubLambdaFunction = new lambda.Function(this, "SecurityHubLambdaFunction", {
      code: new lambda.AssetCode("lambda"),
      runtime: this.LambdaDefaultRuntime,
      handler: "security_hub.lambda_handler",
      memorySize: memorySize,
      role: lambdaRole,
      timeout: Duration.seconds(timeoutSecond),
      functionName: Naming.of("change-security-hub")
    });
    const end = new sfn.Pass(this, "End", {});
  
    const stateMachine = new sfn.StateMachine(this, "NewAccountSetupStateMachine", {
      stateMachineName: Naming.of("new-account-state-machine"),
      definition: new tasks.LambdaInvoke(this, "DefaultVpc", {
        lambdaFunction: DefaultVpcLambdaFunction,
        resultPath: '$.DeleteDefaultVpc',
      }).next(new tasks.LambdaInvoke(this, "EbsEncryptionByDefault",{
        lambdaFunction: EbsEncryptionByDefaultLambdaFunction,
        resultPath: '$.EbsEncryptionByDefault',
      })).next(new tasks.LambdaInvoke(this, "PasswordPolicy",{
        lambdaFunction: PasswordPolicyLambdaFunction,
        resultPath: '$.PasswordPolicy',
      })).next(new tasks.LambdaInvoke(this, "PublicAccessBlock",{
        lambdaFunction: PublicAccessBlockLambdaFunction,
        resultPath: '$.PublicAccessBlock',
      })).next(new tasks.LambdaInvoke(this, "SecurityHub",{
        lambdaFunction: SecurityHubLambdaFunction,
        resultPath: '$.SecurityHub',
      }))
  });
  const rule = new events.Rule(this, 'AccountCheckRule', {
    ruleName: Naming.of("create-account-check-rule"),
    eventPattern:{
      "source": ["aws.controltower"],
      "detailType":["AWS Service Event via CloudTrail"],
      "detail": {
        "eventName": ["CreateManagedAccount"]
      }
    },
    targets: [new targets.SfnStateMachine(stateMachine)],
  });

  }

}

リソース名は定義するクラスを別に用意して、以下で定義しています。

{appName}-{env}-{リソース名}の形式としており、{env}は環境変数で定義したものを取得しますが、指定なしだとdevとなります。

例:account-setup-dev-xxxxxxx

./lib/naming.ts

export class Naming {
  public static appName: string = "account-setup";
  public static env: string = process.env.ENV ? process.env.ENV : "dev";

  public static of(name: string): string {
    return `${this.appName}-${this.env}-${name}`;
  }
}

Lambda内で利用する定数は以下のファイルにまとめています。主に実行対象のリージョンやSecurity Hubで無効化したいコントロールなどを定義しています。

./lambda/constants/__init__.py

## 実行対象のリージョン
REGION_LIST = ["us-east-1","ap-northeast-1"]
## Security Hub AFSBで無効化したいコントロール
SECURITY_HUB_AFSBP_DISABLE_CONTROL_LIST = ["IAM.6","EC2.8","CloudTrail.5"]
SECURITY_HUB_AFSBP_STANDARD_NAME = "aws-foundational-security-best-practices/v/1.0.0"
SECURITY_HUB_CIS_STANDARD_NAME = "cis-aws-foundations-benchmark/v/1.2.0"

PASSWORD_POLICY = {
    "MinimumPasswordLength": 8,
    "RequireSymbols": True,
    "RequireNumbers": True,
    "RequireUppercaseCharacters": True,
    "RequireLowercaseCharacters": True,
    "AllowUsersToChangePassword": True,
}

REGION_LIST東京とバージニア北部リージョンのみを実行対象としています。対象を変更したい場合はリージョン名をリストに追加してください。

もしSecurity Hub AWS 基礎セキュリティのベストプラクティス v1.0.0のコントロールで無効化したい項目を変更する場合は、SECURITY_HUB_AFSBP_DISABLE_CONTROL_LISTコントロールのIDをリストで定義するようにしてください。今回は"IAM.6","EC2.8","CloudTrail.5"の3つを例として無効化対象にしています。

Lambdaのコード

実行するLambdaのコード(./lambda/)はそれぞれ公開しているブログのコードをもとに作成しています。動作の詳細は各ブログをご参照ください。

Security Hubのチューニング(security_hub.py)はブログ化していないのですが、やっていることは以下の通りです。初期セットアップ用のため、無効化されたコントロールの再有効化には対応していません。

  • AWS 基礎セキュリティのベストプラクティス v1.0.0の有効化
  • SECURITY_HUB_AFSBP_DISABLE_CONTROL_LIST で定義されたコントロール無効化
  • CIS AWS Foundations Benchmark v1.2.0の無効化

リソースのデプロイ

リポジトリからクローンしてフォルダ内に移動します。

$ git clone https://github.com/jun-suzuki1028/aws-cdk-ct-account-setup-sfn.git
$ cd aws-cdk-ct-account-setup-sfn

CDKをインストールします。今回はローカルインストールしています。

$ npm install aws-cdk

あとはsysthでテンプレートを作成、デプロイコマンドを実行すれば実行できます。実行先のアカウントはControl Towerのマネジメントアカウントに設定してください。

$ cdk synth
$ cdk deploy

デフォルトではリソース名がdevとなっていますが、他の環境に変えたい場合は環境変数で指定してあげると変更できます。(今回の動作確認はdevで作成) 以下のようにexportして再度cdk synthを実行すればリソース名がprdに変わります。

$ export ENV=prd

リソースの展開ができたので、各リソースを確認していきます。

Eventルール

Control Towerのアカウント発行イベントを取得するEventRuleです。ターゲットには後述するステートマシンとしています。

StepFunctions ステートマシン

account-setup-dev-new-account-state-machineという名前で作成されたステートマシンを確認します。

ステートマシンの定義は以下の形になってます。単純に各タスクを直列に並べているだけです。

Lambda関数

ステートマシンのタスクに紐づくLambdaを確認してみると、5つのLambda関数がデプロイされています。

CDKが正しく実行できていれば、ここまで確認したリソースが同じようにデプロイされているはずです。

動作確認

アカウントの発行イベント

それでは実際に動かしてみます。今回は管理アカウントでEventBridgeのイベント保存機能を使ってアカウントの発行イベントを再実行しました。

イベントを再生してしばらく待つと、ステートマシンが実行され正常終了されていることが確認できました。

次に実際に実行先のアカウントの環境を見て、想定通り実行されているかを確認していきます。

デフォルトVPCの削除

東京、バージニア北部どちらもデフォルトVPCが削除されていることを確認できました。今回は2リージョンのみ指定しているため、他のリージョンでは削除されていません。

EBSデフォルト暗号化の有効化

東京、バージニア北部どちらもEBSデフォルト暗号化の有効化されていることを確認できます。こちらも2リージョンのみ指定しているため、他のリージョンでは有効化されていません。

IAMパスワードポリシーの強化

デフォルトから指定したパスワードポリシーへ変更されています。

アカウントレベルのS3パブリックアクセスブロック有効化

全ての設定がオンになっていることが確認できました。

Security Hubのチューニング

Security Hubでは「AWS 基礎セキュリティのベストプラクティス v1.0.0」が有効化されており、指定したコントロールが無効化されていることを確認します。

確認したところ、指定した3つのコントロールが無効になっていることが確認できました。

  • IAM.6
  • EC2.8
  • CloudTrail.5

また「CIS AWS Foundations Benchmark v1.2.0」の標準は無効化されていることも確認できます。

これで今回作成したリソースの動作確認が一通りできました。

おわりに

サンプルとしてStepFunctionsから実行する初期セットアップの一例をCDKで実装してみました。管理する環境によって設定したい項目などは変わってくるかと思いますので、このサンプルから自由にカスタマイズしてご活用ください。ステートマシンが失敗することを考えて通知機能をつけるのもいいですね。

また本実装は初期セットアップ用なので、既存環境へ変更したい場合は別のステートマシンを作成が必要です。組織内のアカウントを全て取得して、実行する仕組みがあれば非常に管理が楽になるかと思います。ぜひそちらも検討してみてください。

参考