CDKでジャンプアカウントからOrganization外アカウントへログインできるようにしてみた

# はじめに
弊社では以前、AWS CDKを使用してジャンプアカウントの仕組みを構築しました。この仕組みにより、弊社所有のAWSアカウント（以下、ダイレクトアカウント）へスイッチロールしてログインできるようにしています。
一方、弊社ではリセラーから提供されているAWSアカウント（以下、リセラーアカウント）も併用しており、今回の改修では、このリセラーアカウントにもジャンプアカウント経由でログインできるように対応範囲を拡張しました。改修作業においてもAWS CDKを活用し、運用負担の軽減と安全かつ効率的なアクセス管理を実現しています。

以前の記事はこちらです。

https://qiita.com/pohd_ccoe/items/a3b135717e2d09bacd14

# 構成
構成イメージは以下になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/91a2334b-e173-4d0a-15f6-e13d9c67a045.png)


今回は、ジャンプアカウントからリセラーアカウントにスイッチロールしてログインできるように改修を行います。この改修では、前回同様、AWS CDKを使用して、リセラーアカウントへのログイン経路を追加します。
スイッチ先のアカウントで使用するロールのデプロイには、ジャンプアカウントのCloudFormation Stacksetsを活用します。Stacksetsの種類は、対象アカウントによって以下のように使い分けます。

:::note info
- **ダイレクトアカウント**へのデプロイ ⇒ サービスマネージド型Stacksets
→ AWS Organizationsで一元管理されるアカウントへのデプロイに適しています。
- **リセラーアカウント**へのデプロイ ⇒ セルフマネージド型のStacksets
→ AWS Organizationsに含まれない外部アカウントにも対応できるため、この形式を使用します。
:::
これにより、ジャンプアカウントからダイレクトアカウントおよびリセラーアカウントの両方への安全で効率的なスイッチロールを実現します。

サービスマネージド型とセルフマネージド型といったStacksetsアクセス許可モデルについてはこちらをご覧ください

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html#stacksets-concepts-stackset-permission-models


## 環境
使用している環境は以下となります。
```
$ node -v
v18.19.0

$ cdk --version
2.125.0 (build 5e3c3c6)
```

## ファイル構成

前回との変更点は以下になります。
- self-managed-stacksets.ts
リセラーアカウント向け設定を行うセルフマネージド型Stacksetsのコンストラクトを新規追加
- cmn-jump-stack.ts
ダイレクトアカウントとリセラーアカウントで使用するStacksetを分岐する処理を追加改修

```
CMN-JUMP
├── bin
│ └── cmn-jump.ts 　　　　　　　　　　　 # エントリーポイント
├── lib
│ ├── constants
│ │ └── vendor.ts 　　　　　　　　　　　 # パートナー毎のIAM設定
│ ├── constructs
│ │ ├── iam.ts 　　　　　　  　　　　　　# JUMPアカウントのIAM定義
│ │ └── service-managed-stacksets.ts 　# サービスマネージド型Stacksets定義
│ │ └── self-managed-stacksets.ts      # セルフマネージド型Stacksets定義（新規）
│ ├── templates
│ │ └── iam.ts 　　　　　  　　　　　　　# SwitchRole先のIAM設定
│ ├── types
│ │ ├── account.ts 　　　　　　　　　　　# AWSアカウント設定
│ │ └── common.ts 　　　　　　　　　　 　# 共通定数
│ └── cmn-jump-stack.ts 　　　　　　    # JUMPアカウントのCDKスタック定義（改修）
└── README.md
```

## リソース
### self-managed-stacksets.ts
リセラーアカウント向け設定を行うセルフマネージド型Stacksetsを定義します。
セルフマネージド型Stacksetsで必要なアクセス許可は公式が出しているドキュメントを基に作成しました。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/stacksets-prereqs-self-managed.html

:::note warn
セルフマネージド型Stacksetsを使用するためには、ジャンプアカウントに`administrationRole`、スイッチロール先アカウントに`executionRole`が必要となります。
これらのロールは事前にデプロイし、そのロール名やARNをコードで参照するようにしています。
:::

```typescript: self-managed-stacksets.ts
import { CfnStackSet, aws_iam as iam } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { DirectAccountId, StacksetsProps, systemName } from '../types/common';

export class SelfManagedStackSets extends Construct {
  constructor(scope: Construct, id: string, props: StacksetsProps) {
    super(scope, id);

    const adminRole = iam.Role.fromRoleArn(
      this,
      `${systemName}-stackset-administration-role`,
      `arn:aws:iam::${DirectAccountId.JUMP}:role/${systemName}-stackset-administration-role`
    );
    const executionRole = iam.Role.fromRoleName(
      this,
      `${systemName}-stackset-execution-role`,
      `${systemName}-stackset-execution-role`
    );

    new CfnStackSet(this, 'Default', {
      stackSetName: `${systemName}-${props.vendorName}-role-for-${props.accountInfo.alias}-stackset`,
      permissionModel: 'SELF_MANAGED',
      capabilities: ['CAPABILITY_NAMED_IAM'],
      stackInstancesGroup: [
        {
          regions: props.regions,
          deploymentTargets: {
            accounts: [props.accountInfo.id],
          },
        },
      ],
      templateBody: props.templateBody,
      administrationRoleArn: adminRole.roleArn,
      executionRoleName: executionRole.roleName,
    });
  }
}
```

### cmn-jump-stack.ts
ダイレクトアカウントかリセラーアカウントかよって、使用するStacksetsを分岐する処理を追加しました。
```typescript: cmn-jump-stack.ts
import { DefaultStackSynthesizer, Stack, StackProps, Stage } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Iam } from './constructs/iam';
import { SelfManagedStackSets } from './constructs/self-managed-stacksets';
import { ServiceManagedStackSets } from './constructs/service-managed-stacksets';
import { IamStack } from './templates/iam';
import { accounts } from './types/accounts';
import { AccountType, DirectAccountId, Vendor } from './types/common';

interface CmnJumpStackProps extends StackProps {
  vendor: Vendor;
}

export class CmnJumpStack extends Stack {
  constructor(scope: Construct, id: string, props: CmnJumpStackProps) {
    super(scope, id, props);

    const regions = ['ap-northeast-1'];

    props.vendor.stackSetsInfos.forEach((stackSetsInfo, i) => {
      const account = accounts.find((acc) => acc.id === stackSetsInfo.account)!;
      const stage = new Stage(
        this,
        `Stage-${props.vendor.name}-${account.alias}`
      );

      new IamStack(stage, 'IamStack', {
        synthesizer: new DefaultStackSynthesizer({
          generateBootstrapVersionRule: false,
        }),
        vendorName: props.vendor.name,
        assumeRoleSourceAccountId: DirectAccountId.JUMP,
      });

      const templateBody = JSON.stringify(stage.synth().stacks[0].template);

      if (account.type === AccountType.DIRECT) {
        new ServiceManagedStackSets(
          this,
          `ServiceManagedStackSets${account.id}`,
          {
            stackSetsInfo: stackSetsInfo,
            accountInfo: account,
            regions: regions,
            templateBody: templateBody,
            vendorName: props.vendor.name,
          }
        );
      } else if (account.type === AccountType.RESELLER) {
        new SelfManagedStackSets(this, `SelfManagedStackSets${account.id}`, {
          stackSetsInfo: stackSetsInfo,
          accountInfo: account,
          regions: regions,
          templateBody: templateBody,
          vendorName: props.vendor.name,
        });
      }
    });

    new Iam(this, 'Iam', {
      vendorName: props.vendor.name,
      groupInfos: props.vendor.groupInfos,
    });
  }
}

```

## アカウントデータ設定について
ジャンプアカウントの管理とStacksetsの設定を効率化するため、アカウントデータは`account.ts`ファイルで一元管理しています。このファイルでは、各アカウントの属性を明確に定義し、それに基づいて処理を分岐させる構成としています。

以下は、`account.ts`で管理しているアカウントデータの構成項目です。
| 項目名 | 概要 | 詳細 |
|:------|:------|:------|
| id | アカウントID | `common.ts`のアカウント番号一覧を参照する |
| alias | アカウントエイリアス | アカウントエイリアスを小文字のスネークキャメルケースで表現する |
| type | アカウントタイプ（ダイレクトアカウント or リセラーアカウント） | `common.ts`のアカウントタイプを参照する |
| ouId | ControlTowerのOrganizationID | `common.ts`のOU-IDの一覧を参照する |

- `type`
アカウントが「ダイレクトアカウント」か「リセラーアカウント」かを識別するための項目です。この`type`を基に、Stacksetsの設定を分岐させています。
- `ouId`
Control Towerの管理対象であるダイレクトアカウントの場合のみ必須となる項目です。リセラーアカウントではセルフマネージド型Stacksetsを使用するため、この項目は設定不要としています。

```typescript:例）account.ts
export const accounts: Account[] = [
  // ダイレクトアカウント
  {
    id: DirectAccountId.AAA,
    alias: 'BBBB',
    type: AccountType.DIRECT,
    ouId: OuId.XXX,
  },
  // リセラーアカウント
  {
    id: ResellerAccountId.CCC,
    alias: 'DDDD',
    type: AccountType.RESELLER,
  },
  {
    ...
]
```

# デプロイ
以下のようなデータを準備しデプロイしました。
```typescript:vendor.ts
vendors.push({
  name: 'test',
  stackSetsInfos: [
    {
      account: DirectAccountId.AAA,
    },
    {
      account: ResellerAccountId.BBB,
    },
    ...
  ],
  groupInfos: [
    {
      users: [
        'test@example.com',
        ...
      ],
      accountAuthMaps: [
        {
          account: DirectAccountId.AAA,
          authTypes: [AuthType.Read],
        },
        {
          account: ResellerAccountId.BBB,
          authTypes: [AuthType.Read],
        },
        ...
      ],
    },
  ],
});
```

アカウントに合わせてStacksetsが使い分けされていることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/ab36750d-a98c-2615-62bc-1dd8c131c376.png)



これにより、ジャンプアカウントに作成した１つのIAMユーザーがOrganization内外に関係なく設定したアカウントへスイッチロールできるようになりました。

# さいごに
今回はジャンプアカウントからOrganization外アカウントへログインできるようにしました。この仕組みではジャンプアカウントからCloudFormation Stacksetを使用してスイッチロール先に使用ロールをデプロイしています。そのため、個々のアカウントでロールを作成する必要はなく、一元管理することができます。これは、使用しているアカウント数が多いほど嬉しい仕組みとなっています:relaxed:

この記事がジャンプアカウントを作りたい方やスイッチ用ロールを一元管理したい方などのお役に立てれば幸いです。


# 弊社では一緒に働く仲間を募集中です！:santa:
現在、様々な職種を募集しております。
カジュアル面談も可能ですので、ご連絡お待ちしております！

募集内容等詳細は、是非採用サイトをご確認ください。

https://engineer.po-holdings.co.jp/