[AWS CDK] MFAの登録を強制するIAMグループと特定のEC2インスタンスにしかSSMセッションマネージャー接続できないIAMロールを作ってみた

同じようなリソースを作成したいときに
2024.05.23

都度MFAの登録を強制するIAMグループを作るのが面倒

こんにちは、のんピ(@non____97)です。

皆さんは都度MFAの登録を強制するIAMグループを作るのが面倒だなと思ったことはありますか? 私はあります。

以下記事で紹介しているようなIAMポリシーをアタッチすることで、MFAを登録するまで利用できる権限を絞ることが可能です。

しかし、複数のAWSアカウントに同様のIAMユーザーが存在する場合、同じIAMポリシー、IAMグループを作成するのを何回も手動で作成するのは少し骨が折れます。

ということで、AWS CDKで楽をしたいと思います。

また、以下のようなIAMロールも併せて作成します。

  • 特定のEC2インスタンスにのみSSMセッションマネージャーに接続を許可する
    • SSMセッションマネージャーでEC2インスタンスに接続する際のOSユーザーはIAMロールごとに異なるように設定
  • 特定のEC2インスタンスにのみEC2インスタンスの起動停止を許可する

許可するEC2インスタンスごと、OSユーザーごとにIAMポリシーを作成するのは少し大変です。そのため、タグを使ってABACによる制御を行ってみます。

ABACによる特定のEC2インスタンスにのみSSMセッションマネージャーに接続を許可する箇所は以下記事が参考になります。

AWS CDKのコードの紹介

パラメーター

簡単にAWS CDKのコードを紹介します。

コード全体は以下リポジトリに保存しています。

まず、指定するパラメーターについてです。

以下の2種類の要素についてパラメーターを指定します。

  1. 作成するIAMユーザー
  2. 作成するIAMロール

作成するIAMユーザーについては以下を指定します。

  • IAMユーザー名
  • Eメールアドレス (オプション)

Eメールアドレスを指定できるようにしているのは個人を特定しやすくするためです。

作成するIAMロールについては以下を指定します。

  • IAMロール名 (オプション)
  • IAMロールのPrincipalとして指定するIAMユーザー名 (オプション)
    • 同一AWSアカウント上のIAMユーザーを指定する場合はこちらを使用
  • IAMロールのPrincipalとして指定するIAMユーザーのARN (オプション)
    • 別AWSアカウント上のIAMユーザーを指定する場合はこちらを使用
  • プロジェクト名
    • ABACで使用
  • OSユーザー名
    • SSMセッションマネージャーでEC2インスタンスに接続したときに使用するOSユーザー名

具体的なコードは以下のとおりです。

./parameter/index.ts

import * as cdk from "aws-cdk-lib";

export interface User {
  userName: string;
  emailAddress?: string;
}

export interface Role {
  roleName?: string;
  rolePrincipalUserNames?: string[];
  rolePrincipalUserArns?: string[];
  projectName: string;
  ssmSessionRunAs: string;
}

export interface OperationUsersProperty {
  users: User[];
  roles: Role[];
}

export interface OperationUsersStackProperty {
  env?: cdk.Environment;
  props: OperationUsersProperty;
}

IAMグループの作成

次に、IAMグループのConstructです。

ここでは以下のような操作を行っています。

  • AssumeRoleを許可するIAMポリシーの作成
  • 自IAMユーザーのアクセスキーを発行を許可するIAMポリシーの作成
  • 自IAMユーザーのタグ操作を許可するIAMポリシーの作成
  • MFAを登録しなければ操作を制限するIAMポリシーの作成
    • MFA未登録状態では、MFAの登録/更新/無効/削除やパスワード変更、ログイン情報のみを許可
  • IAMグループの作成
    • 作成したIAMポリシーとIAMSelfManageServiceSpecificCredentialsをアタッチ
    • IAMSelfManageServiceSpecificCredentialsは自IAMユーザーの認証情報を管理できるように付与

具体的なコードは以下のとおりです。

./lib/construct/group-construct.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export interface GroupConstructProps {}

export class GroupConstruct extends Construct {
  readonly group: cdk.aws_iam.IGroup;

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

    const assumeRolePolicy = new cdk.aws_iam.ManagedPolicy(
      this,
      "AssumeRolePolicy",
      {
        statements: [
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["*"],
            actions: ["sts:AssumeRole"],
          }),
        ],
      }
    );

    const createAccessKeyPolicy = new cdk.aws_iam.ManagedPolicy(
      this,
      "CreateAccessKeyPolicy",
      {
        statements: [
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: [
              "arn:aws:iam::" +
                cdk.Stack.of(this).account +
                ":user/${aws:username}",
            ],
            actions: [
              "iam:GetAccessKeyLastUsed",
              "iam:ListAccessKeys",
              "iam:CreateAccessKey",
              "iam:DeleteAccessKey",
              "iam:UpdateAccessKey",
            ],
          }),
        ],
      }
    );

    const tagPolicy = new cdk.aws_iam.ManagedPolicy(this, "TagPolicy", {
      statements: [
        new cdk.aws_iam.PolicyStatement({
          effect: cdk.aws_iam.Effect.ALLOW,
          resources: [
            "arn:aws:iam::" +
              cdk.Stack.of(this).account +
              ":user/${aws:username}",
          ],
          actions: ["iam:ListUserTags", "iam:UntagUser", "iam:TagUser"],
        }),
      ],
    });

    const enforceMfaPolicy = new cdk.aws_iam.ManagedPolicy(
      this,
      "EnforceMfaPolicy",
      {
        statements: [
          new cdk.aws_iam.PolicyStatement({
            sid: "SelfManagedMfa",
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: [
              "arn:aws:iam::" +
                cdk.Stack.of(this).account +
                ":mfa/${aws:username}*",
              "arn:aws:iam::" +
                cdk.Stack.of(this).account +
                ":user/${aws:username}*",
            ],
            actions: [
              "iam:ChangePassword",
              "iam:CreateVirtualMFADevice",
              "iam:DeleteVirtualMFADevice",
              "iam:DeactivateMFADevice",
              "iam:EnableMFADevice",
              "iam:GetLoginProfile",
              "iam:GetUser",
              "iam:ResyncMFADevice",
              "iam:ListMFADevices",
            ],
          }),
          new cdk.aws_iam.PolicyStatement({
            sid: "RestrictActionsWithoutMfa",
            effect: cdk.aws_iam.Effect.DENY,
            resources: ["*"],
            notActions: [
              "iam:ChangePassword",
              "iam:CreateVirtualMFADevice",
              "iam:DeleteVirtualMFADevice",
              "iam:DeactivateMFADevice",
              "iam:EnableMFADevice",
              "iam:GetLoginProfile",
              "iam:GetUser",
              "iam:ResyncMFADevice",
              "iam:ListMFADevices",
            ],
            conditions: {
              BoolIfExists: {
                "aws:MultiFactorAuthPresent": "false",
              },
            },
          }),
        ],
      }
    );

    const group = new cdk.aws_iam.Group(this, "Default", {
      managedPolicies: [
        assumeRolePolicy,
        createAccessKeyPolicy,
        tagPolicy,
        enforceMfaPolicy,
        cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
          "IAMSelfManageServiceSpecificCredentials"
        ),
      ],
    });
    this.group = group;
  }
}

IAMユーザーの作成

次にIAMユーザー作成のConstructです。

こちらは指定された名前でIAMユーザーを作成するだけのものです。

作成するIAMユーザーにはAWSマネジメントコンソールにアクセスするために必要なパスワードは付与していません。IAMユーザー作成後にパスワードを手動で割り当てます。

AWS CDK上でパスワードの生成 or パスワードの指定も可能です。今回はSecrets Managerの管理が面倒だったので行いませんでした。

IAMロールの作成

最後にIAMロール作成のConstructです。

ここでは以下のような操作を行っています。

  • 特定のEC2インスタンスにのみSSMセッションマネージャーに接続を許可するIAMポリシーの作成
  • 特定のEC2インスタンスにのみEC2インスタンスの起動停止を許可するIAMポリシーの作成
  • IAMロールの作成
    • ABACをするため、各IAMロールにはプロジェクト名を示すProjectを付与
    • SSMセッションマネージャーでEC2インスタンスに接続する際のOSユーザーを指定するため、各IAMロールにはOSユーザー名を示すSSMSessionRunAsを付与

具体的なコードは以下のとおりです。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Role } from "../../parameter/index";

export interface RoleConstructProps {
  roles: Role[];
}

export class RoleConstruct extends Construct {
  readonly users: cdk.aws_iam.IUser[];

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

    const ssmSessionManagerPolicy = new cdk.aws_iam.ManagedPolicy(
      this,
      "SsmSessionManagerPolicy",
      {
        statements: [
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["arn:aws:ssm:*:*:session/${aws:username}-*"],
            actions: ["ssm:ResumeSession", "ssm:TerminateSession"],
          }),
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["arn:aws:ssm:*:*:document/*"],
            actions: ["ssm:StartSession"],
          }),
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["*"],
            actions: ["ssm:StartSession", "ssm:GetConnectionStatus"],
            conditions: {
              StringEquals: {
                "aws:ResourceTag/Project": "${aws:PrincipalTag/Project}",
              },
            },
          }),
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["*"],
            actions: [
              "ssm:DescribeInstanceInformation",
              "ssm:DescribeSessions",
            ],
          }),
        ],
      }
    );

    const ec2InstanceStartStopPolicy = new cdk.aws_iam.ManagedPolicy(
      this,
      "Ec2InstanceStartStopPolicy",
      {
        statements: [
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["*"],
            actions: ["ec2:DescribeInstances"],
          }),
          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["*"],
            actions: ["ec2:StartInstances", "ec2:StopInstances"],
            conditions: {
              StringEquals: {
                "aws:ResourceTag/Project": "${aws:PrincipalTag/Project}",
              },
            },
          }),
        ],
      }
    );

    props.roles.forEach((roleInfo) => {
      const principalFromUserArns: cdk.aws_iam.IPrincipal[] | undefined =
        roleInfo.rolePrincipalUserArns?.map((rolePrincipalUserArn) => {
          return new cdk.aws_iam.ArnPrincipal(rolePrincipalUserArn);
        });

      const principalFromUserNames = roleInfo.rolePrincipalUserNames?.map(
        (rolePrincipalUserName) => {
          return new cdk.aws_iam.ArnPrincipal(
            `arn:aws:iam::${
              cdk.Stack.of(this).account
            }:user/${rolePrincipalUserName}`
          );
        }
      );

      const principals = [
        ...(principalFromUserArns ?? []),
        ...(principalFromUserNames ?? []),
      ];

      if (!principals) {
        return;
      }

      const role = new cdk.aws_iam.Role(
        this,
        `${roleInfo.projectName}_${roleInfo.ssmSessionRunAs}OperationRole`,
        {
          roleName: roleInfo.roleName,
          assumedBy: new cdk.aws_iam.CompositePrincipal(
            ...principals
          ).withConditions({
            Bool: {
              "aws:MultiFactorAuthPresent": "true",
            },
          }),
          managedPolicies: [
            ssmSessionManagerPolicy,
            ec2InstanceStartStopPolicy,
          ],
        }
      );

      cdk.Tags.of(role).add("Project", roleInfo.projectName);
      cdk.Tags.of(role).add("SSMSessionRunAs", roleInfo.ssmSessionRunAs);
    });
  }
}

以下がキモだったりします。

          new cdk.aws_iam.PolicyStatement({
            effect: cdk.aws_iam.Effect.ALLOW,
            resources: ["arn:aws:ssm:*:*:document/*"],
            actions: ["ssm:StartSession"],
          }),

ssm:StartSessionのリソースはEC2インスタンスだけでなく、SSMドキュメントも指定することが可能です。

AWS_Systems_Manager_のアクション、リソース、および条件キー_-_サービス認証リファレンス_🔊

抜粋 : AWS Systems Manager のアクション、リソース、および条件キー - サービス認証リファレンス

上述のステートメントが存在しない場合は、SSMのドキュメントにもタグを付与する必要があります。仮に上述のステートメントが存在しない場合は"User: arn:aws:sts::<AWSアカウントID>:assumed-role/<IAMロール名>/<IAMユーザー名> is not authorized to perform: ssm:StartSession on resource: arn:aws:ssm:us-east-1:<AWSアカウントID>:document/SSM-SessionManagerRunShell because no identity-based policy allows the ssm:StartSession actionとエラーになります。

許可するSSMドキュメントを絞りたい方は以下記事で紹介しているようにssm:SessionDocumentAccessCheckを使用しましょう。

やってみた

リソースのデプロイ

実際やってみましょう。

まず、AWS CDKで各種リソースをデプロイします。パラメーターは以下のとおりです。

./parameter/index.ts

export const operationUsersStackProperty: OperationUsersStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
    users: [
      {
        userName: "non-97-test-user1",
        emailAddress: "test-user1@non-97.net",
      },
      {
        userName: "non-97-test-user2",
      },
    ],
    roles: [
      {
        rolePrincipalUserNames: ["non-97-test-user1"],
        projectName: "Test Project 1",
        ssmSessionRunAs: "os-user1",
      },
      {
        rolePrincipalUserNames: ["non-97-test-user2"],
        projectName: "Test Project 2",
        ssmSessionRunAs: "os-user2",
      },
      {
        roleName: "non-97-project-ope-role",
        rolePrincipalUserNames: ["non-97-test-user1"],
        rolePrincipalUserArns: [
          "arn:aws:iam::<AWSアカウントID>:user/non-97-test-user3",
        ],
        projectName: "Test Project 2",
        ssmSessionRunAs: "os-user3",
      },
    ],
  },
};

また、検証用にProjectというタグのキーを持つ、EC2インスタンスを2台用意しておきました。

EC2インスタンスのタグの確認

各EC2インスタンスにはOSユーザーを作成しておきます。

Project : Test Project 1 のタグが付与されているEC2インスタンス

$ sudo useradd -s /bin/bash -m os-user1
$ tail -n 1 /etc/passwd
os-user1:x:1001:1001::/home/os-user1:/bin/bash

Project : Test Project 2 のタグが付与されているEC2インスタンス

$ sudo useradd -s /bin/bash -m os-user3
$ tail -n 1 /etc/passwd
os-user3:x:1002:1002::/home/os-user3:/bin/bash

SSMセッションマネージャーの設定は以下のとおりです。

SSMセッションマネージャーの設定

この画面上では表示されていませんが、Linux インスタンスの Run As サポートを有効にするは有効されています。

IAMユーザーのパスワード発行

IAMユーザーのパスワードの発行を行います。

ユーザー一覧から作成したIAMユーザーを選択します。

作成されたIAMユーザーの確認

セキュリティ認証情報タブを選択して、コンソールアクセスを有効にするをクリックします。

コンソールへのアクセスを許可

コンソールアクセスを有効にするをクリックします。

表示されたパスワードは後ほど使用するので控えておきます。

MFAの登録

AWS CDKで作成されたIAMユーザーを使ってマネジメントコンソールにログインします。

IAM ユーザーとしてサインイン

パスワードの再設定を行います。

続行するにはパスワードを変更する必要があります

ログインに成功しました。

サインインできたことを確認

MFAの登録を行うためにセキュリティ認証情報をクリックします。

セキュリティ認証情報

自IAMユーザーの認証情報画面が表示されました。MFA未登録の状態ではアクセスキー周りは許可していないので拒否されていますね。

ほとんど情報にアクセスできない

MFAの設定をしていない状態でスイッチロールしようとすると、以下のように拒否されました。

Invalid information in one or more fields

MFAの登録をします。

デバイス名はIAMユーザー名+任意の文字列で設定します。これ以外の名前を指定した場合は拒否されます。今回はIAMユーザー名と同じ名前にしました。

MFA デバイスを選択

MFAコードを入力してMFAを追加をクリックします。

デバイスの設定

正常にMFAデバイスの登録ができました。

しばらく待つとアクセスキーのセルフ発行もできるようになっていました。

少し待てばアクセスキーのセルフ発行も可能に

ちなみに、パスワードポリシーを閲覧する権限は特に許可していないですが、パスワードポリシーに即したパスワードの入力が求められました。

パスワードポリシーの設定内容が反映されている

パスワードポリシー

SSMセッションマネージャーによるEC2インスタンスへの接続

SSMセッションマネージャーによるEC2インスタンスへの接続を試してみます。

事前準備としてProject : Test Project 1SSMSessionRunAs : os-user1のタグが付与されているIAMロールにスイッチロールします。

MFAの設定をしていない状態でスイッチロール

スイッチロール後、EC2のコンソールからProject : Test Project 1が付与されているEC2インスタンスを選択します。

EC2インスタンスの一覧

ちなみに、以下記事で紹介されているようにマネジメントコンソール上で特定のEC2インスタンスのみ表示するということはできません。

セッションマネージャータブから接続をクリックします。

SSMセッションマネージャーで接続

問題なく接続でき、ユーザーもSSMSessionRunAsで指定したos-user1であることができました。

/bin/bash
cd /home/$(whoami)
sh-5.2$ /bin/bash
$ cd /home/$(whoami)
$ whoami
os-user1

AWS CLIからも試してみましょう。

スイッチロールします。

$ export AWS_ACCESS_KEY_ID="<IAMユーザーのアクセスキー>"
$ export AWS_SECRET_ACCESS_KEY="<IAMユーザーのシークレットアクセスキー>"

$ aws sts get-caller-identity
{
    "UserId": "AIDA6KUFAVPUSPF546HYX",
    "Account": "<AWSアカウントID>",
    "Arn": "arn:aws:iam::<AWSアカウントID>:user/non-97-test-user1"
}

$ credentials=$(aws sts assume-role \
  --role-arn arn:aws:iam::<AWSアカウントID>:role/OperationUsersStack-RoleConstructTestProject1osuser-0JMNXpeOCu1T \
  --role-session-name test-user-1-session \
  --serial-number arn:aws:iam::<AWSアカウントID>:mfa/non-97-test-user1 \
  --token-code <MFAコード> \
  | jq -r .Credentials)

$ export AWS_ACCESS_KEY_ID=$(echo "${credentials}" | jq -r .AccessKeyId)
$ export AWS_SECRET_ACCESS_KEY=$(echo "${credentials}" | jq -r .SecretAccessKey)
$ export AWS_SESSION_TOKEN=$(echo "${credentials}" | jq -r .SessionToken)

$ aws sts get-caller-identity
{
    "UserId": "AROA6KUFAVPUTHUBPQEG2:test-user-1-session",
    "Account": "<AWSアカウントID>",
    "Arn": "arn:aws:sts::<AWSアカウントID>:assumed-role/OperationUsersStack-RoleConstructTestProject1osuser-0JMNXpeOCu1T/test-user-1-session"
}

SSMセッションマネージャーで接続します。

$ aws ssm start-session --target i-02b55a14b1e24cbd2

Starting session with SessionId: test-user-1-session-c6us6xzl4qr25gmdl6a3uo3ho4
/bin/bash
cd /home/$(whoami)
sh-5.2$ /bin/bash
$ cd /home/$(whoami)

問題なく接続できました。

せっかくなのでSSMセッションマネージャーのポートフォワーディングも試します。

EC2インスタンスにNginxをインストールしたのち、ポートフォワーディングでEC2インスタンスのTCP/80をローカルマシンのTCP/18080で待ち受けます。

$ aws ssm start-session \
  --target i-02b55a14b1e24cbd2 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["80"], "localPortNumber":["18080"]}'

Starting session with SessionId: test-user-1-session-lvtryv7df6hjeawnmz6eygeouy
Port 18080 opened for sessionId test-user-1-session-lvtryv7df6hjeawnmz6eygeouy.
Waiting for connections...

localhost:18080にアクセスすると、確かにNginxのコンテンツが返されました。

curl localhost:18080 -v
* Host localhost:18080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:18080...
* connect to ::1 port 18080 from ::1 port 64730 failed: Connection refused
*   Trying 127.0.0.1:18080...
* Connected to localhost (127.0.0.1) port 18080
> GET / HTTP/1.1
> Host: localhost:18080
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.24.0
< Date: Wed, 22 May 2024 13:36:13 GMT
< Content-Type: text/html
< Content-Length: 615
< Last-Modified: Fri, 13 Oct 2023 13:33:26 GMT
< Connection: keep-alive
< ETag: "65294726-267"
< Accept-Ranges: bytes
< 
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
* Connection #0 to host localhost left intact

次に、IAMロールとは異なり、Project : Test Project 2のタグが付与されているEC2インスタンスにSSMセッションマネージャーで接続しようとしてみます。

Projectタグが付与されていない場合は拒否される

どうやらssm:GetConnectionStatusのところで弾かれていそうです。

AWS CLIからも試してみましょう。

$ aws ssm start-session --target i-03c25edcfbffaf9b8

An error occurred (AccessDeniedException) when calling the StartSession operation: User: arn:aws:sts::<AWSアカウントID>:assumed-role/OperationUsersStack-RoleConstructTestProject1osuser-0JMNXpeOCu1T/test-user-1-session is not authorized to perform: ssm:StartSession on resource: arn:aws:ec2:us-east-1:<AWSアカウントID>:instance/i-03c25edcfbffaf9b8 because no identity-based policy allows the ssm:StartSession action

こちらも拒否されました。ABACにより意図したとおり制御できていそうです。

EC2インスタンスの停止

EC2インスタンスの停止も確認してみます。

IAMロールとは同じく、Project : Test Project 1のEC2インスタンスを停止しようとしてみます。

EC2インスタンスの停止ができることを確認

問題なくリクエストが受け付けられました。

続いて、IAMロールとは異なり、Project : Test Project 2のEC2インスタンスを停止しようとしてみます。

ABACで許可されていないEC2インスタンスは停止できない

ものすごく怒られました。意図したとおりです。

同じようなリソースを作成したいときに

AWS CDKでMFAの登録を強制するIAMグループと、特定のEC2インスタンスにしかSSMセッションマネージャー接続できないIAMロールを作ってみました。

同じようなリソースを作成したいときにはAWS CDKは便利ですね。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!