※この記事は リンクアンドモチベーション Advent Calendar 2023 の9日目の記事です。
はじめに
こんにちは、リンクアンドモチベーションSREグループの久原です。
今回は念願だったAWSへのSingle Sign On (以下 SSO) 導入を行ったため、導入において工夫したポイントを5つ記載しました。
- ① 手動ではなくコードベースでリソースを管理する
- ② アカウント発行・削除のフローをPRで完結させる
- ③ AWSコンソールのサインイン先アカウントが一目で分かる
- ④ 簡単にAWS CLIのスイッチロールができる
- ⑤ 誤った本番アカウントへのサインインに気づくことができる
それぞれを以下で詳しく説明していきます。
目次
SSO導入の全体像
SSO導入の背景についてはAWSの公式ドキュメントにも記載がある通りです。
SSO導入前後のアーキテクチャは以下のようになります。
SSO導入前のアーキテクチャ
SSO導入前はこちらの記事のように、IAMのスイッチロールを用いて構築を行っていました。
ユーザーはまず踏み台アカウントにログインし、付与されたroleにスイッチロールすることで各アカウントのリソースにアクセスすることができます。
SSO導入後のアーキテクチャ
SSO導入はAWS IAM アイデンティティセンターを用いて行いました。
IDプロバイダは既に弊社で導入していたOktaを採用しました。
管理アカウントでAWS IAM アイデンティティセンターのリソースを構築することで、AWS Organizationsで紐づく各アカウントに自動でroleが作成されます。
ユーザーはAWSアクセスポータルを介して各アカウントのリソースにアクセスすることができます。
その他の各要素に関してはこちらの記事が参考になるかと思います。
本記事ではOkta側の設定については割愛しますが、以下のドキュメントを参考にOkta側の設定を行いました。
SREの体験向上の工夫
上記の構築を行うSREとして、以下の観点で工夫を行いました。
- ① 手動ではなくコードベースでリソースを管理する
- ② アカウント発行・削除のフローをPRで完結させる
今までは全てAWSコンソールから手動でIAMに関するリソース群 (ユーザー, ユーザーグループ, ロール) を構築していました。
しかし、リソースの全体像を把握することが困難で、アカウント発行・削除の履歴を残しづらいという課題がありました。
そのため、Terraformを用いてIAMに関するリソース群を全てコードで管理することにしました。
① 手動ではなくコードベースでリソースを管理する
1. ユーザー・ユーザーグループ・ロールをシンプルに定義
ユーザー・権限・グループの追加や削除をする際に最小限の変更で済むように、シンプルに変数定義できるように記載方法を工夫しました。
# アクセス権限セット
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
で繰り返しリソースを構築しています。
(クリックして展開)
# 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]
}
3. アクセス権限セットへのAWS管理ポリシーのアタッチ
アクセス権限セット × AWS管理ポリシー
の組み合わせの数だけアタッチリソースは存在するため、ローカル変数を用いて変形してからリソースを繰り返し構築しました。
(クリックして展開)
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
}
4. 3と同様に他の関連付けも構築
ユーザー・グループの関連付けはmodulesを用いて、terraform.tfvars
での定義の仕方に沿った構築ができるように記載しました。
(クリックして展開)
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"
}
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}/", "")
}
上記と変数・データソースの定義を行うことで、IAM アイデンティティセンターに関する一連のリソースをTerraformで管理することができました。
② アカウント発行・削除のフローをPRで完結させる
アカウント発行・削除の際、PRベースでスムーズに承認と適用ができるように以下の実装を行いました。
- PR作成時に
terraform plan
を自動実行 - PRをmainマージ時に
terraform apply
を自動実行
applyの自動実行については以下のようにGitHub Actionsのワークフローでterraformコマンド実行用のCodeBuildを呼び出しています。
これにより、SREは発行されるPRのレビューを行うだけでリソースの構築は自動で行われるようになりました。
SSOユーザー(開発者)の体験向上の工夫
SSOのユーザー側の体験を良くするために、以下の観点で工夫を行いました。
- ③ AWSコンソールのサインイン先アカウントが一目で分かる
- ④ 簡単にAWS CLIのスイッチロールができる
- ⑤ 誤った本番アカウントへのサインインに気づくことができる
③ AWSコンソールのサインイン先アカウントが一目で分かる
デフォルトの状態だとバッジにスイッチロール先のアカウント名が表示されないため、現在どのアカウントにいるかがわかりづらいです。
上記の課題に対しては、既に提供されているAWS Peacock Management Console Chrome拡張機能を採用しました。
こちらを利用することで、アカウント名がバッジに表示されるだけでなく、アカウントごとに任意の色分けをすることができます。
④ 簡単にAWS CLIのスイッチロールができる
基本的に、SSOのサインイン画面から以下のCommand line or programmatic access
をクリックして一時的な認証情報を得ることでAWS CLIでの操作が可能となります。
しかし、アカウントを変更する度に毎回AWSアクセスポータルにアクセスして情報を取得するという手間がかかります。
こちらを解消するために、AWSumeとaws-sso-utilを組み合わせることでAWS CLIのスイッチロールをスムーズに行えるようにしました。(参考記事)
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
をクリックし、認証は完了です。
あとは以下のコマンドを叩くだけで簡単にスイッチロールをすることができます。
$ awsume {プロファイル名}
⑤ 誤った本番アカウントへのサインインに気づくことができる
本番アカウントへのサインインは慎重に行いたいですが、権限を持っている場合、誤ってサインインして気づかず作業をしてしまう...という可能性があります。
これを防ぐために、本番アカウントへのサインインがあった際にSlackにメンション付きで通知をする機能を作成しました。
構成は以下のようになっています。
1. Amazon EventBridgeがユーザーのサインインイベントを取得してAWS Lambdaをトリガーする
ユーザーのサインインイベントはAWS CloudTrailにログが残ります。
そのため、Amazon EventBridgeでサインインイベントを検知し、AWS Lambdaをトリガーします。
# 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に通知をするようにしました。
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
(クリックして展開) その他必要なリソース
# 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
}
上記の構築によって、本番アカウントにサインインした際以下のような通知をSlackからユーザーへ送れるようになりました。
最後に
地味なところではありますが、信頼性の担保のためにSSO導入は重要です。
また、生産性という観点でも開発者が日々行うサインイン作業の体験の向上はヒットしていくはずです。
これからも信頼性・生産性の両立を意識した改善を行なっていこうと思います。