IAM Identity Center 環境で各 AWS アカウントへのログインを一時的に許可する簡易承認ワークフローを作ってみた

IAM Identity Center 環境で各 AWS アカウントへのログインを一時的に許可する簡易承認ワークフローの作ってみました。 承認されると対象のアカウントにログイン出来るようになる仕組みです。
2023.05.15

1. はじめに

お疲れさまです。とーちです。
AWS アカウントへのログインを承認制にして、一時的にログインを許可したいという需要はそれなりにあるんじゃないかと思います。
以下のブログで、IAM ユーザに対して一時的に特権を付与するワークフローが紹介されていますが、AWS Organizations 及び IAM Identity Center を使っている環境下で実現するとしたらどうなるかを考えてみたのでご紹介したいと思います。

システム要件

以下のような要件とします。

  • IAM Identity Center 導入済み、AWS アカウントへのログインは IAM Identity Center のアクセスポータルから行うものとする
  • 作業者が AWS アカウントにログインしたいときには申請を行う
  • 管理者が申請内容を承認すると作業者が申請した期限に基づき AWS アカウントにログインできるようになる

システム概要

システム概要は以下のような形になります

  • 処理内容
    • AWS Systems Manager Automation(以下 Automation) では以下のような処理を行います。
      1. 承認者が内容を確認し承認を行う
      2. 作業者が指定した開始時刻に起動する EventBridge Scheduler を登録する。EventBridge Scheduler は Lambda を起動する。
      3. 作業者が指定した終了時刻に起動する EventBridge Scheduler を登録する。EventBridge Scheduler は Lambda を起動する。
    • EventBridge Scheduler から起動される Lambda では以下のような処理を行います。
      1. アカウントへのユーザと許可セットの割り当て・割り当て解除
      2. 起動元の EventBridge Scheduler を削除

一時的なログイン権限の割当を実現するためには IAM Identity Center 許可セットのインラインポリシーで「aws:CurrentTime」条件キーを使用する方法等もあるかと思いますが、この方法だと許可セットを都度作成しなければならず、また許可セット内のポリシーが複雑な場合の制御が難しそうだと考えたので、権限を許可する時刻のコントロールには、「EventBridge Scheduler」を使い、申請された時刻のタイミングで、IAM Identity Center の「create-account-assignment」API 等で対象のアカウントへのユーザと許可セットの割り当て・割り当て解除の処理をすることにしました。

なお、IAM Identity Center のアカウント、ユーザ・グループ、許可セットといった概念の考え方については以下の記事が大変参考になりますので、ご確認頂ければと思います。

2. 前提条件

今回は冒頭でもご説明した通り、AWS Organizations 環境下を想定しているので、以下の前提のもと作成していきます。

  • AWS Organizations
    • AWS アカウントが AWS Organizations 組織に参加していること
  • IAM Identity Center
    • ユーザーは作成済み
    • 許可セットは作成済み
  • 後述の CDK で作成するリソースは 管理アカウント(マスターアカウント)に作成する想定

3. 管理者承認システムの構築

今回は CDK(Typescript) を使って構築してみようと思います。
本 CDK で作成される AWS リソースの範囲については上記のシステム概要をご参照ください。

Automation の部分は、以下の記事でもご紹介している「SSM Document CDK」を使って作成します。

また、今回使用するコードの全文は以下のリポジトリに格納しています。

3.1. 前準備

事前に SSM Document CDK のライブラリをインストールしておきます。

> mkdir cdk-ssmAutomation-test/ && cd cdk-ssmAutomation-test
> cdk init app --language=typescript
> npm install @cdklabs/cdk-ssm-documents --save-prod

3.2. CDK コードの説明

CDK のコードは以下の通りとなっています。

lib/cdk_iam-identity-center-temporary-access-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as sns from "aws-cdk-lib/aws-sns";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as snssub from "aws-cdk-lib/aws-sns-subscriptions";
import {
    AutomationDocument,
    AwsApiStep,
    ApproveStep,
    DataTypeEnum,
    DocumentFormat,
    Input,
    HardCodedString,
    HardCodedStringList,
    AwsService,
    OnFailure,
} from "@cdklabs/cdk-ssm-documents";
require("dotenv").config();

export class CdkIamIdentityCenterTemporaryAccessStack extends cdk.Stack {
    readonly myDoc: AutomationDocument;

    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        // 環境変数
        const approverroleArn: string = process.env.APPROVER_ROLE_ARN ?? "";
        const snsSubEmail: string = process.env.SNS_SUB_EMAIL ?? "";
        const iamIdentitycenterArn: string =
            process.env.IAM_IDENTITYCENTER_ARN ?? "";
        const adminPermissionsetArn: string =
            process.env.ADMIN_PERMISSIONSET_ARN ?? "";
        const idStoreID: string =
            process.env.IAM_IDENTITYCENTER_IDSTORE_ID ?? "";
        // IAM Role
        // Lambdaが使用するIAMロール
        const lambdarole = new iam.Role(this, "mylambdarole", {
            assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
            inlinePolicies: {
                ["accountassignment"]: new iam.PolicyDocument({
                    statements: [
                        new iam.PolicyStatement({
                            actions: [
                                "sso:CreateAccountAssignment",
                                "sso:DeleteAccountAssignment",
                                "identitystore:ListUsers",
                                "scheduler:DeleteSchedule",
                            ],
                            effect: iam.Effect.ALLOW,
                            resources: ["*"],
                        }),
                    ],
                }),
            },
        });
        lambdarole.addManagedPolicy(
            iam.ManagedPolicy.fromAwsManagedPolicyName(
                "service-role/AWSLambdaBasicExecutionRole"
            )
        );
        // EventBridgeSchedulerが使用するIAMロール
        const schedulerrole = new iam.Role(this, "myschedulerrole", {
            assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com"),
            inlinePolicies: {
                ["accountassignment"]: new iam.PolicyDocument({
                    statements: [
                        new iam.PolicyStatement({
                            actions: ["lambda:InvokeFunction"],
                            effect: iam.Effect.ALLOW,
                            resources: ["*"],
                        }),
                    ],
                }),
            },
        });
        // 作成するAutomation Runbookが使用するサービスロール
        const automationrole = new iam.Role(this, "myautomationrole", {
            assumedBy: new iam.ServicePrincipal(
                "ssm.amazonaws.com"
            ).withConditions({
                ["StringEquals"]: {
                    "aws:SourceAccount": cdk.Stack.of(this).account,
                },
                ["ArnLike"]: {
                    "aws:SourceArn": `arn:aws:ssm:*:${
                        cdk.Stack.of(this).account
                    }:automation-execution/*`,
                },
            }),
        });
        automationrole.addManagedPolicy(
            iam.ManagedPolicy.fromAwsManagedPolicyName(
                "service-role/AmazonSSMAutomationRole"
            )
        );
        automationrole.addManagedPolicy(
            iam.ManagedPolicy.fromAwsManagedPolicyName(
                "AmazonEventBridgeSchedulerFullAccess"
            )
        );

        // 承認ステップに入ったときに通知で使用するSNS
        const mysns = new sns.Topic(this, "mysns", {
            topicName: "AutomationSnsTopic",
        });
        mysns.addSubscription(new snssub.EmailSubscription(snsSubEmail));

        // Lambda function
        const myLambda = new lambda.Function(this, "MyLambda", {
            runtime: lambda.Runtime.PYTHON_3_9,
            handler: "lambda_function.lambda_handler",
            code: lambda.Code.fromAsset("lambda_src"),
            role: lambdarole,
            environment: {
                ["IAM_IDENTITYCENTER_ARN"]: iamIdentitycenterArn,
                ["ADMIN_PERMISSIONSET_ARN"]: adminPermissionsetArn,
                ["IAM_IDENTITYCENTER_IDSTORE_ID"]: idStoreID,
            },
        });

        // Systems Manager Automation Runbook
        this.myDoc = new AutomationDocument(this, "myAutomationRunbook", {
            documentFormat: DocumentFormat.YAML,
            tags: [{ key: "myTag", value: "myValue" }],
            documentName: "TemporaryPrivilegeWorkflow",
            description:
                "Temporary Privilege Workflow for IAM Identity Center.",
            assumeRole: HardCodedString.of(automationrole.roleArn),
            updateMethod: "NewVersion",
            docInputs: [
                Input.ofTypeString("StartTime", {
                    defaultValue: "2022-11-20T13:00:00",
                    allowedPattern:
                        "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$",
                    description: "(Required) Specify authority use start time.",
                }),
                Input.ofTypeString("EndTime", {
                    defaultValue: "2022-11-20T13:00:00",
                    allowedPattern:
                        "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$",
                    description: "(Required) Specify authority use end time.",
                }),
                Input.ofTypeString("AccountID", {
                    defaultValue: "123456789012",
                    allowedPattern: "[0-9]{12}",
                    description:
                        "(Required) AWS account ID you wish to login with.",
                }),
                Input.ofTypeString("UserName", {
                    defaultValue: "test",
                    description:
                        "(Required) User name used for identitycenter login.",
                }),
            ],
        });
        const approveStep = new ApproveStep(this, "approve", {
            approvers: HardCodedStringList.of([approverroleArn]),
            notificationArn: HardCodedString.of(mysns.topicArn),
            message: HardCodedString.of("Do you approve?"),
            onFailure: OnFailure.abort(),
        });

        const setStartSchdulerStep = new AwsApiStep(this, "startschduler", {
            service: AwsService.SCHEDULER,
            pascalCaseApi: "CreateSchedule", // 実行する API オペレーション名
            apiParams: {
                Name: "StartAccountLinking{{global:DATE_TIME}}",
                FlexibleTimeWindow: {
                    Mode: "OFF",
                },
                ScheduleExpression: "at({{StartTime}})",
                ScheduleExpressionTimezone: "Asia/Tokyo",
                Target: {
                    Arn: HardCodedString.of(myLambda.functionArn),
                    RoleArn: HardCodedString.of(schedulerrole.roleArn),
                    Input: JSON.stringify({
                        accountid: "{{AccountID}}",
                        username: "{{UserName}}",
                        action: "create",
                        schedulerarn: "<aws.scheduler.schedule-arn>",
                    }),
                },
            },
            outputs: [
                {
                    outputType: DataTypeEnum.STRING,
                    name: "ScheduleArn",
                    selector: "$.ScheduleArn",
                },
            ],
        });
        const setEndSchdulerStep = new AwsApiStep(this, "endschduler", {
            service: AwsService.SCHEDULER,
            pascalCaseApi: "CreateSchedule", // 実行する API オペレーション名
            apiParams: {
                Name: "EndAccountLinking{{global:DATE_TIME}}",
                FlexibleTimeWindow: {
                    Mode: "OFF",
                },
                ScheduleExpression: "at({{EndTime}})",
                ScheduleExpressionTimezone: "Asia/Tokyo",
                Target: {
                    Arn: HardCodedString.of(myLambda.functionArn),
                    RoleArn: HardCodedString.of(schedulerrole.roleArn),
                    Input: JSON.stringify({
                        accountid: "{{AccountID}}",
                        username: "{{UserName}}",
                        action: "delete",
                        schedulerarn: "<aws.scheduler.schedule-arn>",
                    }),
                },
            },
            isEnd: true,
            outputs: [
                {
                    outputType: DataTypeEnum.STRING,
                    name: "ScheduleArn",
                    selector: "$.ScheduleArn",
                },
            ],
        });
        this.myDoc.addStep(approveStep);
        this.myDoc.addStep(setStartSchdulerStep);
        this.myDoc.addStep(setEndSchdulerStep);
    }
}

ポイントとなる部分を解説していきます。

Automation Runbook の入力パラメータについて

Automation では入力パラメータのバリデーションを行うことができます。バリデーションは固定値を使用できる他、正規表現パターンで記載することもできます。 バリデーションを設けることで不測のエラーを減らすことができるので積極的に使ったほうがいいでしょう。
以下が CDK でバリデーションを設定している部分になります。

Input.ofTypeString("StartTime", {
    defaultValue: "2022-11-20T13:00:00",
    allowedPattern:
        "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$",
    description: "(Required) Specify authority use start time.",
}),

EventBridge Scheduler の登録処理

Automation Runbook の中で EventBridge Scheduler の登録を行うわけですが、Automation では AWS API を実行することが出来るため、API を使用してスケジューラーを作成しています。API に渡すパラメータは apiParams に記載します。実際にどういったパラメータが API に必要になるかはBoto3 のリファレンスを参考にしてください。
また API に渡すパラメータとして、Automatin Runbook 実行時にユーザーから入力されたパラメータを渡すこともできます。{{入力パラメータ名}} の形で記載します。以下が CDK 内での該当の記載部分になります。

        const setStartSchdulerStep = new AwsApiStep(this, "startschduler", {
            service: AwsService.SCHEDULER,
            pascalCaseApi: "CreateSchedule", // 実行する API オペレーション名
            apiParams: {
                Name: "StartAccountLinking{{global:DATE_TIME}}",
                FlexibleTimeWindow: {
                    Mode: "OFF",
                },
                ScheduleExpression: "at({{StartTime}})",
                ScheduleExpressionTimezone: "Asia/Tokyo",
                Target: {
                    Arn: HardCodedString.of(myLambda.functionArn),
                    RoleArn: HardCodedString.of(schedulerrole.roleArn),
                    Input: JSON.stringify({
                        accountid: "{{AccountID}}",
                        username: "{{UserName}}",
                        action: "create",
                        schedulerarn: "<aws.scheduler.schedule-arn>",
                    }),
                },
            },

その他

  • 本コードではパブリック公開のため、環境変数から IAM Identity Center の ARN 等のシステム固有の値を取得していますが、実際に使用する際はプライベートリポジトリにした上で、cdk.json 等に記載したほうがいいと思います。

3.3. AWS Lambda によるアカウント割り当て・解除

EventBridge Scheduler からは Lambda が起動され、Lambda で IAM Identity Center のアカウント割当(ユーザとアカウントと許可セットの紐づけ)を行います。また Lambda を起動した EventBridge Scheduler はそのままだと残り続けてしまうので、その削除も行っています。
以下がコードになります。

import boto3
import json
import os
import sys

print('Loading function')

# AWS clients
sso = boto3.client('sso-admin')
scheduler = boto3.client('scheduler')
identitystore = boto3.client('identitystore')

# Environment variables
iamIdentitycenterArn = os.getenv('IAM_IDENTITYCENTER_ARN', '')
adminPermissionsetArn = os.getenv('ADMIN_PERMISSIONSET_ARN', '')
idStoreID = os.getenv('IAM_IDENTITYCENTER_IDSTORE_ID', '')


def get_user_id(event):
    """Fetch user id based on username from the event"""
    users = identitystore.list_users(
        IdentityStoreId=idStoreID,
        Filters=[{'AttributePath': 'UserName',
                  'AttributeValue': event['username']}]
    )
    return users["Users"][0]["UserId"]


def lambda_handler(event, context):
    try:
        # print(f"Received event: {json.dumps(event)}")
        user_id = get_user_id(event)

        assignment_args = {
            'InstanceArn': iamIdentitycenterArn,
            'TargetId': event['accountid'],
            'TargetType': 'AWS_ACCOUNT',
            'PermissionSetArn': adminPermissionsetArn,
            'PrincipalType': 'USER',
            'PrincipalId': user_id
        }

        # Perform action based on the event
        if event['action'] == "create":
            response = sso.create_account_assignment(**assignment_args)
        elif event['action'] == "delete":
            response = sso.delete_account_assignment(**assignment_args)
        else:
            print(f"Unknown action: {event['action']}")
            sys.exit(1)

        #if response:
        #    print(response)

        # Delete schedule
        response = scheduler.delete_schedule(
            Name=event['schedulerarn'].rsplit('/', 1)[1])
        #if response:
        #    print(response)
    except Exception as e:
        print(f"An error occurred: {e}")
        sys.exit(1)

各種 API を実行しているだけなので、特筆すべき点はありませんが、create_account_assignment 及び delete_account_assignment で指定するユーザの情報は「ユーザー ID」を指定する必要があります。このために Lambda が受け取ったユーザー名を元にユーザー ID の割り出しを行う「get_user_id」という関数を作成しています。

3.4. 動かしてみた

実際に上記のシステムで申請〜アカウントにログインするところまでをやってみます。

  1. Automation によるワークフロー実行前の状態を確認しておきます。申請対象ユーザの IAM Identity Center の AWS アクセスポータルの画面を開くとログインできる AWS アカウントは存在しない状態となっています

  2. 作業者が AWS Systems Manager の画面から「オートメーション」を選択し、対象の Automation Runbook を実行します。

  3. 承認画面で管理者が承認を行います。承認画面の URL は環境変数で設定した管理者メールアドレス宛にも通知されます。

  4. 承認されると Automation で作成したワークフローが最後まで完了し、2つの EventBridge Scheduler を登録します。

  5. 申請した時刻になったタイミングで、申請対象ユーザの IAM Identity Center の AWS アクセスポータルの画面を更新すると申請した AWS アカウントへのマネージメントコンソールへのリンクが表示されるようになっていました

  6. 対象 AWS アカウントにログインしそのまま操作を続けていると、申請した終了時刻を 1 分ほど過ぎたタイミングでエラーが表示されました。IAM Identity Center のアカウント割当を削除するとちゃんと既存のセッションも終了されるようです。

  7. 念のため、IAM Identity Center の対象アカウントの「割り当てられたユーザとグループ」も確認してみましたが、ちゃんと削除されていました。

3.5. 改善ポイント

上記の内容で最低限の要件は満たせるはずですが、更に改善するためのポイントを思いつく限り書いてみました。

  • 改善ポイント
    • Slack から SystemsManager Automation を呼び出すことができます。SystemsManager のコンソール画面からワークフローを起動するとなると AWS アカウントへのログインが必要になるので、Slack から申請できたほうが良いと思います。
    • IAM Identity Center は AWS Organizations のメンバーアカウントに委任できるので委任したほうがよりセキュアです。委任する場合は以下ブログ記事にあるような点を注意しましょう。
    • 上記のコードではワークフローへの入力として1つのアカウントのみを受け付けていますが、Automation ではリスト型の入力も扱えるので1度の申請で複数のアカウントといったことも可能だと思います。

4. まとめ

以上、IAM Identity Center 環境で各 AWS アカウントへのログインを一時的に許可する簡易承認ワークフローの作成方法でした。
上記のような承認ワークフローを導入する際には、ぜひ、管理者が承認するための工数、作業者が申請するための工数についてもご留意頂ければと思います。これらは積もり積もるとかなりの時間となってくると思いますので、本当にこういった承認ワークフローが必要なのか、なぜ必要なのかはよく検討しましょう。例えば各ユーザが本番系アカウントで作業する際の証跡を残したいという理由ならば、CloudTrail でも十分かもしれません。
導入するとしても本番アカウントのみにしたり、Administrator 権限を使用するときだけにしたりなど工夫は必要かと思います。

この記事が誰かのお役に立てばなによりです。
以上、とーちでした。

参考