AWS Organizations 内のアカウントリストを組織構造と一緒に csv 出力する

## はじめに
AWS Organizations でマルチアカウント管理していると、アカウントの一覧を定期的に取得したいときはありませんか？

アカウント情報だけではなく、所属する OU の情報も一緒に出力する方法を紹介します。

## 実はコンソールから csv をダウンロードできる
コンソール上でも通知が出ているように、最近組織内のアカウントリストを csv 出力することができるようになりました。アクションから「アカウントリストをエクスポート」でダウンロード可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125105/1333de19-3353-0d66-e9af-375ee8c6ba97.png)

これはこれでとても便利なのですが、2022年1月時点では以下の問題があります。

### コンソールからしかダウンロードできない
AWS CLI や SDK 経由でダウンロードができないため、定期的に取得したいといったケースでは自動化が困難です。以下のドキュメントに記載があります。

https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_export.html?icmpid=docs_orgs_console
> The only way to export the .csv file with account details is by using the AWS Management Console. You can't export the account list .csv file using the AWS CLI.

### 取得できる情報が限られている
csv には以下の情報が含まれますが、OU など組織構造の情報は取得できません。

* Account ID
* ARN 
* Email
* Name
* Status 
* Joined method
* Joined timestamp 

そのため AWS Lambda で Organizations の API を叩いて、OU 情報を含むアカウントリストを出力できるようにしました。 

## EventBrdige & Lambda でアカウントリストを自動更新
そのアカウントが所属する OU ID と、親 OU を含む OU 名を出力します。例えば以下のような出力になります。

```accounts.csv
Id,Name,Email,Status,Joined Method,Joined Timestamp,OU Id,1st Level OU,2nd Level OU,3rd Level OU,4th Level OU,5th Level OU
000000000000,account-mgmt,account+mgmt@example.com,ACTIVE,INVITED,2022-01-31 07:19:57,r-xxxx
111111111111,account-0001,account+0001@example.com,ACTIVE,INVITED,2022-01-31 07:25:38,ou-xxxx-yyyyyyyy,Suspended
222222222222,account-0002,account+0002@example.com,ACTIVE,CREATED,2022-01-31 07:31:28,ou-xxxx-zzzzzzzz,Sample System,Additional,Workloads,Prod
333333333333,account-0003,account+0003@example.com,ACTIVE,CREATED,2022-01-31 08:15:49,ou-xxxx-zzzzzzzz,Sample System,Additional,Workloads,SDLC
444444444444,account-0004,account+0004@example.com,ACTIVE,CREATED,2022-01-31 09:18:50,ou-xxxx-zzzzzzzz,Sample System,Foundational,Security,Prod
555555555555,account-0005,account+0005@example.com,ACTIVE,CREATED,2022-01-31 10:21:30,ou-xxxx-zzzzzzzz,Sample System,Foundational,Infrastructure,Prod
666666666666,account-0006,account+0006@example.com,ACTIVE,CREATED,2022-01-31 11:21:05,ou-xxxx-zzzzzzzz,Sample System,Foundational,Infrastructure,SDLC
```

EventBrdige には以下のルールを登録しておき、アカウント作成時に Lambda 関数が起動するようにします。

```json
{
  "detail-type": ["AWS API Call via CloudTrail"],
  "source": ["aws.organizations"],
  "detail": {
    "eventSource": ["organizations.amazonaws.com"],
    "eventName": ["CreateAccount"]
  }
}
```

Lambda 関数のコードは以下です。

* ListChildren API を再帰処理し、組織内の OU リストを作成
* リスト内の各 OU に対し以下を実行
    * ListParents API を再起処理し、自 OU と上位 OU の名前を取得
    * ListAccountsForParent API で OU に所属するアカウントの情報を取得し、レコード追加
* csv ファイルを書き出し、環境変数 `BUCKET` で指定した S3 バケットにアップロード

というのがざっくりな処理の流れです。もう少し簡潔に書けそうな気がするのですが、かなり長くなってしまいました。

```lambda_function.py
"""
AWS Organizations list accounts

Environment variables:
    BUCKET: S3 Bucket Name
"""
from logging import getLogger, INFO
import csv
import operator
import os
from botocore.exceptions import ClientError
import boto3

logger = getLogger()
logger.setLevel(INFO)

def upload_s3(output, key, bucket):
    """Upload csv to S3"""
    try:
        s3_resource = boto3.resource('s3')
        s3_bucket = s3_resource.Bucket(bucket)
        s3_bucket.upload_file(output, key, ExtraArgs={'ACL': 'bucket-owner-full-control'})
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise

def get_ou_ids(org, parent_id):
    """Recursively process list_children to create a list of OUs."""
    ou_ids = []
    try:
        paginator = org.get_paginator('list_children')
        iterator = paginator.paginate(
            ParentId=parent_id,
            ChildType='ORGANIZATIONAL_UNIT'
        )
        for page in iterator:
            for ou in page['Children']:
                ou_ids.append(ou['Id'])
                ou_ids.extend(get_ou_ids(org, ou['Id']))
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return ou_ids

def get_ou_name(org, ou_id):
    """Return OU name"""
    try:
        ou_info = org.describe_organizational_unit(OrganizationalUnitId=ou_id)
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return ou_info['OrganizationalUnit']['Name']

def get_ou_structure(org, child_id):
    """Recursively process list_parents and return the OU structure."""
    ou_structure = []
    try:
        parent_ou = org.list_parents(ChildId=child_id)['Parents'][0]
        if parent_ou['Id'][:2] == 'ou':
            ou_structure.append(get_ou_name(org, parent_ou['Id']))
            ou_structure.extend(get_ou_structure(org, parent_ou['Id']))
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return ou_structure

def list_accounts():
    """Create accounts list"""
    org = boto3.client('organizations')
    accounts = []
    ou_structure = []

    try:
        root_id = org.list_roots()['Roots'][0]['Id']
        ou_id_list = [root_id]
        ou_id_list.extend(get_ou_ids(org, root_id))

        for ou_id in ou_id_list:
            if ou_id[:2] == 'ou':
                ou_structure = get_ou_structure(org, ou_id)
                ou_structure.reverse()
                ou_structure.append(get_ou_name(org, ou_id))
            paginator = org.get_paginator('list_accounts_for_parent')
            page_iterator = paginator.paginate(ParentId=ou_id)
            for page in page_iterator:
                for account in page['Accounts']:
                    item = [
                        account['Id'],
                        account['Name'],
                        account['Email'],
                        account['Status'],
                        account['JoinedMethod'],
                        account['JoinedTimestamp'].strftime('%Y-%m-%d %H:%M:%S'),
                        ou_id,
                    ]
                    accounts.append(item + ou_structure)
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return sorted(accounts, key=operator.itemgetter(5))

def lambda_handler(event, context):
    """Lambda function to output the list of accounts in AWS organization in CSV format."""
    key = 'accounts.csv'
    output_file = '/tmp/accounts.csv'
    bucket = os.environ['BUCKET']
    header = [[
        'Account Id',
        'Account Name',
        'Account Email',
        'Account Status',
        'Joined Method',
        'Joined Timestamp',
        'OU Id',
        '1st Level OU',
        '2nd Level OU',
        '3rd Level OU',
        '4th Level OU',
        '5th Level OU'
    ]]
    account_list = list_accounts()

    with open(output_file, 'w', newline='', encoding='utf-8') as output:
        writer = csv.writer(output)
        writer.writerows(header)
        writer.writerows(account_list)

    upload_s3(output_file, key, bucket)
```

IAM ロールには以下のようなポリシーをアタッチします。

```iam_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::<your_bucket_name>/*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "organizations:DescribeOrganizationalUnit",
                "organizations:ListAccountsForParent",
                "organizations:ListChildren",
                "organizations:ListParents",
                "organizations:ListRoots"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}
```

## CDK でデプロイする
上記を AWS Construct Library 化して npm/pypi に公開しました。詳細は Construct Hub でご確認ください。

https://constructs.dev/packages/cdk-organizations-list-accounts/

例えば TypeScript の場合、以下の手順でデプロイできます。

インストール

```shell-session
$ yarn add cdk-organizations-list-accounts
```

使い方

```typescript
import * as cdk from 'aws-cdk-lib';
import { OrganizationsListAccounts } from 'cdk-organizations-list-accounts';

const App = new cdk.App();
const stack = new cdk.Stack(App, 'Stack', { env: { region: 'us-east-1' } });
new OrganizationsListAccounts(stack, 'Organizations-List-Accounts');
```

Deploy!

```shell-session
$ cdk deploy
```

以上です。
参考になれば幸いです。