Terraform で IdP を管理するときに知りたかったネタを書く

この 1 年ほど、IdP である OneLogin の構成の一部を Terraform で管理してきました。

この note では「IdP でこういう運用をしたいとき、Terraform でどう表現するのがよさそうか」を、サンプルを交えて書いています。実態としては単純にExpressions や Built-in Functions を使っているだけなのですが、情シスにとって馴染みのあるシナリオを添えることでこれらの使い所がわかりやすくなるかな、と思っての試みです。 

サンプルは OneLogin の仕様に準ずるものですが、他の IdP でも応用できる部分があるかなと思います。


なお Terraform の基本的な使い方については触れていませんので、ご興味がある方向けはチュートリアルをどうぞ。https://developer.hashicorp.com/terraform/tutorials

2019 年の情報ではありますが、チュートリアルの内容をざっくり書いた記事があるのでご参考まで。

Okta を利用している方は下記の神記事を写経するのが良いと思います。



前提: Terraform で管理しているリソース

  • Role (onelogin_role)

  • Mapping (onelogin_user_mapping)

  • Application の一部 (onelogin_saml_app, onelogin_app_role_attachment)

上記の OneLogin のリソースを Terraform で管理しています。Role, Mapping といった「権限管理」で使用するリソースの管理が主目的です。

Role をユーザに割り当てることで、特定の SSO アプリケーションへのアクセス権を付与したり、SCIM 時にグループをプロビジョニングすることができます。Mapping は User Attribute を評価して任意の Role を自動で割り当てるために使っています。

Provider が非対応のパラメータがあるので、Application については気持ちばかりのバックアップ用途、他リソースからの参照用途 (Application の ID 指定) で使っています。

それぞれの詳細はドキュメントをご参照ください。
https://registry.terraform.io/providers/onelogin/onelogin/latest/docs



ケース①: 全ての部署 Role を作成してユーザへの割り当て (Mapping) もいい感じにしたい

「権限管理のために、部署ごとの Role を効率よく作成・管理したい」が Terraform を導入したきっかけでした。
「この部署の所属メンバに、このアプリケーションへのアクセス権を付与する」という、よくある対応のためです。問題となるのはその数で、組織構成がシンプルなうちは困らないのですが、フェーズの変化とともに部署数がじわじわ増えてきますし、たまにビッグバンも起きます。「GUI でのポチポチ運用では限界が来る」と感じていたので、部署 Role だけでも Terraform で管理できたらなぁ、と。

「必要になったときに、必要な部署 Role を作成する」アプローチもありますが、依頼〜対応完了までの待ち時間がもったいないですし、特に組織変更があった週は相応の頻度で依頼が飛んできます。その都度管理コンソールでポチって typo したり設定ミスをしてしまう可能性も無視できません。(なにより、OneLogin の管理コンソールはレスポンスが悪いことがあるのであまり開きたくないのです…)

上記の背景から、常に全ての部署の Role を用意しておくことにしました。数が多いですが、Terraform の Local Value, for_each を使っていい感じに管理します。

サンプルコード

// sample1.tf

locals {
  org_department = {
    // 組織コード, 組織名
    100 = { name = "dev" }
    101 = { name = "dev-product1" }
    102 = { name = "dev-product2" }
    103 = { name = "dev-sre" }
    104 = { name = "dev-design" }
    200 = { name = "biz" }
    201 = { name = "biz-sales" }
    202 = { name = "biz-cs" }
    203 = { name = "biz-marketing" }
    300 = { name = "corp" }
    301 = { name = "corp-hr" }
    302 = { name = "corp-accounting" }
    303 = { name = "corp-legal" }
    304 = { name = "corp-general-affairs" }
    305 = { name = "corp-it" }
  }
}

# ---------------------------------
# onelogin_roles
# ---------------------------------
resource "onelogin_roles" "department" {
  for_each = local.org_department

  name = "org_Dept_${each.value.name}"
  users = [
    // ユーザへの割り当ては Mapping で指定 
  ]
  apps   = []
  admins = []
}

# ---------------------------------
# onelogin_user_mappings
# ---------------------------------
resource "onelogin_user_mappings" "department" {
  for_each = local.org_department

  name     = "org_Dept_${each.value.name}"
  enabled  = true
  match    = "all"
  position = 0
  actions {
    value  = [onelogin_roles.department[each.key].id]
    action = "add_role"
  }
  conditions {
    operator = "="
    source   = "department"
    value    = each.value.name
  }
}

Local Value はローカル変数、for_each がインスタンス作成を繰り返すループ処理、と書くとイメージしやすいでしょうか。Local Value に部署マスタを記述し、for_each で部署の数だけ Role, Mapping を作成しています。

each の使い方は以下のドキュメントに記述があります。
https://developer.hashicorp.com/terraform/language/meta-arguments/for_each#the-each-object


作成されるインスタンス

terraform apply 後、terraform state list で作成されたインスタンスを確認してみます。

% terraform state list
onelogin_roles.department["100"]
onelogin_roles.department["101"]
onelogin_roles.department["102"]
onelogin_roles.department["103"]
onelogin_roles.department["104"]
onelogin_roles.department["200"]
onelogin_roles.department["201"]
onelogin_roles.department["202"]
onelogin_roles.department["203"]
onelogin_roles.department["300"]
onelogin_roles.department["301"]
onelogin_roles.department["302"]
onelogin_roles.department["303"]
onelogin_roles.department["304"]
onelogin_roles.department["305"]
onelogin_user_mappings.department["100"]
onelogin_user_mappings.department["101"]
onelogin_user_mappings.department["102"]
onelogin_user_mappings.department["103"]
onelogin_user_mappings.department["104"]
onelogin_user_mappings.department["200"]
onelogin_user_mappings.department["201"]
onelogin_user_mappings.department["202"]
onelogin_user_mappings.department["203"]
onelogin_user_mappings.department["300"]
onelogin_user_mappings.department["301"]
onelogin_user_mappings.department["302"]
onelogin_user_mappings.department["303"]
onelogin_user_mappings.department["304"]
onelogin_user_mappings.department["305"]

onelogin_roles.department, onelogin_user_mappings.department では for_each によって複数のインスタントが作成されています。
"<TYPE>.<NAME>[<KEY>]" の記述により、特定のインスタンスを指定できます。terraform state show コマンドで、特定のインスタンスの設定値を確認します。

% terraform state show 'onelogin_roles.department["100"]'  
# onelogin_roles.department["100"]:
resource "onelogin_roles" "department" {
    id   = "617623"
    name = "org_Dept_dev"
}
% terraform state show 'onelogin_user_mappings.department["100"]'  
# onelogin_user_mappings.department["100"]:
resource "onelogin_user_mappings" "department" {
    enabled  = true
    id       = "471258"
    match    = "all"
    name     = "org_Dept_dev"
    position = 52

    actions {
        action = "add_role"
        value  = [
            "617623",
        ]
    }

    conditions {
        operator = "="
        source   = "department"
        value    = "dev"
    }
}
  • Role

    • Role 名は "org_Dept_dev"

  • Mapping

    • Mapping の名称は "org_Dept_dev "

    • 「ユーザの department の値が dev とイコールか?」を評価

    • 評価が正の場合、Role "org_Dept_dev" (Role ID: 617623) を割り当て

となっており、それぞれ適切に当該部署用の設定がされています。


余談

SCIM で連携しているアプリケーション (Google Workspace, etc) に対して、部署 Role を「グループ」としてプロビジョニングしています。組織変更の当日には Google Workspace に新部署のグループが揃っており、キックオフや定例 Mtg 開催の会議招集タスクに役立っています(たぶん)。複数部署を兼務するユーザについては Mapping の設定を工夫して対応しています。

また、部署 Role と同じ要領で「雇用形態」や「役職」ごとの Role も作成しています。



ケース②: 「部署 × 契約形態」の組み合わせの Role, Mapping をいい感じに用意したい

「様々な Attribute を評価して、適切な権限を割り当てたい」というケースがあります。例えば「"特定の部署" の "特定の雇用形態" のユーザに、この権限を付与したい」はよくあるパターンかと思います。

仮に部署数が 15, 契約形態が 4 種類 (正社員、契約社員、派遣社員、業務委託) とすると 60 パターンの組み合わせとなり、Role, Mapping あわせて 120 個の作成が必要です。

一つ一つ定義するのは大変なので、setproduct を使っていい感じにします。

The setproduct function finds all of the possible combinations of elements from all of the given sets by computing the Cartesian product.

https://developer.hashicorp.com/terraform/language/functions/setproduct

「setproduct 関数は、与えられたすべての集合の要素の組み合わせの可能性を、デカルト積を計算することによって見つけます。」(by DeepL) です。

部署と契約形態のマスタを用意すれば、各要素ごとの組み合わせは Terraform がよしなに生成してくれます。


サンプルコード

// sample2.tf

locals {
  org_department = {
    // 組織コード, 組織名
    100 = { name = "dev" }
    101 = { name = "dev-product1" }
    102 = { name = "dev-product2" }
    103 = { name = "dev-sre" }
    104 = { name = "dev-design" }
    200 = { name = "biz" }
    201 = { name = "biz-sales" }
    202 = { name = "biz-cs" }
    203 = { name = "biz-marketing" }
    300 = { name = "corp" }
    301 = { name = "corp-hr" }
    302 = { name = "corp-accounting" }
    303 = { name = "corp-legal" }
    304 = { name = "corp-general-affairs" }
    305 = { name = "corp-it" }
  }
  org_contractType = {
    full-timer             = { name = "full" },     // 正社員
    contract_worker        = { name = "contract" }, // 契約社員
    permatemp              = { name = "tmp" },      // 派遣社員
    outsourcing-contractor = { name = "os" },       // 業務委託
  }
  // org_department-contractType 用に list を生成(中間処理)
  list_department = [
    for key, value in local.org_department : {
      key   = key
      value = value.name
    }
  ]
  // org_department-contractType 用に list を生成(中間処理)
  list_contractType = [
    for key, value in local.org_contractType : {
      key   = key
      value = value.name
    }
  ]
  // department × contractType の組み合わせ
  org_department-contractType = [
    for pair in setproduct(local.list_department, local.list_contractType) : {

      department   = pair[0].value
      contractType = pair[1].value
      dept-ct      = "${pair[0].value}.${pair[1].value}"
    }
  ]
}

# ---------------------------------
# onelogin_roles
# ---------------------------------
resource "onelogin_roles" "department-contractType" {
  for_each = {
    for role in local.org_department-contractType : "${role.dept-ct}" => role
  }

  name = "org_DeptCt_${each.value.dept-ct}"
  users = [
    // ユーザ割り当ては mapping で指定 
  ]
  apps   = []
  admins = []
}

# ---------------------------------
# onelogin_user_mappings
# ---------------------------------
resource "onelogin_user_mappings" "depatment-contractType" {
  for_each = {
    for mapping in local.org_department-contractType : "${mapping.dept-ct}" => mapping
  }

  name     = "org_DeptCt_${each.value.dept-ct}"
  enabled  = true
  match    = "all"
  position = 0

  actions {
    value  = [onelogin_roles.department-contractType[each.key].id]
    action = "add_role"
  }

  conditions {
    operator = "="
    source   = "department"
    value    = each.value.department
  }

  conditions {
    operator = "="
    source   = "custom_attribute_contractType"
    value    = each.value.contractType
  }
}
  • Local Value に部署マスタ、契約形態マスタを用意

  • 「部署×契約形態」の全要素の組み合わせを org_department-contractType として生成 (setproduct)

  • org_department-contractType を引数に、for_each で Role, Mapping を作成


作成されるインスタンス

部署・雇用形態の組み合わせを全て網羅して 120 個のインスタンスが作成されました。(出力行が多いため、一部のみ抜粋しています。)

% terraform state list
onelogin_roles.department-contractType["biz-cs.contract"]
onelogin_roles.department-contractType["biz-cs.full"]
onelogin_roles.department-contractType["biz-cs.os"]
onelogin_roles.department-contractType["biz-cs.tmp"]
onelogin_roles.department-contractType["biz-marketing.contract"]
onelogin_roles.department-contractType["biz-marketing.full"]
onelogin_roles.department-contractType["biz-marketing.os"]
onelogin_roles.department-contractType["biz-marketing.tmp"]
onelogin_roles.department-contractType["biz-sales.contract"]
onelogin_roles.department-contractType["biz-sales.full"]
... (省略)
onelogin_user_mappings.depatment-contractType["dev.contract"]
onelogin_user_mappings.depatment-contractType["dev.full"]
onelogin_user_mappings.depatment-contractType["dev.os"]
onelogin_user_mappings.depatment-contractType["dev.tmp"]
% terraform state list | wc -l
     120

org_department-contractType の中身も確認してみます。sample2.tf ファイルの末尾に output ブロックを追記して、再度 terraform apply を実行します。

// sample2.tf 末尾に追加

output "org_department-contractType" {
    value = local.org_department-contractType
}
% terraform apply
... (省略)

Outputs:

org_department-contractType = [
  {
    "contractType" = "contract"
    "department" = "dev"
    "dept-ct" = "dev.contract"
  },
  {
    "contractType" = "full"
    "department" = "dev"
    "dept-ct" = "dev.full"
  },
  {
    "contractType" = "os"
    "department" = "dev"
    "dept-ct" = "dev.os"
  },
  {
    "contractType" = "tmp"
    "department" = "dev"
    "dept-ct" = "dev.tmp"
  },
  {
    "contractType" = "contract"
    "department" = "dev-product1"
    "dept-ct" = "dev-product1.contract"
  },
  {
    "contractType" = "full"
    "department" = "dev-product1"
    "dept-ct" = "dev-product1.full"
  },
  ... (省略)
  {
    "contractType" = "tmp"
    "department" = "corp-it"
    "dept-ct" = "corp-it.tmp"
  },
]

"dept-ct" の value が、部署と契約形態を組み合わせた文字列となっていることがわかります。


補足

resource "onelogin_roles" "department-contractType" {
  for_each = {
    for role in local.org_department-contractType : "${role.dept-ct}" => role
  }
  ... (省略)  

for_each  の引数に指定できる値の型は "set" または "map" に限定されています。org_department-contractType は list 型なので、そのままでは引数に使うことができません。そのため for を使って map 型に変換する一手間をかけています。

この処理について詳しく説明されている記事を紹介させていただきます。



ケース③: 複雑な条件による Role 割り当てをいい感じに表現したい

例として、SCIM でプロビジョニングする Role (グループ) を作成し、以下のように所属部署や職種、個別都合 (※) で割り当てる Role を分けるケースを想定します。
※ 個別ケースはなるべく排除したいですが、人事マスタ上の情報だけで権限管理するのはなかなかに難しい…

  • 職種が「engineer」のユーザ全員に、一律でベースとなる権限を付与する

  • SRE チームに所属しているエンジニアには、管理権限を含めたより強い権限を付与する

  • 法務チームには ReadOnly な権限を付与する

  • ○○さん (hoge@example.com)にも、業務都合上 ReadOnly権限を付与する

なお、OneLogin には職種用の User Attribute が用意されていないので、Custom User Field で「job」というAttribute (Custom Field)を作成しておきます。

ここでも for_each を使って必要なインスタンスを作成したいのですが、Mapping の Conditions ブロックで「評価対象の Attribute が異なる」,「条件の数も異なる」場合にどう書いたらよいのか悩んでいました。dynamic でいい感じにできました。

A dynamic block acts much like a for expression, but produces nested blocks instead of a complex typed value. It iterates over a given complex value, and generates a nested block for each element of that complex value.

https://developer.hashicorp.com/terraform/language/expressions/dynamic-blocks

for_each がインスタンスの作成を繰り返すのに対して、dynamic はインスタンス内の「特定のブロック」の作成を繰り返してくれます。


サンプルコード

// sample3.tf

locals {
  app-a_provisioningGroup = {
    role-basic = {
      name          = "power-user"
      mapping_match = "all"
      mapping_condition = [
        {
          operator = "="
          source   = "custom_attribute_job"
          value    = "engineer"
        }
      ]
    }
    role-admin = {
      name          = "administrator"
      mapping_match = "all"
      mapping_condition = [
        {
          operator = "="
          source   = "custom_attribute_job"
          value    = "engineer"
        },
        {
          operator = "="
          source   = "department"
          value    = "dev-sre"
        }
      ]
    }
    role-audit = {
      name          = "readonly"
      mapping_match = "any"
      mapping_condition = [
        {
          operator = "="
          source   = "department"
          value    = "corp-legal"
        },
        {
          operator = "="
          source   = "email"
          value    = "hoge@example.com"
        },
      ]
    }
  }
}

# ---------------------------------
# onelogin_roles
# ---------------------------------

resource "onelogin_roles" "setGroup_app-a" {
  for_each = local.app-a_provisioningGroup

  name = "setGroup_app-a_${each.value.name}"
  apps = []
  users = [
    // nelogin_user_mappings.setGroup_app-a で割り当て
  ]
  admins = []
}

# ---------------------------------
# onelogin_user_mappings
# ---------------------------------

resource "onelogin_user_mappings" "setGroup_app-a" {
  for_each = local.app-a_provisioningGroup

  enabled  = true
  name     = "setGroup_app-a_${each.value.name}"
  match    = each.value.mapping_match
  position = 0

  actions {
    action = "add_role"
    value = [
      onelogin_roles.setGroup_app-a[each.key].id,
    ]
  }

  dynamic "conditions" {
    for_each = local.app-a_provisioningGroup["${each.key}"].mapping_condition
    content {
      operator = conditions.value["operator"]
      source   = conditions.value["source"]
      value    = conditions.value["value"]
    }
  }
}


作成されるインスタンス

terraform show コマンドで確認します。

% terraform show
# onelogin_roles.setGroup_app-a["role-admin"]:
resource "onelogin_roles" "setGroup_app-a" {
    id   = "617694"
    name = "setGroup_app-a_administrator"
}

# onelogin_roles.setGroup_app-a["role-audit"]:
resource "onelogin_roles" "setGroup_app-a" {
    id   = "617692"
    name = "setGroup_app-a_readonly"
}

# onelogin_roles.setGroup_app-a["role-basic"]:
resource "onelogin_roles" "setGroup_app-a" {
    id   = "617693"
    name = "setGroup_app-a_power-user"
}

# onelogin_user_mappings.setGroup_app-a["role-audit"]:
resource "onelogin_user_mappings" "setGroup_app-a" {
    enabled  = true
    id       = "471384"
    match    = "any"
    name     = "setGroup_app-a_readonly"
    position = 69

    actions {
        action = "add_role"
        value  = [
            "617692",
        ]
    }

    conditions {
        operator = "="
        source   = "department"
        value    = "corp-legal"
    }
    conditions {
        operator = "="
        source   = "email"
        value    = "hoge@example.com"
    }
}

# onelogin_user_mappings.setGroup_app-a["role-basic"]:
resource "onelogin_user_mappings" "setGroup_app-a" {
    enabled  = true
    id       = "471383"
    match    = "all"
    name     = "setGroup_app-a_power-user"
    position = 68

    actions {
        action = "add_role"
        value  = [
            "617693",
        ]
    }

    conditions {
        operator = "="
        source   = "custom_attribute_job"
        value    = "engineer"
    }
}

# onelogin_user_mappings.setGroup_app-a["role-admin"]:
resource "onelogin_user_mappings" "setGroup_app-a" {
    enabled  = true
    id       = "471385"
    match    = "all"
    name     = "setGroup_app-a_administrator"
    position = 70

    actions {
        action = "add_role"
        value  = [
            "617694",
        ]
    }

    conditions {
        operator = "="
        source   = "custom_attribute_job"
        value    = "engineer"
    }
    conditions {
        operator = "="
        source   = "department"
        value    = "dev-sre"
    }
}

Mapping の "conditions" がそれぞれ要望通りの条件となっています。


補足

Role, Mapping  に関連する情報を Local Value (app-a_provisioningGroup) に を集約することで、構成の見通しが良くなります。変更を加えるのも楽になりました。



おわりに

laaS 寄りなクラウドサービスを Terraform で管理する知見は多く見つかるのですが、IdP での利用事例はなかなか見つからないので「どう書けばいいんや〜」と手探りで進めてきました。試行錯誤の結果がどなたかのお役に立てれば幸いです。

今回はコードの書き方にフォーカスした内容でしたが、Terraform を使うことの嬉しみツラみの振り返りもどこかで書ければと思います。

いただいたサポートは記事を書くためのエネルギー(珈琲、甘いもの)に変えさせていただきます!