AWS CDKでコード管理されたJUMPアカウントを構成してみた

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/7894980b-c216-7994-ad5d-227080bcb687.png)


# はじめに
Identity Centerで管理されていないユーザーのアクセス管理について、皆さんはどうされていますか？:thinking:
弊社では、Identity Centerで管理されていないユーザーが安全に弊社のAWSアカウントへアクセスできるよう、JUMPアカウントを使用したログイン体制を整備しました。このJUMPアカウントはAWS CDKを使って自動的に作成され、効率的に管理できる仕組みになっています。
この記事では、JUMPアカウントの構成方法と、AWS CDKを使ってどのように実装したかをご紹介します。

また、前編となる下記の記事も見ていただければと思います。

https://qiita.com/masaki_seyama_POHD_CCoE/items/ba1ad955abf2c9c552e7

# JUMPアカウント作成の目的
現在、弊社ではMicrosoft Entra IDとIdentity Centerを活用し、各アカウントへのSSO（シングルサインオン）を実現しています。しかし、中には契約面の関係でEntra IDからユーザーを払い出していない作業者（SE）もいるため、Identity Centerを通じたログイン制御が適用できない状況です。
この対応策として、Entra IDを持たないユーザー向けに「JUMPアカウント」を設定し、IAMユーザーの一括管理を行う仕組みを整備しました。これにより、そのようなユーザーにおいても、安全かつ効率的なアクセス管理が可能になりました。

# 要件
安全かつ効率的にJUMPアカウントを設定するために以下のように要件を設定しました。:page_facing_up:
- IAMユーザーはJUMPアカウントにのみ作成、一元管理とする。
- パートナーごとに大枠グループを分け、権限管理しやすく、かつわかりやすくする。
- パートナー内で違う権限セットが必要な場合は、連番でグループを分ける。
- スイッチ用ロールはJUMPアカウントからCloudFormationのStacksetsを使用して作成、一元管理する。
- MFA認証を必須とする。
:::note info
MFA認証は、セキュリティのベストプラクティスなので必須としました
[IAM でのセキュリティのベストプラクティス（多要素認証 (MFA) を必須とする）](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/best-practices.html#enable-mfa-for-privileged-users)
:::

# 構成
ユーザーはJUMPアカウントにMFAを持つIAMユーザーを所有する。そのIAMユーザーを使用し、各アカウントへスイッチロールすることでログインできるようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/d40c08c7-9d84-7d67-27a8-75649a8987dc.png)


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

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

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

## 各リソースを記述
### cmn-jump.ts
エントリーポイントとなり、パートナー毎にStackを作成します。
タグは弊社内ルールに基づいて付与しています。

:::note warn
複数パートナーが存在する、かつ権限変更がある程度の頻度で発生するのでパートナーごとにStackを分けました
:::

```typescript:cmn-jump.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { App, Tags } from 'aws-cdk-lib';
import { CmnJumpStack } from '../lib/cmn-jump-stack';
import { vendors } from '../lib/constants/vendor';
import { DirectAccountId, systemName } from '../lib/types/common';

const app = new App();

vendors.forEach((vendor, i) => {
  const vendorName = vendor.name.replace(/[-_]/g, '');
  new CmnJumpStack(app, `${systemName}-${vendorName}-role-stack`, {
    description: 'create stackset to create roles for cross-account access',
    env: {
      account: DirectAccountId.JUMP,
      region: 'ap-northeast-1',
    },
    vendor: vendor,
    terminationProtection: true,
  });
});

/**
 * Tags
 */
Tags.of(app).add('SystemName', systemName);
Tags.of(app).add('Environment', 'prd');
Tags.of(app).add('Owner', 'CCoE');
```

### cmn-jump-stack.ts
ここでは、StacksetsでSwitchRole先に流すStacksetsテンプレートの作成と、そのテンプレートを使用しRoleを作成するStacksetsの設定、JUMPアカウント内のIAMを設定します。
```typescript:cmn-jump-stack.ts
import { CfnTag, DefaultStackSynthesizer, Stack, StackProps, Stage } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Iam } from './constructs/iam';
import { ServiceManagedStackSets } from './constructs/service-managed-stacksets';
import { IamStack } from './templates/iam';
import { accounts } from './types/accounts';
import { DirectAccountId, Vendor, systemName } 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);

      new ServiceManagedStackSets(
        this,
        `ServiceManagedStackSets${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,
    });
  }
}

```

### iam.ts（JUMPアカウント内のIAM設定用）
MFA認証を必須とするポリシー（enforceMfaPolicy）と、vendor.ts で定義されたユーザーとグループに基づいてIAMを作成します。
```typescript:iam.ts
import { aws_iam as iam, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { AccountAuthMap, GroupInfo } from '../types/common';

export interface IamProps {
  vendorName: string;
  groupInfos: GroupInfo[];
}

export class Iam extends Construct {
  private readonly vendorName: string;

  constructor(scope: Construct, id: string, props: IamProps) {
    super(scope, id);

    this.vendorName = props.vendorName;

    const enforceMfaPolicyStatements: iam.PolicyStatement[] = [
      new iam.PolicyStatement({
        sid: 'AllowViewAccountInfo',
        effect: iam.Effect.ALLOW,
        actions: ['iam:GetAccountPasswordPolicy', 'iam:ListVirtualMFADevices'],
        resources: ['*'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnPasswords',
        effect: iam.Effect.ALLOW,
        actions: ['iam:ChangePassword', 'iam:GetUser'],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnAccessKeys',
        effect: iam.Effect.ALLOW,
        actions: [
          'iam:CreateAccessKey',
          'iam:DeleteAccessKey',
          'iam:ListAccessKeys',
          'iam:UpdateAccessKey',
          'iam:GetAccessKeyLastUsed',
        ],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnSigningCertificates',
        effect: iam.Effect.ALLOW,
        actions: [
          'iam:DeleteSigningCertificate',
          'iam:ListSigningCertificates',
          'iam:UpdateSigningCertificate',
          'iam:UploadSigningCertificate',
        ],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnSSHPublicKeys',
        effect: iam.Effect.ALLOW,
        actions: [
          'iam:DeleteSSHPublicKey',
          'iam:GetSSHPublicKey',
          'iam:ListSSHPublicKeys',
          'iam:UpdateSSHPublicKey',
          'iam:UploadSSHPublicKey',
        ],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnGitCredentials',
        effect: iam.Effect.ALLOW,
        actions: [
          'iam:CreateServiceSpecificCredential',
          'iam:DeleteServiceSpecificCredential',
          'iam:ListServiceSpecificCredentials',
          'iam:ResetServiceSpecificCredential',
          'iam:UpdateServiceSpecificCredential',
        ],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnVirtualMFADevice',
        effect: iam.Effect.ALLOW,
        actions: ['iam:CreateVirtualMFADevice', 'iam:DeleteVirtualMFADevice'],
        resources: ['arn:aws:iam::*:mfa/*'],
      }),
      new iam.PolicyStatement({
        sid: 'AllowManageOwnUserMFA',
        effect: iam.Effect.ALLOW,
        actions: [
          'iam:DeactivateMFADevice',
          'iam:EnableMFADevice',
          'iam:ListMFADevices',
          'iam:ResyncMFADevice',
        ],
        resources: ['arn:aws:iam::*:user/${aws:username}'],
      }),
      new iam.PolicyStatement({
        sid: 'DenyAllExceptListedIfNoMFA',
        effect: iam.Effect.DENY,
        notActions: [
          'iam:CreateVirtualMFADevice',
          'iam:EnableMFADevice',
          'iam:GetUser',
          'iam:GetMFADevice',
          'iam:ListMFADevices',
          'iam:ListVirtualMFADevices',
          'iam:ResyncMFADevice',
          'sts:GetSessionToken',
        ],
        resources: ['*'],
        conditions: {
          BoolIfExists: {
            'aws:MultiFactorAuthPresent': 'false',
          },
        },
      }),
    ];

    const enforceMfaPolicy = new iam.ManagedPolicy(this, 'EnforceMfaPolicy', {
      managedPolicyName: `${this.vendorName}-enforce-mfa-policy`,
      statements: enforceMfaPolicyStatements,
    });

    props.groupInfos.forEach((groupInfo, i) => {
      i += 1;
      const seqNum = i.toString().padStart(3, '0');

      const group = new iam.Group(this, `Group${seqNum}`, {
        groupName: `${this.vendorName}-${seqNum}-group`,
      });

      new iam.ManagedPolicy(this, `AssumeRolePolicy${seqNum}`, {
        managedPolicyName: `${this.vendorName}-${seqNum}-policy`,
        groups: [group],
        statements: [this.getStatement(groupInfo.accountAuthMaps)],
      });

      enforceMfaPolicy.attachToGroup(group);

      if (groupInfo.users) {
        groupInfo.users.forEach((user) => {
          const iamuser = new iam.User(
            this,
            `User${this.createIamUserLogicalIdFromEmail(user)}`,
            {
              userName: user,
            }
          );
          iamuser.applyRemovalPolicy(RemovalPolicy.RETAIN);
          iamuser.addToGroup(group);
        });
      }
    });
  }

  protected getStatement(
    accountAuthMaps: AccountAuthMap[]
  ): iam.PolicyStatement {
    let resources: string[] = [];
    accountAuthMaps.forEach((accountAuthMap) => {
      accountAuthMap.authTypes.forEach((authType) => {
        resources.push(
          `arn:aws:iam::${accountAuthMap.account}:role/${this.vendorName}-${authType}-role`
        );
      });
    });
    const policyStatement = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['sts:AssumeRole'],
      resources: resources,
    });
    return policyStatement;
  }

  protected createIamUserLogicalIdFromEmail(email: string): string {
    let localPart = email.substring(0, email.indexOf('@'));
    localPart = localPart.replace(/[-_.]/g, '');
    const iamUserLogicalIdPascalCase =
      localPart.charAt(0).toUpperCase() + localPart.slice(1);
    return iamUserLogicalIdPascalCase;
  }
}

```

### service-managed-stacksets.ts
サービスマネージド型のStacksetsの作成します。
```typescript:service-managed-stacksets.ts
import { CfnStackSet, CfnTag } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Account, StackSetsInfo, systemName } from '../types/common';

interface ServiceManagedStackSetsProps {
  stackSetsInfo: StackSetsInfo;
  accountInfo: Account;
  regions: string[];
  templateBody: string;
  tags: CfnTag[];
  vendorName: string;
}

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

    new CfnStackSet(this, 'Default', {
      stackSetName: `${systemName}-${props.vendorName}-role-for-${props.accountInfo.alias}-stackset`,
      permissionModel: 'SERVICE_MANAGED',
      capabilities: ['CAPABILITY_NAMED_IAM'],
      autoDeployment: {
        enabled: false,
      },
      stackInstancesGroup: [
        {
          regions: props.regions,
          deploymentTargets: {
            organizationalUnitIds: [props.accountInfo.ouId!],
            accounts: [props.accountInfo.id],
            accountFilterType: 'INTERSECTION',
          },
        },
      ],
      callAs: 'DELEGATED_ADMIN',
      templateBody: props.templateBody,
    });
  }
}
```

### iam.ts（スイッチ先ロール作成用）
基本的な権限として以下４つを使用有無に関わらず作成します。
- Administrator
- Power+IAMFull
- Power
- ReadOnly

また、各ロールはカスタム設定できるようにパートナー毎に作成します。
例）{パートナー名}-admin-role
```typescript:iam.ts
import { aws_iam as iam, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { AuthType, DirectAccountId, ResellerAccountId } from '../types/common';

interface IamStackProps extends StackProps {
  vendorName: string;
  assumeRoleSourceAccountId: DirectAccountId | ResellerAccountId;
}

export class IamStack extends Stack {
  private assumeRoleSourceAccountId: DirectAccountId | ResellerAccountId;

  constructor(scope: Construct, id: string, props: IamStackProps) {
    super(scope, id, props);

    this.assumeRoleSourceAccountId = props.assumeRoleSourceAccountId;

    // AuthType: admin
    const adminRole = new iam.Role(this, 'AdministratorAccessRole', {
      roleName: `${props.vendorName}-${AuthType.Admin}-role`,
      assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess'),
      ],
    });

    // AuthType: power
    const powerRole = new iam.Role(this, 'PowerUserAccessRole', {
      roleName: `${props.vendorName}-${AuthType.Power}-role`,
      assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
        iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
      ],
    });

    // AuthType: read
    const readRole = new iam.Role(this, 'ReadOnlyAccessRole', {
      roleName: `${props.vendorName}-${AuthType.Read}-role`,
      assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
        iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCloudShellFullAccess'),
      ],
    });

    // AuthType: power-iam
    const powerIamRole = new iam.Role(this, 'PowerUserAndIamFullAccessRole', {
      roleName: `${props.vendorName}-${AuthType.PowerAndIam}-role`,
      assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
        iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
        iam.ManagedPolicy.fromAwsManagedPolicyName('IAMFullAccess'),
      ],
    });
  }
}
```

### account.ts
AWSアカウントデータをリスト型で管理します。
他ファイルで使用するAWSアカウントデータはこのファイルから取得します。

| 項目名 | 概要 | 詳細 |
|:------|:------|:------|
| id | アカウントID | common.tsのアカウント番号一覧を参照する |
| alias | アカウントエイリアス | アカウントエイリアスを小文字のスネークキャメルケースで表現する |
| type | アカウントタイプ（自己所有 or リセラーアカウント） | common.tsのアカウントタイプを参照する |
| ouId | ControlTowerのOrganizationID | common.tsのOU-IDの一覧を参照する |

```typescript:account.ts
export const accounts: Account[] = [
  {
    id: DirectAccountId.AAA,
    alias: 'BBBB',
    type: AccountType.DIRECT,
    ouId: OuId.XXX,
  },
  {
    ...
]
```

### vendor.ts
パートナーデータを管理します。
このファイルに基づき、IAMユーザー、グループ、権限設定を行います。
パートナー情報は以下のように構成しています。
| 項目名 | 概要 | 詳細 |
|:------|:------|:------|
| name | パートナー会社名 |  |
| stackSetsInfo | stacksetsデータ | 作成するStacksets情報 |
| stackSetsInfo.account | アカウント | Stacksetsを展開するアカウント |
| groupInfo | グループ情報 | 設定するグループの情報 |
| groupInfo.users | ユーザー情報 | グループに追加するIAMユーザーのリスト |
| groupInfo.accountAuthMaps | 権限セット | グループの権限情報 |
| groupInfo.accountAuthMaps.account | スイッチ先アカウント | スイッチ先のアカウント |
| groupInfo.accountAuthMaps.authTypes | 権限タイプ | スイッチ先の権限タイプのリスト |


```typescript:vendor.ts
export const vendors: Vendor[] = [];

vendor.push({
  name: 'vendor1'
  stackSetsInfo: [
    {
      account: DirectAccountId.AAA,
    }
  ],
  groupInfo: [
    {
      users: [ 'user1@example.com' ],
      accountAuthMaps: [
        {
          account: DirectAccountId.AAA,
          authtype: [Authtype.Admin]
        }
      ]
    }
  ]
})
```


# デプロイ
テストデータを使ってデプロイしてみました。
```typescript:vendor.ts
vendors.push({
  name: 'test',
  stackSetsInfos: [
    {
      account: DirectAccountId.AAA,
    },
  ],
  groupInfos: [
    {
      users: ['test@example.com'],
      accountAuthMaps: [
        {
          account: DirectAccountId.AAA,
          authTypes: [AuthType.Read],
        },
      ],
    },
  ],
});
```

JUMPアカウントにIAMユーザーとグループが作成されました！
IAMユーザー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/6b852062-2bca-fa4a-e699-1e9e16ee2bad.png)


IAMグループ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/dbebdaaf-1daa-5888-03aa-d259cfa56a23.png)



作成したユーザーでスイッチロールできるかも検証しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/5eaa9025-15ce-a2c9-a325-e38522eae45f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333262/41641414-b86a-cdc2-0b11-5a61fc115010.png)


無事、指定したアカウントにスイッチロールすることができました。:tada:

# まとめ
今回、AWS CDKを使用してJUMPアカウントを構成しました。コードで管理することで、マネジメントコンソール上での作業をほとんど不要とし、管理のしやすさと作業効率の向上を実現することができました。:blush:
また、新たな環境が増えた場合でもすぐに対応できるため、柔軟かつスケーラブルな運用が可能になりました。
この記事を機に、CDK・JUMPアカウントを使用してみてはいかがでしょうか？


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

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

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