AWSのSSO導入において工夫したポイント5選

※この記事は [リンクアンドモチベーション Advent Calendar 2023](https://qiita.com/advent-calendar/2023/lmi) の9日目の記事です。

# はじめに
こんにちは、リンクアンドモチベーションSREグループの久原です。
今回は念願だったAWSへのSingle Sign On (以下 SSO) 導入を行ったため、導入において工夫したポイントを5つ記載しました。

- ① 手動ではなくコードベースでリソースを管理する
- ② アカウント発行・削除のフローをPRで完結させる
- ③ AWSコンソールのサインイン先アカウントが一目で分かる
- ④ 簡単にAWS CLIのスイッチロールができる
- ⑤ 誤った本番アカウントへのサインインに気づくことができる

それぞれを以下で詳しく説明していきます。

# 目次
- [SSO導入の全体像](#sso導入の全体像)
- [SREの体験向上の工夫](#sreの体験向上の工夫)
  - [① 手動ではなくコードベースでリソースを管理する](#-手動ではなくコードベースでリソースを管理する)
  - [② アカウント発行・削除のフローをPRで完結させる](#-アカウント発行削除のフローをprで完結させる)
- [SSOユーザー(開発者)の体験向上の工夫](#ssoユーザー開発者の体験向上の工夫)
  - [③ AWSコンソールのサインイン先アカウントが一目で分かる](#-awsコンソールのサインイン先アカウントが一目で分かる)
  - [④ 簡単にAWS CLIのスイッチロールができる](#-簡単にaws-cliのスイッチロールができる)
  - [⑤ 誤った本番アカウントへのサインインに気づくことができる](#-誤った本番アカウントへのサインインに気づくことができる)

# SSO導入の全体像
SSO導入の背景についてはAWSの公式ドキュメントにも記載がある通りです。
- [SSO (シングルサインオン) とは何ですか?](https://aws.amazon.com/jp/what-is/sso/)

SSO導入前後のアーキテクチャは以下のようになります。

### SSO導入前のアーキテクチャ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/e3210fb3-7aa3-dfef-f62c-3599155efab6.png)

SSO導入前は[こちらの記事](https://dev.classmethod.jp/articles/iam-switchrole-for-beginner/)のように、IAMのスイッチロールを用いて構築を行っていました。
ユーザーはまず踏み台アカウントにログインし、付与されたroleにスイッチロールすることで各アカウントのリソースにアクセスすることができます。

### SSO導入後のアーキテクチャ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/047e28cf-1cf6-f468-b29f-15c536684f1f.png)

SSO導入は[AWS IAM アイデンティティセンター](https://aws.amazon.com/jp/iam/identity-center/)を用いて行いました。
IDプロバイダは既に弊社で導入していた[Okta](https://www.okta.com/jp/)を採用しました。
管理アカウントでAWS IAM アイデンティティセンターのリソースを構築することで、AWS Organizationsで紐づく各アカウントに自動でroleが作成されます。
ユーザーはAWSアクセスポータルを介して各アカウントのリソースにアクセスすることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/e7eed6c5-4e7f-60fb-ea12-74b41dccd6cc.png)


その他の各要素に関しては[こちらの記事](https://dev.classmethod.jp/articles/aws-sso-wakewakame/)が参考になるかと思います。
本記事ではOkta側の設定については割愛しますが、以下のドキュメントを参考にOkta側の設定を行いました。
- [OktaをAWSアカウントのIDプロバイダーとして構成する](https://help.okta.com/oie/ja-jp/content/topics/deploymentguides/aws/aws-configure-identity-provider.htm)


# SREの体験向上の工夫
上記の構築を行うSREとして、以下の観点で工夫を行いました。
- **① 手動ではなくコードベースでリソースを管理する**
- **② アカウント発行・削除のフローをPRで完結させる**

図示すると以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/50256ccf-bedb-1a05-0054-288b05b9d04b.png)


今までは全てAWSコンソールから手動でIAMに関するリソース群 (ユーザー, ユーザーグループ, ロール) を構築していました。
しかし、リソースの全体像を把握することが困難で、アカウント発行・削除の履歴を残しづらいという課題がありました。
そのため、[Terraform](https://www.terraform.io/)を用いてIAMに関するリソース群を全てコードで管理することにしました。

### ① 手動ではなくコードベースでリソースを管理する

#### 1. ユーザー・ユーザーグループ・ロールをシンプルに定義

ユーザー・権限・グループの追加や削除をする際に最小限の変更で済むように、シンプルに変数定義できるように記載方法を工夫しました。

``` hcl:terraform.tfvars
# アクセス権限セット
permission_sets = {
  "admin_role" = {
    description = "administrator role"
    aws_managed_policy_arns = [
      "arn:aws:iam::aws:policy/AdministratorAccess" # この配列に適用したいAWS管理ポリシーを追加する
    ]
  },
  ...
}

groups = {
  "prod_admin" = {
    description = "prod admin group"
    account_id = [
      "${ACCOUNT ID}" # この配列に適用したいアカウントIDを追加する
    ]
    permission_set_name = [
      "admin_role" # この配列に適用したいアクセス権限セットを追加する
    ]
  },
  ...
}

users = {
  "hogefuga@example.com" = {
    display_name = "hoge-fuga"
    last_name    = "hoge"
    first_name   = "fuga"
    assigned_groups = [
      "prod_admin" # この配列に適用したいグループを追加する
    ]
  },
  ...
}
```

#### 2. ユーザー・ユーザーグループ・ロールの構築
上記の変数定義を用いて、ユーザー・ユーザーグループ・ロールの数だけ`for_each`で繰り返しリソースを構築しています。

<details><summary>(クリックして展開)</summary><div>

```hcl:main.tf
# userの作成
resource "aws_identitystore_user" "identitystore_user" {
  for_each = var.users

  identity_store_id = tolist(data.aws_ssoadmin_instances.ssoadmin_instances.identity_store_ids)[0]

  display_name = each.value["display_name"]
  user_name    = each.key

  name {
    given_name  = each.value["last_name"]
    family_name = each.value["first_name"]
  }

  emails {
    value   = each.key
    primary = true
  }
}

# groupの作成
resource "aws_identitystore_group" "identitystore_group" {
  for_each = var.groups

  identity_store_id = tolist(data.aws_ssoadmin_instances.ssoadmin_instances.identity_store_ids)[0]
  display_name      = replace(each.key, "_", "-")
  description       = each.value["description"]
}

# permission setの作成
resource "aws_ssoadmin_permission_set" "PermissionSet" {
  for_each = var.permission_sets

  name         = replace(each.key, "_", "-")
  description  = each.value["description"]
  instance_arn = tolist(data.aws_ssoadmin_instances.ssoadmin_instances.arns)[0]
}
```
</div></details>


#### 3. アクセス権限セットへのAWS管理ポリシーのアタッチ
`アクセス権限セット × AWS管理ポリシー`の組み合わせの数だけアタッチリソースは存在するため、ローカル変数を用いて変形してからリソースを繰り返し構築しました。

<details><summary>(クリックして展開)</summary><div>

```hcl:main.tf
locals {
  permission_set_aws_managed_policy_map = flatten([
    for ps_name, ps_values in var.permission_sets : [
      for policy_arn in ps_values.aws_managed_policy_arns : {
        permission_set_name    = ps_name
        aws_managed_policy_arn = policy_arn
      }
    ]
  ])
}

# AWS管理ポリシー・アクセス権限セットの関連付け
resource "aws_ssoadmin_managed_policy_attachment" "AwsManagedPolicyAttachment" {
  for_each = { for i in local.permission_set_aws_managed_policy_map : "${i.permission_set_name}-${i.aws_managed_policy_arn}" => i }

  instance_arn       = aws_ssoadmin_permission_set.PermissionSet[each.value.permission_set_name].instance_arn
  managed_policy_arn = each.value.aws_managed_policy_arn
  permission_set_arn = aws_ssoadmin_permission_set.PermissionSet[each.value.permission_set_name].arn
}
```

</div></details>

#### 4. 3と同様に他の関連付けも構築
ユーザー・グループの関連付けはmodulesを用いて、`terraform.tfvars`での定義の仕方に沿った構築ができるように記載しました。

<details><summary>(クリックして展開)</summary><div>

```hcl: main.tf
locals {
  permission_set_account_group_map = flatten([
    for group_name, group in var.groups : [
      for account_id in group.account_id : [
        for permission_set_name in group.permission_set_name : {
          permission_set_name = permission_set_name
          group_name          = group_name
          account_id          = account_id
        }
      ]
    ]
  ])
  group_ids_map = {
    for user, details in var.users : user => {
      group_ids = [for group_name in details.assigned_groups : aws_identitystore_group.identitystore_group[group_name].id]
    }
  }
}

# ユーザー・グループの関連付け
module "aws_identitystore_group_membership" {
  for_each = var.users

  source = "./modules/group_membership"

  identity_store_id = tolist(data.aws_ssoadmin_instances.ssoadmin_instances.identity_store_ids)[0]

  user_id   = aws_identitystore_user.identitystore_user[each.key].user_id
  group_ids = local.group_ids_map[each.key].group_ids
}

# アカウント・アクセス権限セット・グループの関連付け
resource "aws_ssoadmin_account_assignment" "AccountAssignment" {
  for_each = { for i in local.permission_set_account_group_map : "${i.permission_set_name}-${i.group_name}-${i.account_id}" => i }

  instance_arn       = tolist(data.aws_ssoadmin_instances.ssoadmin_instances.arns)[0]
  permission_set_arn = aws_ssoadmin_permission_set.PermissionSet[each.value.permission_set_name].arn

  principal_id   = aws_identitystore_group.identitystore_group[each.value.group_name].group_id
  principal_type = "GROUP"

  target_id   = each.value.account_id
  target_type = "AWS_ACCOUNT"
}
```

```hcl: modules/group_membership/main.tf
resource "aws_identitystore_group_membership" "group_membership" {
  count = length(var.group_ids)

  identity_store_id = var.identity_store_id
  member_id         = var.user_id
  group_id          = replace(var.group_ids[count.index], "${var.identity_store_id}/", "")
}

```

</div></details>

上記と変数・データソースの定義を行うことで、IAM アイデンティティセンターに関する一連のリソースをTerraformで管理することができました。

### ② アカウント発行・削除のフローをPRで完結させる

アカウント発行・削除の際、PRベースでスムーズに承認と適用ができるように以下の実装を行いました。

- PR作成時に `terraform plan` を自動実行
- PRをmainマージ時に `terraform apply` を自動実行

applyの自動実行については以下のようにGitHub Actionsのワークフローでterraformコマンド実行用のCodeBuildを呼び出しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/771f6aaa-40b5-3278-2e33-679d528fdfbb.png)


これにより、SREは発行されるPRのレビューを行うだけでリソースの構築は自動で行われるようになりました。


# SSOユーザー(開発者)の体験向上の工夫
SSOのユーザー側の体験を良くするために、以下の観点で工夫を行いました。

- **③ AWSコンソールのサインイン先アカウントが一目で分かる**
- **④ 簡単にAWS CLIのスイッチロールができる**
- **⑤ 誤った本番アカウントへのサインインに気づくことができる**

### ③ AWSコンソールのサインイン先アカウントが一目で分かる

デフォルトの状態だとバッジにスイッチロール先のアカウント名が表示されないため、現在どのアカウントにいるかがわかりづらいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/32358d3e-5108-c047-e956-d413e9138e82.png)


上記の課題に対しては、既に提供されている[AWS Peacock Management Console Chrome拡張機能](https://dev.classmethod.jp/articles/aws-peacock-management-console/)を採用しました。
こちらを利用することで、アカウント名がバッジに表示されるだけでなく、アカウントごとに任意の色分けをすることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/c6dac059-b892-2b06-cf9e-2720921fcce7.png)

### ④ 簡単にAWS CLIのスイッチロールができる
基本的に、SSOのサインイン画面から以下の`Command line or programmatic access`をクリックして一時的な認証情報を得ることでAWS CLIでの操作が可能となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/600f2724-4b30-1161-94ec-30dc2e2b672c.png)

しかし、アカウントを変更する度に毎回AWSアクセスポータルにアクセスして情報を取得するという手間がかかります。
こちらを解消するために、[AWSume](https://awsu.me/)と[aws-sso-util](https://github.com/benkehoe/aws-sso-util)を組み合わせることでAWS CLIのスイッチロールをスムーズに行えるようにしました。([参考記事](https://htnosm.hatenablog.com/entry/2023/01/02/090000))

AWSumeの導入は[こちらの記事](https://dev.classmethod.jp/articles/awsume/)を参考に行いました。
次に、aws-sso-utilのインストールを行います。
```
$ pip install aws-sso-util 
```

最後に、以下のように`.aws/config`ファイルに記載をします。これで設定は完了です。
```
[profile {プロファイル名}]
sso_start_url      = https://{ssoポータルのurl}.awsapps.com/start
sso_region         = ap-northeast-1
sso_account_id     = {アカウントid}
sso_role_name      = admin-role
credential_process = aws-sso-util credential-process --profile {プロファイル名}
```

実際にサインインする際は、まず以下のコマンドでSSOの認証を行います。
```
$ aws-sso-util login
```
以下の画面にリダイレクトするので、`Confirm and continue`をクリックします。次の画面で`Allow`をクリックし、認証は完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/08e51a14-3d32-5c45-f044-687cb2662643.png)

あとは以下のコマンドを叩くだけで簡単にスイッチロールをすることができます。
```
$ awsume {プロファイル名}
```

### ⑤ 誤った本番アカウントへのサインインに気づくことができる
本番アカウントへのサインインは慎重に行いたいですが、権限を持っている場合、誤ってサインインして気づかず作業をしてしまう...という可能性があります。
これを防ぐために、本番アカウントへのサインインがあった際にSlackにメンション付きで通知をする機能を作成しました。
構成は以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/1bfcc2ff-8ef0-5f5d-f554-d9e731119fdb.png)

#### 1. Amazon EventBridgeがユーザーのサインインイベントを取得してAWS Lambdaをトリガーする
ユーザーのサインインイベントはAWS CloudTrailにログが残ります。
そのため、Amazon EventBridgeでサインインイベントを検知し、AWS Lambdaをトリガーします。
```hcl:main.tf
# AWSサインイン時の通知用AWS EventBridgeルール
resource "aws_cloudwatch_event_rule" "notify_event_rule" {
  name          = "notify_signin_to_slack"
  description   = "Trigger Lambda on signin event"
  event_pattern = jsonencode({
    "detail-type" : ["AWS Console Sign In via CloudTrail"]
  })
}
```

ポイントはイベントパターンの箇所で、 `AWS Console Sign In via CloudTrail` を指定することで、サインインイベントを検知できます。

#### 2. AWS LambdaでSlack通知
Amazon EventBridgeから取得した `userIdentity.arn`の情報をもとに、 `ROLE_NAME` で指定したroleでのアクセスがあった時にSlackに通知をするようにしました。
```python:lambda/notify_signin_to_slack.py
import json
import urllib.request
import os


def lambda_handler(event, context):
    print(json.dumps(event))

    print(event['detail']['userIdentity']['arn'])
    arn = event['detail']['userIdentity']['arn']
    webhook_url = os.environ['RELEASE_CH_WEBHOOK_URL']
    mention_ids = os.environ['SLACK_MENTION_IDS']
    role_name = os.environ['ROLE_NAME']
    if role_name in arn:
        post_slack(arn, mention_ids, webhook_url, role_name)


def post_slack(arn, mention, webhook_url, role_name):
    username = arn.split('/')[-1].split('@')[0]
    aws_account_alias = os.environ['AWS_ACCOUNT_ALIAS']
    message = {
        "attachments": [
            {
                "color": "#f9ee00",
                "pretext": "{}".format(mention),
                "author_name": f"【警告】{aws_account_alias}アカウントへ{role_name}権限でのアクセスが発生しました",
                "title": f"{role_name}権限のアクセス通知",
                "text": f"{username}が{aws_account_alias}アカウントに{role_name}権限でアクセスしました。スレッドに用途を記載してください。",
                "fields": [
                    {
                        "title": "user_arn",
                        "value": arn,
                        "short": False
                    }
                ],
            }
        ]
    }

    send_text = "payload=" + json.dumps(message)

    request = urllib.request.Request(
        webhook_url,
        data=send_text.encode("utf-8"),
        method="POST"
    )

    with urllib.request.urlopen(request):
        pass
```

<details><summary>(クリックして展開) その他必要なリソース</summary><div>

```hcl:main.tf
# AWSサインイン時の通知用Lambda
resource "aws_lambda_function" "lambda_notify_signin_to_slack" {
  function_name    = "notify_signin_to_slack"
  handler          = "notify_signin_to_slack.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_execution_role.arn
  filename         = data.archive_file.lambda_code.output_path
  source_code_hash = data.archive_file.lambda_code.output_base64sha256

  environment {
    variables = {
      RELEASE_CH_WEBHOOK_URL = "${var.release_ch_webhook_url}"
      SLACK_MENTION_IDS      = "${var.slack_mention_ids}"
      ROLE_NAME              = "${var.role_name}"
      AWS_ACCOUNT_ALIAS      = "${var.aws_account_alias}"
    }
  }
}

resource "aws_iam_role" "lambda_execution_role" {
  name  = "notify_signin_to_slack_lambda_role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "lambda_execution_role_policy" {
  role       = aws_iam_role.lambda_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowExecutionFromEventBridge"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda_notify_signin_to_slack.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.notify_event_rule.arn
}

resource "aws_cloudwatch_event_target" "notify_event_target" {
  rule  = aws_cloudwatch_event_rule.notify_event_rule.name
  arn   = aws_lambda_function.lambda_notify_signin_to_slack.arn
}
```
</div></details>

上記の構築によって、本番アカウントにサインインした際以下のような通知をSlackからユーザーへ送れるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2340929/e820a3dc-1b37-a018-ccbe-ca2d59e7d379.png)

# 最後に
地味なところではありますが、信頼性の担保のためにSSO導入は重要です。
また、生産性という観点でも開発者が日々行うサインイン作業の体験の向上はヒットしていくはずです。
これからも信頼性・生産性の両立を意識した改善を行なっていこうと思います。