シンプルな設定ファイルで実現する AWS IAM Identity Center のユーザー管理と開発チームへの委譲

エス・エム・エスで全社 SRE というロールで活動している Security Hub 芸人の山口(@yamaguchi_tk)です。 おすすめのAWSサービスは営業です(いつもお世話になっています)。

1. はじめに

1.1 背景

株式会社エス・エム・エスでは、全社横断の SRE チームが AWS Organizations 配下で 130 以上の AWS アカウントと、200 名を超える開発者の認証と認可を管理しています。AWS IAM Identity Center の導入と Terraform による IaC 化、CI/CD による自動デプロイは整っていますが、ユーザー追加や権限変更のリクエストが積み重なり、運用負荷は増加する一方でした。

1.2 目指す姿

私たちが狙ったゴールはシンプルです。

  • 運用負荷の軽減
    • 設定ファイルベースで権限管理し CI/CD で自動化
  • 開発チームへの安全な委譲
    • 直感的に編集できるテキストファイルで「自分が担当しているアカウントの権限付与は自分たちで管理」

以下では、これを実現するために採ったアプローチと具体的なコードをご紹介します。

TL;DR:

「ヒトに権限を割り当てる」変更はテキストファイルを直すだけ。あとは CI/CD が全部やってくれる、そんな世界を Terraform とAWS IAM Identity Center で実現しました。

2. AWS IAM Identity Center の基本

2.1 機能概要

  • シングルサインオン (SSO)
    • 一度の認証で複数アカウントにアクセス
    • Azure AD や Okta など外部 IdP との連携
  • マルチアカウント管理
    • 全てアカウントへのアクセス制御を統合
  • 許可セット
    • IAMポリシーをテンプレート化して再利用
    • ユーザー/グループ単位で割り当て

2.2 主要な概念

  • ユーザー
    • Identity Center で管理される個々の利用者
    • メールアドレスを一意の識別子として使用
  • グループ
    • ユーザーの集合を管理する単位
  • 許可セット
    • ポリシーとロールのテンプレート
  • アカウント割り当て
    • アクセス制御の基本単位
    • ユーザー/グループとアカウントの紐付け
    • 許可セットの適用

3. Terraform での実装

3.1 標準的な実装例

標準的な Terraform による実装では、以下のようなコードを記述します。

# ユーザーの定義
resource "aws_identitystore_user" "example" {
  identity_store_id = aws_ssoadmin_instances.example[0].identity_store_id
  display_name      = "Example User"
  user_name         = "user@example.com"
  emails {
    value = "user@example.com"
  }
}

# グループの定義
resource "aws_identitystore_group" "example" {
  identity_store_id = aws_ssoadmin_instances.example[0].identity_store_id
  display_name      = "Example Group"
  description       = "Example group for testing"
}

# ユーザーをグループに割り当て
resource "aws_identitystore_group_membership" "example" {
  identity_store_id = aws_ssoadmin_instances.example[0].identity_store_id
  group_id          = aws_identitystore_group.example.group_id
  member_id         = aws_identitystore_user.example.user_id
}

# 許可セットの定義
resource "aws_ssoadmin_permission_set" "example" {
  name             = "ExamplePermissionSet"
  description      = "Example permission set"
  instance_arn     = aws_ssoadmin_instances.example[0].arn
  session_duration = "PT1H"
}

# 許可セットへポリシーのアタッチ
resource "aws_ssoadmin_managed_policy_attachment" "example" {
  instance_arn       = aws_ssoadmin_instances.example[0].arn
  permission_set_arn = aws_ssoadmin_permission_set.example.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

# ユーザーをアカウントに割り当て
resource "aws_ssoadmin_account_assignment" "example" {
  instance_arn       = aws_ssoadmin_instances.example[0].arn
  permission_set_arn = aws_ssoadmin_permission_set.example.arn
  target_id          = "123456789012"
  target_type        = "AWS_ACCOUNT"
  principal_id       = aws_identitystore_user.example.user_id
  principal_type     = "USER"
}

標準的な実装では、各リソースを個別に定義し、リソース間の依存関係を明示的に記述します。

例えば、グループメンバーシップの定義では、ユーザーとグループの ID を直接参照(リソース指定することも可能)する必要があります。

3.2 標準的な実装例での Terraform Plan 例

標準的な実装例での Terraform Plan 例を以下に示します。

# aws_identitystore_user.example will be created
+ resource "aws_identitystore_user" "example" {
    + display_name = "Example User"
    + identity_store_id = "d-1234567890"
    + user_id = (known after apply)
    + user_name = "user@example.com"
    + emails {
        + value = "user@example.com"
    }
}

# aws_identitystore_group_membership.example will be created
+ resource "aws_identitystore_group_membership" "example" {
    + group_id = "1234567890abcdef"
    + identity_store_id = "d-1234567890"
    + member_id = (known after apply)
}

# aws_ssoadmin_account_assignment.example will be created
+ resource "aws_ssoadmin_account_assignment" "example" {
    + instance_arn = "arn:aws:sso:::instance/ssoins-1234567890abcdef"
    + permission_set_arn = "arn:aws:sso:::permissionSet/ssoins-1234567890abcdef/ps-1234567890abcdef"
    + principal_id = (known after apply)
    + principal_type = "USER"
    + target_id = "123456789012"
    + target_type = "AWS_ACCOUNT"
}

許可セットを付与する対象のユーザーが内部 ID(ssoins-1234567890abcdefなど)で表示されています。

3.3 標準的な実装における課題

標準的な実装では、以下のような課題があります。

コードが複雑になる

  • 多数のリソース定義が必要となり、コード量が膨大になる
  • リソース間の依存関係の管理が複雑
  • 変更の影響範囲の把握が困難

レビューの困難さ

  • 内部 ID による表示のため、Terraform Plan で変更内容を把握することが難しい

3.4 課題を解決するために採用したアプローチ

これらの課題に対処するために、私たちは三つの方針に沿って改善を進めました。

コード量の削減

  • Assignment 等の実装をテキストファイル化する

レビューを楽に

  • Terraform Plan にメールアドレス、グループ名、許可セット名を表示する

開発チームに安全に委譲できるように

  • 安全に変更できるように AWS アカウント単位で委譲できる仕組みにする
  • ディレクトリ、ファイル名だけで目的が伝わる構造にする

4. 具体的な実装

4.1 ディレクトリ構造

terraform/
├── user/                    # ユーザー管理
│   ├── user.txt            # ユーザーリスト(メールアドレス)
│   └── dummy.tf
├── membership/             # グループメンバーシップ
│   ├── XXX_Developers.txt  # 開発者グループ
│   ├── XXX_SREs.txt       # SREグループ
│   ├── YYY_Developers.txt  # 開発者グループ
│   ├── YYY_SREs.txt       # SREグループ
│   └── dummy.tf
├── assignment/             # アクセス権限の割り当て
│   ├── 12345678/          # AWSアカウントID
│   │   ├── AdministratorAccess_GROUP.txt
│   │   ├── PowerUserAccess_GROUP.txt
│   │   ├── AdministratorAccess_USER.txt
│   │   ├── PowerUserAccess_USER.txt
│   │   └── dummy.tf
│   ├── 23456789/
│   └── ...
├── su/                    # symlinkによる組織構造
│   ├── 医療キャリア/      # 開発者グループ
│   │   ├── prd -> ../../assignment/12345678
│   │   ├── stg -> ../../assignment/23456789
│   │   └── dev
└── others/                   # Terraformの実装コード
    ├── variables.tf          # 変数定義
    ├── assignmeents.tf       # assignmentの実装コード
    ├── assignmeents_dummy.tf # dummy.tfを読み込むための実装コード
    ├── memberships.tf        # group membershipの実装コード
    ├── permissionsets.tf     # permission setの実装コード
    ├── users.tf              # userの定義コード
    └── groups.tf             # Groupの実装コード

4.2 設定ファイルの実装例

ユーザー管理(user/user.txt)

Identiy Center に登録するユーザーのメールアドレスを記載します。

user1@example.com
user2@example.com

グループ管理(membership/XXX_Developers.txt, membership/XXX_SREs.txt)

{グループ名}.txt という形式でファイルを作成します。
Group に所属させるユーザーのメールアドレスのメールアカウント部分(@より左側)を記載します。

user1
user2

アクセス権限・グループ(assignment/12345678/AdministratorAccess_GROUP.txt)

{許可セット名}_GROUP.txt という形式でファイルを作成します。
ディレクトリ名の AWS アカウント ID で、テキストファイル名のアンダースコア `_` の左側に記載の許可セットを付与する Group 名を記載します。

XXX_SREs

アクセス権限・ユーザー(assignment/12345678/AdministratorAccess_USER.txt)

{許可セット名}_USER.txt という形式でファイルを作成します。
ディレクトリ名の AWS アカウント ID で、テキストファイル名のアンダースコア `_` の左側に記載の許可セットを付与する ユーザーのメールアドレスのメールアカウント部分(@より左側)を記載します。

user1
user2

※ 弊社の環境では、Identity Center への認証は外部 IdP による SSO からのアクセスのみに絞っているので、@より右側のドメイン名は全ユーザー共通です。

4.4 実装コード

user 定義(others/users.tf)

################################################################################
# User
################################################################################
resource "aws_identitystore_user" "this" {
  for_each = local.users

  display_name      = each.value
  identity_store_id = "d-abc1234567"
  user_name         = each.value

  emails {
    primary = true
    type    = "work"
    value   = each.value
  }
  name {
    family_name = " "
    given_name  = " "
  }
}

################################################################################
# Users
################################################################################
locals {
  user_file = "../user/user.txt"
  # ファイルが存在すれば読み込む、なければ空リスト
  emails = (can(fileexists(local.user_file)) && fileexists(local.user_file)
  ? split("\n", trimspace(file(local.user_file))) : [])

  users = {
    for email in local.emails : regex("(^[^@]+)", email)[0] => email
  }
}

################################################################################
# dummy module
################################################################################
module "dummy_user" {
  source = "../user"
}

Group 定義(others/groups.tf)

################################################################################
# Groups
################################################################################
locals {
  groups = [
    "XXX_SREs",
    "XXX_Developers",
    "YYY_SREs",
    "YYY_Developers",
  ]

  group_map = {
    for group in local.groups :
    group => {
      group_name = group
    }
  }
}

resource "aws_identitystore_group" "this" {
  for_each = local.group_map

  display_name      = each.value.group_name
  identity_store_id = "d-abc123456"

}

assignment 定義(others/assignments.tf)

locals {
  merged_targets = concat(local.assignment_target_users, local.assignment_target_groups)

  # assignments_targets の各要素に対して処理
  #  {
  #    file_name = "PowerUserAccess_USER.txt"
  #    permission_set_arn = "arn:aws:sso:::permissionSet/..."
  #    principal_type     = "USER"
  #
  #    file_exists = true/false
  #    raw_user_names = [...]
  #    user_ids_map = { "alice" = "uuid1", "bob" = "uuid2" }
  #  }
  # のような構造を作る
  flatten_targets = flatten([
    for account in local.assignment_target_aws_accounts : [
      for t in local.merged_targets : {
        file_path          = "${local.assignment_file_path}/${account}/${t.file_name}"
        file_name          = t.file_name
        account            = account
        permission_set_arn = t.permission_set_arn
        principal_type     = t.principal_type
      }
    ]
  ])

  assignment_targets_expanded = [
    for t in local.flatten_targets : {
      file_name           = t.file_name
      account             = t.account
      resourcename_prefix = trimsuffix(t.file_name, ".txt")
      permission_set_arn  = t.permission_set_arn
      principal_type      = t.principal_type

      file_path   = t.file_path
      file_exists = can(fileexists(t.file_path)) && fileexists(t.file_path)

      # ファイルが存在すれば読み込む、なければ空リスト
      raw_user_names = (can(fileexists(t.file_path)) && fileexists(t.file_path)
      ? split("\n", trimspace(file(t.file_path))) : [])

      # TXTファイルが空の場合を考慮
      # 空文字列""の場合はLISTから外す
      user_names = [
        for name in can(fileexists(t.file_path)) && fileexists(t.file_path)
        ? split("\n", trimspace(file(t.file_path))) : [] :
        name
        if name != ""
      ]
    }
  ]
}

###################################
# assignment_targets_expanded をフラットに合体
###################################
#  例: {
#    "PowerUserAccess_USER.txt-alice" = { user_id="xxx", permission_set_arn="yyy", principal_type="USER" }
#    "PowerUserAccess_USER.txt-bob"   = { user_id="xxx", permission_set_arn="yyy", principal_type="USER" }
#    "AdministratorAccess_USER.txt-charlie" = { ... }
#  }
locals {
  combined_assignment_users = merge([
    # assignment_targets_expanded の各要素 t に対して…
    for t in local.assignment_targets_expanded : {
      # さらに t.user_ids_map の各 entry (user_name, user_id) について…
      for user_name in t.user_names :
      # キーを "<awsaccount_id>_<file_name>_<user_name>"、値をオブジェクトにする
      "${t.account}_${t.resourcename_prefix}_${user_name}" => {
        user_name          = user_name
        account            = t.account
        permission_set_arn = t.permission_set_arn
        principal_type     = t.principal_type
      }
    }
  ]...)
}

###################################
# for_each で一括作成
###################################
#  combined_assignments には「(ファイル名)-(ユーザー名)」がキーになっている
resource "aws_ssoadmin_account_assignment" "this" {
  for_each = local.combined_assignment_users

  instance_arn   = local.instance_arn
  target_id      = each.value.account
  target_type    = "AWS_ACCOUNT"
  principal_id   = each.value.principal_type == "USER" ? aws_identitystore_user.this[each.value.user_name].user_id : aws_identitystore_group.this[each.value.user_name].group_id
  principal_type = each.value.principal_type

  permission_set_arn = each.value.permission_set_arn
}

membership 定義(others/memberships.tf)

locals {
  # memberships_targets の各要素に対して処理
  #  {
  #    file_name = "SREs.txt"
  #    group_id = "..."
  #
  #    file_exists = true/false
  #    raw_user_names = [...]
  #    user_ids_map = { "alice" = "uuid1", "bob" = "uuid2" }
  #  }
  # のような構造を作る
  memberships_targets_expanded = [
    for t in local.memberships_targets : {
      file_name           = t.file_name
      resourcename_prefix = trimsuffix(t.file_name, ".txt")
      group_name          = trimsuffix(t.file_name, ".txt")

      file_path   = "${local.membership_file_path}/${t.file_name}"
      file_exists = can(fileexists("${local.membership_file_path}/${t.file_name}")) && fileexists("${local.membership_file_path}/${t.file_name}")

      # ファイルが存在すれば読み込む、なければ空リスト
      raw_user_names = (can(fileexists("${local.membership_file_path}/${t.file_name}")) && fileexists("${local.membership_file_path}/${t.file_name}")
      ? split("\n", trimspace(file("${local.membership_file_path}/${t.file_name}"))) : [])

      # TXTファイルが空の場合を考慮
      # 空文字列""の場合はLISTから外す
      user_names = [
        for name in can(fileexists("${local.membership_file_path}/${t.file_name}")) && fileexists("${local.membership_file_path}/${t.file_name}")
        ? split("\n", trimspace(file("${local.membership_file_path}/${t.file_name}"))) : [] :
        name
        if name != ""
      ]
    }
  ]
}

###################################
# membership_targets_expanded をフラットに合体
###################################
#  例: {
#    "SREs-alice" = { group_id="xxx", user_id="xxx" }
#    "SREs-bob"   = { group_id="xxx",user_id="xxx" }
#    "SREs-charlie" = { ... }
#  }
locals {
  combined_memberships = merge([
    # assignment_targets_expanded の各要素 t に対して…
    for t in local.memberships_targets_expanded : {
      # さらに t.user_ids_map の各 entry (user_name, user_id) について…
      for user_name in t.user_names :
      # キーを "<file_name>_<user_name>"、値をオブジェクトにする
      "${t.resourcename_prefix}_${user_name}" => {
        group_name = t.group_name
        user_name  = user_name
      }
    }
  ]...)
}

###################################
# for_each で一括作成
###################################
#  combined_memberships には「(ファイル名)_(ユーザー名)」がキーになっている
resource "aws_identitystore_group_membership" "this" {
  for_each = local.combined_memberships

  identity_store_id = "d-95671a55c2"
  group_id          = aws_identitystore_group.this[each.value.group_name].group_id
  member_id         = aws_identitystore_user.this[each.value.user_name].user_id
}

################################################################################
# dummy module
################################################################################
module "dummy_menbership" {
  source = "../membership"
}

variables 定義(others/variables.tf)

locals {
  instance_arn = tolist(data.aws_ssoadmin_instances.instance.arns)[0]

  assignment_file_path = "../assignment"
  # assignment以下のフォルダを列挙
  assignment_target_aws_accounts = distinct([
    for file in fileset(local.assignment_file_path, "*/*.txt") : dirname(file)
  ])

  membership_file_path = "../membership"
}

locals {
  # assignments_targets の定義
  assignment_target_groups = [
    {
      file_name          = "AdministratorAccess_GROUP.txt"
      permission_set_arn = aws_ssoadmin_permission_set.AdministratorAccess.arn
      principal_type     = "GROUP"
    },
    {
      file_name          = "DeveloperAccess_GROUP.txt"
      permission_set_arn = aws_ssoadmin_permission_set.DeveloperAccess.arn
      principal_type     = "GROUP"
    },
  ]

  assignment_target_users = [
    {
      file_name          = "AdministratorAccess_USER.txt"
      permission_set_arn = aws_ssoadmin_permission_set.AdministratorAccess.arn
      principal_type     = "USER"
    },
    {
      file_name          = "Developer_USER.txt"
      permission_set_arn = aws_ssoadmin_permission_set.Developer.arn
      principal_type     = "USER"
    },
  ]
}

locals {
  memberships_targets = [
    {
      file_name = "XXX_SREs.txt"
    },
    {
      file_name = "XXX_Developers.txt"
    },
    {
      file_name = "YYY_SREs.txt"
    },
    {
      file_name = "YYY_Developers.txt"
    },
  ]
}

dummy.tf 読み込み(others/assignments_dummy.tf)

module "dummy_12345678" {
  source = "../assignment/12345678"
}

module "dummy_23456789" {
  source = "../assignment/23456789"
}
...

4.5 実装のポイント

設定ファイルの読み込みは、Terraform の file() 関数を使用して実装しています。ファイルの内容を行ごとに分割し、trimspace() で空白を除去することで、クリーンなデータを取得します。空行は if user != "" の条件で除外し、有効なデータのみを処理対象としています。

リソースの生成は、for_each を使用して動的に行います。ネストされたリストは flatten()で平坦化し、一意のキーを生成することで重複を防止しています。これにより、設定ファイルの変更が効率的にリソースの更新に反映されます。

エラーハンドリングは、ファイルの存在確認から始まります。存在しないファイルに対しては file() 関数がエラーを返し、不正な形式のデータは split()trimspace() で適切に処理されます。また、存在しないユーザーやグループへの参照は、Terraform の実行時にエラーとして検出されます。

今回は tfaction を利用することを前提としているため、設定ファイルを置いているディレクトリに dummy.tf を配置し、ディレクトリ自体を module として扱っています。
tfaction-root.yaml に以下の設定を追加することで module 側で変更があった場合(=設定ファイルに変更があった場合)に Terraform Plan が実行されるようになります。

update_local_path_module_caller:
  enabled: true

4.6 実装したコードでの Terraform Plan 表示

  # aws_identitystore_group_membership.this["XXX-SREs_user1"] will be created
  + resource "aws_identitystore_group_membership" "this" {
      + group_id          = "abc12345-1234-1234-1234-abc123456789"
      + id                = (known after apply)
      + identity_store_id = "d-1234567890"
      + member_id         = (known after apply)
      + membership_id     = (known after apply)
    }

  # aws_ssoadmin_account_assignment.this["123456789012_PowerUserAccess_USER_user1"] will be created
  + resource "aws_ssoadmin_account_assignment" "this" {
      + id                 = (known after apply)
      + instance_arn       = "arn:aws:sso:::instance/ssoins-1234567890abcdef"
      + permission_set_arn = "arn:aws:sso:::permissionSet/ssoins-1234567890abcdef/ps-1234567890abcdef"
      + principal_id       = (known after apply)
      + principal_type     = "USER"
      + target_id          = "123456789012"
      + target_type        = "AWS_ACCOUNT"
    }

  # aws_identitystore_user.this["user1"] will be created
  + resource "aws_identitystore_user" "this" {
      + display_name      = "user1@example.com"
      + external_ids      = (known after apply)
      + id                = (known after apply)
      + identity_store_id = "d-1234567890"
      + user_id           = (known after apply)
      + user_name         = "user1@example.com"

      + emails {
          + primary = true
          + type    = "work"
          + value   = "user1@example.com"
        }

      + name {
          + family_name = " "
          + given_name  = " "
        }
    }

4.7 開発チームへの委譲

ここまで実装できると、AWS アカウント単位に作成したディレクトリ単位や、XXX_GROUP.txt 単位で CODEOWNER を開発チームに設定することで、開発チームが管理しているグループへのメンバー追加や AWS アカウントへの権限付与について、開発チームが自律的な対応が可能になります。

4.8 課題の解決

コードが複雑になる

日常的に変更する必要のあるコードをシンプルにすることで解決しました。

具体的には、ユーザーの追加、グループへのユーザー追加、ユーザー・グループへの権限付与について、プレーンなテキストファイルによる設定ファイル方式を採用することで、コードをシンプルに保つことができるようになりました。

レビューの困難さ

内部 ID ではなく、人間が見て変更内容がわかるような Plan 表示を行うことで解決しました。

具体的には、「誰に」「何の権限を」「どの AWS アカウントに」付与するのかを人間が見てわかるような Terraform Plan 表示を実現することで、Plan 表示を確認するだけで変更内容が把握できるようになりました。

4.9 今回の実装が実現できた要因

今回の実装が実現できた要因として、現状の運用規模と運用スタイルがあると思います。

弊社の規模として、前述の通りAWS アカウント数が 130 以上、開発者数が 200 名以上です。それ以外の Identity Center での管理単位としては、グループ数が数十程度、許可セット数が10個程度で運用しています。

許可セットはセキュリティの最小権限の原則を考慮すると、AWS アカウント、アプリケーションのアーキテクチャ、開発者の属性、運用方法に応じた細かい粒度での許可設定が求められますが、弊社では、全社 SRE チームが全社横断で管理するため、管理負担とのバランスを取りつつ最小権限ではなく広めに権限を付与するような許可セットで運用しています。

この運用方法と規模感だから、プレーンテキストファイルによる設定、モノレポ管理、CI/CDでの自動化の実装と運用ができていると言えます。

5. まとめ

運用の効率化

1行1エントリのプレーンなテキストファイルでのシンプルな設定により、簡単に権限付与・削除が出来るようになりました。
また、Git の履歴を追うだけで権限付与の履歴も一目瞭然になりました。

for_each / flatten() による動的リソース生成を実装することでコード量を大幅削減できました。

レビュー体験の向上

Plan 出力にメールアドレス、ユーザー名、グループ名、許可セット名がそのまま表示されることで、「誰に」「何の権限を」「どの AWS アカウントに」付与するかが、Terraform Plan を確認するだけで可能になりました。

目的を持ったディレクトリ構造のため、ファイル差分で変更の意図が伝わるようになりました。

開発チームへの安全な委譲

プレーンなテキストファイルの編集だけで権限の付与が完了します。

AWS アカウント ID 毎のディレクトリ構造で、環境と権限の関係が直感的になりました。
また、symlink で日本語名称を付けたディレクトリ構造を作成することで、対象とするAWS アカウント IDを直感的に探すことが可能になりました。

これからのチャレンジ

生成 AI を活用した Issue から自動で PR を作成する等による AI OPS にチャレンジしていきます。(注釈:過去に Amazon Q Developer を利用したチャレンジは失敗しています→ https://speakerdeck.com/yamaguchitk333/codecatalyst-in-action-automating-pr-creation-for-route-53-and-iam-identity-center-management )

許可セットの単位がまだ荒いので、その粒度を細かくするか ABAC にすることでセキュリティの強化を行います。