0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社ポーラ・オルビスホールディングスAdvent Calendar 2024

Day 14

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

Last updated at Posted at 2024-12-13

image.png

はじめに

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

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

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認証を必須とする。

MFA認証は、セキュリティのベストプラクティスなので必須としました
IAM でのセキュリティのベストプラクティス(多要素認証 (MFA) を必須とする)

構成

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

image.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を作成します。
タグは弊社内ルールに基づいて付与しています。

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

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を設定します。

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を作成します。

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の作成します。

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(スイッチ先ロール作成用)

基本的な権限として以下4つを使用有無に関わらず作成します。

  • Administrator
  • Power+IAMFull
  • Power
  • ReadOnly

また、各ロールはカスタム設定できるようにパートナー毎に作成します。
例){パートナー名}-admin-role

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の一覧を参照する
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 権限タイプ スイッチ先の権限タイプのリスト
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]
        }
      ]
    }
  ]
})

デプロイ

テストデータを使ってデプロイしてみました。

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

IAMグループ
image.png

作成したユーザーでスイッチロールできるかも検証しました。
image.png

image.png

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

まとめ

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

弊社では一緒に働く仲間を募集中です!

現在、様々な職種を募集しております。
カジュアル面談も可能ですので、ご連絡お待ちしております!

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?