🔑

IAM Identity Center(AWS SSO)のグループとユーザーをTerraformでDRYに書く

2022/10/12に公開2

こんにちは、株式会社スマートラウンドSREの@shonansurvivorsです。

Terraform AWS Provider v4.33.0より、IAM Identity Center(AWS SSO)のグループとユーザーが作成できるようになりました!

https://github.com/hashicorp/terraform-provider-aws/releases/tag/v4.33.0

当社は以前からIAM Identity Centerを利用しており、これまではマネジメントコンソールによる管理だったのですが、今回早速Terraform化しましたので、そこで得られた知見を元にしたサンプルコードを紹介します。

なお、Identity Centerの許可セット(IAMポリシー情報)などは以前からAWS Providerで対応済みですが、それらリソースは本記事のスコープ外となります。ご承知おきください。

環境

  • Terraform 1.1.7
  • AWS Provider 4.33.0

工夫したポイント

  • 各ユーザーがどのグループに所属しているかがわかりやすく、メンテナンスしやすいコードとした
  • aws_identitystore_group_membershipが公式のサンプルコードのままだとエラーになるので、これを回避した

グループ

まず、グループです。

for_eachを使用し、locals(Local Values)に必要最低限の情報を追記するだけでグループを追加できるようにしています。

data "aws_ssoadmin_instances" "this" {}

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

  identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
  display_name      = replace(each.key, "_", "-") # 当社ではTerraformリソース名をスネークケース、AWSリソース名を原則ケバブケースで統一しているため
  description       = each.value["description"]
}

locals {
  aws_identitystore_group = {
    foo_service_admin = {
      description = "foo service prd/stg:admin"
    }
    foo_service_developer = {
      description = "foo service prd:read only, stg:admin"
    }
    bar_service_admin = {
      description = "bar service prd/stg:admin"
    }
    bar_service_developer = {
      description = "bar service prd:read only, stg:admin"
    }
  }
}

applyすると、以下のような名前で各リソースが作成されます。

aws_identitystore_group.this["foo_service_admin"]
aws_identitystore_group.this["foo_service_developer"]
aws_identitystore_group.this["bar_service_admin"]
aws_identitystore_group.this["bar_service_developer"]

ユーザー

ユーザーも同様にfor_eachを使用します。

resource "aws_identitystore_user" "this" {
  for_each = local.aws_identitystore_user

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

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

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

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

locals {
  aws_identitystore_user = {
    "hanako.sato@exmaple.com" = {
      display_name = "Hanako Sato"
      name = {
        given_name  = "Hanako"
        family_name = "Sato"
      }
    }
    "taro.yamada@example.com" = {
      display_name = "Taro Yamada"
      name = {
        given_name  = "Taro"
        family_name = "Yamada"
      }
    }
  }
}

こちらをapplyすると、以下のような名前で各リソースが作成されます。

aws_identitystore_user.this["hanako.sato@exmaple.com"]
aws_identitystore_user.this["taro.yamada@example.com"]

グループとユーザーの紐付け

課題

グループとユーザーの紐付けは、aws_identitystore_group_membershipというリソースで管理するのですが、グループとユーザーを1対1で紐付ける仕様となっています。

以下は、公式のサンプルコードからの抜粋です。

resource "aws_identitystore_group_membership" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]
  group_id          = aws_identitystore_group.example.group_id
  member_id         = aws_identitystore_user.example.user_id
}

この仕様に沿って、素直にfor_eachとlocalsでリソースを作成する場合、localsの一例として以下が考えられます。

locals {
  aws_identitystore_group_membership = {
    "hanako.sato@exmaple.com_foo_service_admin" = {
      member_id = aws_identitystore_user.this["hanako.sato@exmaple.com"].id
      group_id  = aws_identitystore_group.this["foo_service_admin"].id
    }
    "hanako.sato@exmaple.com_bar_service_admin" = {
      member_id = aws_identitystore_user.this["hanako.sato@exmaple.com"].id
      group_id  = aws_identitystore_group.this["bar_service_admin"].id
    }
    "taro.yamada@exmaple.com_foo_service_developer" = {
      member_id = aws_identitystore_user.this["taro.yamada@exmaple.com"].id
      group_id  = aws_identitystore_group.this["foo_service_developer"].id
    }
    "taro.yamada@exmaple.com_bar_service_developer" = {
      member_id = aws_identitystore_user.this["taro.yamada@exmaple.com"].id
      group_id  = aws_identitystore_group.this["bar_service_developer"].id
    }
  }
}

しかし、できればユーザー作成時に定義済みの既存のlocalsを活かして、もっと直感的にコードを書けるようにしたいと思いました。

解決策

そこで、moduleを活用して、以下のようなコードとすることにしました。

locals {
  aws_identitystore_user = {
    "hanako.sato@exmaple.com" = {
      display_name = "Hanako Sato"
      name = {
        given_name  = "Hanako"
        family_name = "Sato"
      }
      # 以下を追加
      group_ids = [
        aws_identitystore_group.this["foo_service_admin"].id,
        aws_identitystore_group.this["bar_service_admin"].id,
      ]
    }
    "taro.yamada@example.com" = {
      display_name = "Taro Yamada"
      name = {
        given_name  = "Taro"
        family_name = "Yamada"
      }
      # 以下を追加
      group_ids = [
        aws_identitystore_group.this["foo_service_developer"].id,
        aws_identitystore_group.this["bar_service_developer"].id,
      ]
    }
  }
}

# 以下を追加
module "aws_identitystore_group_membership" {
  for_each = local.aws_identitystore_user

  source = "../modules/identitystore_group_membership"

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

  member_id = aws_identitystore_user.this[each.key].user_id
  group_ids = local.aws_identitystore_user[each.key].group_ids
}

既存のaws_identitystore_user用のlocalsにgroup_idsを追加しています。これにより、どのユーザーがどのグループに属しているかがわかりやすくなり、また所属グループの追加や削除対応を行う際も直感的に記述できるようになったのではと思います。

module側のコードは以下です。for_eachではなくcountを使っている理由は後述します。

# main.tf
resource "aws_identitystore_group_membership" "this" {
  count = length(var.group_ids)

  identity_store_id = var.identity_store_id
  member_id         = var.member_id
  group_id          = replace(var.group_ids[count.index], "${var.identity_store_id}/", "")  # replaceを使っている理由も後述
}

# variables.tf
variable "identity_store_id" {
  type = string
}

variable "member_id" {
  type = string
}

variable "group_ids" {
  type = list(string)
}

1種類のresourceだけで構成されるmoduleを作成することは推奨されないという話を耳にしたことがあり、自分も普段はそのようにしていますが、今回に関しては

  • 組織内のみで使うmoduleであること
  • module呼び出し元のコードの可読性を向上できること

から問題無しと判断しました。

上記のコードをapplyすると、aws_identitystore_group_membershipは以下のリソース名で作成されます。countを使っているので連番となります。

module.aws_identitystore_group_membership["hanako.sato@example.com"].aws_identitystore_group_membership.this[0]
module.aws_identitystore_group_membership["hanako.sato@example.com"].aws_identitystore_group_membership.this[1]
module.aws_identitystore_group_membership["taro.yamada@example.com"].aws_identitystore_group_membership.this[0]
module.aws_identitystore_group_membership["taro.yamada@example.com"].aws_identitystore_group_membership.this[1]

module側でfor_eachを使用しなかった理由

できれば、各リソースのインデックスを前述の連番ではなくグループIDなどのユニークな情報にしたく、最初は以下のようにmodule側でfor_eachを使うことを検討しました。

# main.tf
resource "aws_identitystore_group_membership" "this" {
  for_each = toset(var.group_ids) # for_eachを使う

  identity_store_id = var.identity_store_id
  member_id         = var.member_id
  group_id          = replace(each.key, "${var.identity_store_id}/", "") # each.keyを使う
}

しかし、このようにするとplan/apply時に以下のエラーになります。

╷
│ Error: Invalid for_each argument
│ 
│   on ../modules/identitystore_group_membership/main.tf line 2, in resource "aws_identitystore_group_membership" "this":
│    2:   for_each = toset(var.group_ids)
│     ├────────────────
│     │ var.group_ids is list of string with 2 elements
│ 
│ The "for_each" value depends on resource attributes that cannot be determined until apply, so Terraform cannot predict how many instances will be created. To work around this, use
│ the -target argument to first apply only the resources that the for_each depends on.

var.group_idsの大元は、以下のlocalsのgroup_idsですが、これはaws_identitystore_groupに依存しています。

locals {
  aws_identitystore_user = {
    "hanako.sato@exmaple.com" = {
      # 略
      group_ids = [
        aws_identitystore_group.this["foo_service_admin"].id,
        aws_identitystore_group.this["bar_service_admin"].id,
      ]
    }
    # 略
  }
}

plan/apply時にはaws_identitystore_groupが未作成である場合、前述のようなエラーになります。

target指定で先にaws_identitystore_groupをapplyしておくことでエラーを回避する方法もあるのですが、運用が複雑化するのと、Terraform歴の浅いメンバーがこのエラーに遭遇した時に短時間で適切に判断・対処することは難しいと考え、countを採用することにしました。

aws_identitystore_group_membershipでreplace関数を使用している理由

以下、引数group_idでreplace関数を使っている理由の解説です。

# main.tf
resource "aws_identitystore_group_membership" "this" {
  count = length(var.group_ids)

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

以下は、再び公式のサンプルコードからの抜粋です。引数group_idに、aws_identitystore_groupの属性group_idをそのまま指定しています。

resource "aws_identitystore_group_membership" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]
  group_id          = aws_identitystore_group.example.group_id
  member_id         = aws_identitystore_user.example.user_id
}

しかし、属性group_idを参照するとidentity_store_id/group_idのようにidentity_store_id/をプレフィックスに持つ値が返ってきます。その結果、apply時にエラーとなります。

そのため、replace関数を使って、これを削除するようにしています。

おわりに

以上、IAM Identity CenterのグループおよびユーザーのTerraform化の解説でした。同様にこれからTerraform化しようとされている方の参考になれば幸いです。

採用情報

株式会社スマートラウンドではSREを含め、エンジニアを募集中です!正社員はもちろん、副業でのジョインも歓迎です。少しでもご興味あればお気軽にご応募ください。Meetyでのカジュアル面談も可能です。

https://www.wantedly.com/projects/1137576

https://jobs.smartround.com/

スマートラウンド テックブログ

Discussion

Kurata SayuriKurata Sayuri

課題のところ、私なら

locals {
  aws_identitystore_group_membership = {
    foo_service_admin = [
      "hanako.sato@exmaple.com",
    ]
    bar_service_admin = [
      "hanako.sato@exmaple.com",
    ]
    foo_service_developer = [
      "taro.yamada@exmaple.com",
    ]
    bar_service_developer = [
      "taro.yamada@exmaple.com",
    ]
  }
}

としておいて、

resource "aws_identitystore_group_membership" "this" {
  for_each = merge([
    for group, members in local.aws_identitystore_group_membership : {
      for member in members : "${group}_${member}" => { group = group, member = member }
    }
  ]...)
  identity_store_id = data.aws_ssoadmin_instances.example.identity_store_ids[0]
  group_id          = aws_identitystore_group.this[each.value.group].id
  member_id         = aws_identitystore_user.this[each.value.member].id
}

とかやっちゃいますね。

Takashi YamaharaTakashi Yamahara

(記事の投稿者です)
そうした書き方は思いつきませんでした!参考になります!ありがとうございます!