続・Terraform + TROCCO入門　その1: tfmigrateによる既存取込も含めたユーザー／チームの堅牢な管理

# はじめに

最近開発が進んできているTROCCO®のTerraform対応について、年末のアドベントカレンダーで記事を2本執筆しました。

https://qiita.com/SoySoySoyB/items/16ceae5dad35fedad132

https://qiita.com/SoySoySoyB/items/05e4d31d3e372790c775

当時は対応リソースがまだ限定されていましたが、そこから1月に入ってチーム、転送設定、ワークフローと徐々に拡充されています。そこで今回は、Terraform活用の重要性が高いと思われる、ユーザー／チームの管理について取り上げることにします。


# こんな方におすすめ

- Terraformを使ってTROCCO®のユーザー／チームを堅牢に管理したい
- 利用者の追加や削除、部署変更があったときにシンプルに対応できるようになりたい
- 既に登録済のリソースについて、Terraform管理下に移行したい（今回はユーザーを例に）

:::note warn

Terraformの基礎やCI/CDについては取り上げないので、基礎から抑えたい方は過去記事をご覧ください！

:::


# 概要の説明

具体のコードは例によって[サンプルリポジトリ](https://github.com/SoySoySoyB/terraform-trocco-sample/tree/main/sample3_advanced/trocco/user_team_management)で公開しているので、まずは要点を掴んでおきましょう。


## 前提

以下のような組織を想定しています。

- ユーザーは部門、役職（=マネージャー／メンバー）、個々人という階層で管理する
- 部門のなかにデータマネジメント部門がある
- 部門と別で技術管理者がいる

そして、このようなことをやりたいとします。

- 各部門のマネージャーおよび技術管理者がアカウントの管理者権限を持つ
- 上記に加えてデータマネジメント部門のみが接続情報の編集権限を持つ
- データマネジメント部門のマネージャーのみが監査ログの取得権限を持つ（~~よく考えたらこの前提おかしくないかと思ったのですが、まあ設定ということで苦笑~~）
- 部門ごとにチームを作って権限管理を行いたい


## 実現方法

以下のような形でユーザー管理を実現しています。

- localsブロックでuserの階層構造を記載する
- 所属部門／役職に応じて権限やチームの所属を指定する
- ユーザーの新規追加時には、パスワードを発行する必要があるのでユーザーごとにTerraformで生成する
    - 初期パスワードはStateに残ってしまうので、変更依頼をすること
- 新規追加時には、一度個別に生成したあとに、部門に書き加える形とする
    - これは部門に対応させてチームに追加しようとしたときに、メールアドレスが未承認だとエラーになるため
    - 部門に追加されたタイミングで個別に発行したパスワードのリソースはdestroyされる
        - 管理するリソースが増えすぎてしまうので、都度削除する／なおユーザー側には残る
- 既存の取り込みの際には、tfmigrateかimportブロックを活用する
    - こちらの詳細は後述します


# 詳細説明

それでは、具体の説明に入っていきます。


## 新規ユーザーを作成する

まずは新規ユーザーの作成です。`./sample3_advanced/trocco/user_team_management/_resources.tf`の`newly_created_users`の部分に発行するユーザーを記載します。

サンプルでは以下のようになっています。

```hcl:./sample3_advanced/trocco/user_team_management/_resources.tf
  newly_created_users = [
    "${local.your_email_before_at}+${local.sample_label}_marketing__manager@${local.your_email_domain}",
    "${local.your_email_before_at}+${local.sample_label}_marketing__member@${local.your_email_domain}",
    "${local.your_email_before_at}+${local.sample_label}_product__manager@${local.your_email_domain}",
    "${local.your_email_before_at}+${local.sample_label}_product__member@${local.your_email_domain}",
    "${local.your_email_before_at}+${local.sample_label}_data_management__manager@${local.your_email_domain}",
    "${local.your_email_before_at}+${local.sample_label}_data_management__member@${local.your_email_domain}",
  ]
```

applyを実行すると、個々のユーザーに対して別々のパスワードが発行され、招待メールが送られます。サンプルの通りにやろうとすると、自分のメールアドレスのエイリアスになっているので、自分にメールがまとめてきます。

コードは割愛しますがユーザー部分の設定に基づいて、初期発行時には、

- 権限はメンバー
- 接続情報の編集権限はなし
- 監査ログは利用不可

の状態になっています。

続いて、メールに記載されているURLをクリックしてメールアドレスの認証をしましょう。完了したら、`newly_created_users`の部分をコメントアウトして、逆に部門ごとのコメントアウトされている部分を変更します。

```hcl:./sample3_advanced/trocco/user_team_management/_resources.tf
  departments = {
    marketing = {
      department_name_ja = "マーケティング本部"
      managers = [
        "${local.your_email_before_at}+${local.sample_label}_marketing__manager@${local.your_email_domain}",
      ],
      members = [
        "${local.your_email_before_at}+${local.sample_label}_marketing__member@${local.your_email_domain}",
      ],
    }
    product = {
      department_name_ja = "プロダクト本部"
      managers = [
        "${local.your_email_before_at}+${local.sample_label}_product__manager@${local.your_email_domain}",
      ],
      members = [
        "${local.your_email_before_at}+${local.sample_label}_product__member@${local.your_email_domain}",
      ],
    }
    data_management = {
      department_name_ja = "データマネジメント本部"
      managers = [
        "${local.your_email_before_at}+${local.sample_label}_data_management__manager@${local.your_email_domain}",
      ],
      members = [
        "${local.your_email_before_at}+${local.sample_label}_data_management__member@${local.your_email_domain}",
      ],
    }
  }
  # 部門とは別に技術担当者が存在している想定
  technical_admins = [
    "${local.your_email_before_at}+${local.sample_label}_product__member@${local.your_email_domain}",
  ]
```

合わせて、下部のチームの部分のコメントアウトも外してください。この状態でapplyをすると、ユーザーの権限がそれぞれに応じたものに変更されるとともに、部門ごとの管理者のチームとメンバーのチームが生成されます。

チームの定義は以下のようになっています。

```hcl:./sample3_advanced/trocco/user_team_management/_resources.tf
# 部門マネージャー＋技術管理者による部門ごとの管理者チームを生成
# メールアドレスの認証前にはチームを作成できないので注意
resource "trocco_team" "department_managers" {
  for_each = local.departments
  name     = "${local.departments[each.key].department_name_ja} 管理者"
  members = concat(
    [
      for email in each.value["managers"] : {
        user_id = local.user_email_ids[email],
        role    = "team_admin"
      }
      ], [
      for email in local.technical_admins : {
        user_id = local.user_email_ids[email],
        role    = "team_admin"
      }
    ]
  )
  depends_on = [
    trocco_user.users
  ]
}

# 部門マネージャー＋技術管理者をチーム管理者／部門メンバーをチームメンバーとした部門ごとのメンバーチームを生成
resource "trocco_team" "department_members" {
  for_each = local.departments
  name     = "${local.departments[each.key].department_name_ja} メンバー"
  members = concat(
    [
      for email in each.value["managers"] : {
        user_id = local.user_email_ids[email],
        role    = "team_admin"
      }
    ],
    [
      for email in local.technical_admins : {
        user_id = local.user_email_ids[email],
        role    = "team_admin"
      }
    ],
    [
      for email in each.value["members"] : {
        user_id = local.user_email_ids[email],
        role    = "team_member"
      } if !contains(local.technical_admins, email)
    ]
  )
  depends_on = [
    trocco_user.users
  ]
}
```

:::note

なお、リソースグループは後日開発予定なので、もう少々お待ちください！

:::


## ユーザーの部署異動に対応する

ユーザーの部署異動に応じて権限変更を行いたい場合は、`departments`のなかで対象のユーザーを変更したい部門／役職に移動します。既にメールアドレス認証は済んでいるので、applyをすれば部署／役職に応じた権限に変更されるとともに、所属するチームが変更されます。


## 既存ユーザーを取り込む

取り込みの運用を考えるにあたって、Terraformの基本的な挙動をおさらいしておきましょう。Terraformで既存リソースを取り込むには、

- importコマンド
- importブロック

がネイティブの機能としてあります。そして、これをより実運用にあたり便利にしたものとして、[tfmigrateというツール](https://qiita.com/minamijoyo/items/670eb100552b0834dd5e)があります。

importコマンドでは事前検証なしにリソースを取り込んでしまうので運用上は望ましくなく、そのためimportブロックやtfmigrateを活用します。

もう少し詳しく理解したい人は、以下の記事をご覧ください。

https://zenn.dev/primenumber/articles/a0e98542f8da0f


### tfmigrateの手順

ツールとしては、リソース定義を記載した上で移行処理の内容をまとめて記載することで、プルリクエストでの実行時に差分がないことを確認し、差分がなければmainブランチにマージすることで取り込みがされるようになっています。

対象ディレクトリは異なりますが、以下のようなイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/770d2baa-823f-a829-470b-3e588e848275.png)

具体の実装としては、下記のファイルで移行処理がどこで、処理結果をどこに保存するかという指定ができ、

```hcl:./sample3_advanced/trocco/user_team_management/.tfmigrate.hcl
tfmigrate {
  migration_dir = "./state_operation/tfmigrate"
  history {
    storage "gcs" {
      bucket = "{YOUR_BUCKET_NAME_BACKEND}"
      name   = "sample3_advanced/trocco/user_team_management/tfmigrate_history.json"
    }
  }
}

```

`migration_dir`に.hclの処理ファイルを格納することで、これが処理対象になります。

```hcl:記述例
migration "state" "import_users" {
  actions = [
    "import trocco_user.users[\\\"your.mail+dummy1@example.com\\\"] 1",
    "import trocco_user.users[\\\"your.mail+dummy2@example.com\\\"] 2",
    "import trocco_user.users[\\\"your.mail+dummy3@example.com\\\"] 3",
    "import trocco_user.users[\\\"your.mail+dummy4@example.com\\\"] 4",
  ]
}
```

tfactionはtfmigrateに対応しており、プルリクエストのラベルに`tfmigrate:<target>`の記載をするだけで、通常の`terraform plan / apply`ではなく`tfmigrate plan / apply`の実行に処理を変更することができます。簡単すぎて最高です。

リソース定義やhclファイルを書くのは大変なのでは、という点についてはimportブロックとまとめて後述します。


### importブロックの手順

tfmigrateではstateの処理でdiffが出ないことを強制させる形になりますが、importブロックではdiffを許容できます。こちらもリソース定義を記載した上で、importブロックを記載するとcreateではなくimportの挙動になり、通常のterraform planで確認／terraform applyで取り込みができます。

```hcl:記述例
import {
  to = trocco_user.users["your.mail+dummy1@example.com"]
  id = 1
}

import {
  to = trocco_user.users["your.mail+dummy2@example.com"]
  id = 2
}

import {
  to = trocco_user.users["your.mail+dummy3@example.com"]
  id = 3
}

import {
  to = trocco_user.users["your.mail+dummy4@example.com"]
  id = 4
}
```


### リソース定義やtfmigrateのhclファイル／importブロックを生成する

やり方はわかった、でも実際やろうとするとめんどくさいよね、とみなさんも思いますよね。よくわかります。

ということで、リソース定義を生成するためのlocal部分の記載や、tfmigrateのhclファイル、importブロックの記載内容を、ユーザー一覧のAPIで取得したデータから生成するためのPythonスクリプトを作成しました。

`./sample3_advanced/trocco/user_team_management/state_operation/script/state_operation.ipynb`のスクリプトを実行すると、`./sample3_advanced/trocco/user_team_management/state_operation/generated_file/`のディレクトリにそれぞれの素材が自動生成されます。

```python:./sample3_advanced/trocco/user_team_management/state_operation/script/state_operation.ipynb
from dotenv import load_dotenv
import urllib, os, json, pandas as pd, pytz, datetime


def fetch_users():
    endpoint = 'users'
    limit = 200  # max 200
    next_cursor = ''
    df_users = pd.DataFrame()
    while True:
        trocco_api = f'https://trocco.io/api/{endpoint}?limit={limit}&cursor={next_cursor}'
        req = urllib.request.Request(trocco_api)
        req.add_header('Authorization', f'Token {os.getenv("trocco_api_key")}')
        res = json.load(urllib.request.urlopen(req))
        df_users = pd.concat([df_users, pd.DataFrame(res['items'])])
        next_cursor = res.get('next_cursor', '')
        if not next_cursor:
            break
    return df_users

def base_file_name(action):
    jst = pytz.timezone('Asia/Tokyo')
    return f'{datetime.datetime.now(jst).strftime("%Y%m%d")}__{action}_users'

def generate_tfmigrate_hcl_file(action, df_users):
    migration_hcl = f'# tfmigrateでユーザーをまとめて{action}するための定義；ファイル名末尾の.tmpを削除してtfmigrateディレクトリに配置することで処理可能になる\nmigration "state" "{action}_users"' + ' {\n  actions = [\n'
    migration_hcl += '\n'.join([f'    "{action if action != "remove" else "rm"} trocco_user.users[\\\\\\"{email}\\\\\\"]{" " + str(id) if action == "import" else ""}",' for email, id in zip(df_users.email, df_users.id)])
    migration_hcl += '\n  ]\n}\n'
    with open(f'../generated_file/{base_file_name(action)}.hcl.tmp', 'w', encoding='utf-8') as f:
        f.write(migration_hcl)

def generate_users_list(action, df_users):
    if action ==  'import':
        users_list = generate_user_list('imported_users', df_users)
        users_list = users_list + '\n' + generate_user_list('imported_super_admin_user', df_users.query('role == "super_admin"'))
        users_list = users_list + '\n' + generate_user_list('imported_admin_users', df_users.query('role == "admin"'))
        users_list = users_list + '\n' + generate_user_list('imported_connection_modify_not_restricted_users', df_users.query('is_restricted_connection_modify == False'))
        if 'can_use_audit_log' in list(df_users.keys()):
            users_list = users_list + '\n' + generate_user_list('can_use_audit_log_users', df_users.query('can_use_audit_log == True'))
        with open(f'../generated_file/{base_file_name(action)}_list_for_tf.txt', 'w', encoding='utf-8') as f:
            f.write('  # ユーザー取込時に定義に差分が出ないようにするため、権限ごとのユーザー一覧を生成；locals部分に記載する\n' + users_list + '\n')

def generate_user_list(list_name, df_users):
    user_list = f'  {list_name} = [\n'
    user_list += '\n'.join([f'    "{email}",  # {id}' for email, id in zip(df_users.email, df_users.id)])
    user_list += '\n  ]'
    return user_list

def generate_blocks(action, df_users):
    blocks = f'# {action if action != "remove" else "removed"}ブロックでユーザーをまとめて{action}するための定義；terraformのルートディレクトリに配置し、処理後に別フォルダに移管する\n'
    if action == 'import':
        blocks += '\n'.join([f'import {{\n  to = trocco_user.users[\"{email}\"]\n  id = {id}\n}}\n' for email, id in zip(df_users.email, df_users.id)])
    if action == 'remove':
        blocks += '\n'.join([f'removed {{\n  from = trocco_user.users[\"{email}\"]\n  lifecycle {{\n    destroy = false\n  }}\n}}\n' for email, id in zip(df_users.email, df_users.id)])
    with open(f'../generated_file/{base_file_name(action)}.tf.tmp', 'w', encoding='utf-8') as f:
        f.write(blocks)

def main():
    # ルートディレクトリに.envファイルを配置しておく
    load_dotenv()
    df_users = fetch_users()

    # 個別処理を入れる場合の例
    # df_users = df_users.query('email.str.find("sample2") > 0')

    if not os.path.isdir('../generated_file'):
        os.makedirs('../generated_file')

    for action in ['import', 'remove']:
        generate_tfmigrate_hcl_file(action, df_users)
        generate_users_list(action, df_users)
        generate_blocks(action, df_users)

if __name__ == '__main__':
    main()
```

ちなみにこういった処理はremoveに対しても活用できるので、どうせ使うだろうということでremoveに使える素材も生成しています。ということでまとめてimportして、まとめてremoveするのも試せます。

解説は以上になります。基本的にただ取込／除外をしているだけでは差分が出ないはずですが、既存リソースを取り扱う以上、不用意な設定変更が発生しないようお気をつけください。

特に、特権管理者のAPI KEYを利用するとユーザーを削除できてしまうので、既存ユーザーの取り込みを試すなら管理者権限からにしておきましょう。


# おわりに

一通り試してみて個人的にもこれは超絶便利では・・・！と思ったものです。これからさらに対応リソースは拡充されていきますし、私も活用方法を模索していくので、引き続きお楽しみに！