都度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種類の要素についてパラメーターを指定します。
- 作成するIAMユーザー
- 作成する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ユーザーの認証情報を管理できるように付与
- 作成した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
を付与
- ABACをするため、各IAMロールにはプロジェクト名を示す
具体的なコードは以下のとおりです。
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 のアクション、リソース、および条件キー - サービス認証リファレンス
上述のステートメントが存在しない場合は、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インスタンスには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セッションマネージャーの設定は以下のとおりです。
この画面上では表示されていませんが、Linux インスタンスの Run As サポートを有効にする
は有効されています。
IAMユーザーのパスワード発行
IAMユーザーのパスワードの発行を行います。
ユーザー一覧から作成したIAMユーザーを選択します。
セキュリティ認証情報
タブを選択して、コンソールアクセスを有効にする
をクリックします。
コンソールアクセスを有効にする
をクリックします。
表示されたパスワードは後ほど使用するので控えておきます。
MFAの登録
AWS CDKで作成されたIAMユーザーを使ってマネジメントコンソールにログインします。
パスワードの再設定を行います。
ログインに成功しました。
MFAの登録を行うためにセキュリティ認証情報
をクリックします。
自IAMユーザーの認証情報画面が表示されました。MFA未登録の状態ではアクセスキー周りは許可していないので拒否されていますね。
MFAの設定をしていない状態でスイッチロールしようとすると、以下のように拒否されました。
MFAの登録をします。
デバイス名はIAMユーザー名+任意の文字列
で設定します。これ以外の名前を指定した場合は拒否されます。今回はIAMユーザー名と同じ名前にしました。
MFAコードを入力してMFAを追加
をクリックします。
正常にMFAデバイスの登録ができました。
しばらく待つとアクセスキーのセルフ発行もできるようになっていました。
ちなみに、パスワードポリシーを閲覧する権限は特に許可していないですが、パスワードポリシーに即したパスワードの入力が求められました。
SSMセッションマネージャーによるEC2インスタンスへの接続
SSMセッションマネージャーによるEC2インスタンスへの接続を試してみます。
事前準備としてProject : Test Project 1
、SSMSessionRunAs : os-user1
のタグが付与されているIAMロールにスイッチロールします。
スイッチロール後、EC2のコンソールからProject : Test Project 1
が付与されているEC2インスタンスを選択します。
ちなみに、以下記事で紹介されているようにマネジメントコンソール上で特定のEC2インスタンスのみ表示するということはできません。
セッションマネージャー
タブから接続
をクリックします。
問題なく接続でき、ユーザーも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セッションマネージャーで接続しようとしてみます。
どうやら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インスタンスを停止しようとしてみます。
問題なくリクエストが受け付けられました。
続いて、IAMロールとは異なり、Project : Test Project 2
のEC2インスタンスを停止しようとしてみます。
ものすごく怒られました。意図したとおりです。
同じようなリソースを作成したいときに
AWS CDKでMFAの登録を強制するIAMグループと、特定のEC2インスタンスにしかSSMセッションマネージャー接続できないIAMロールを作ってみました。
同じようなリソースを作成したいときにはAWS CDKは便利ですね。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!