はじめに
最近開発が進んできているTROCCO®のTerraform対応について、年末のアドベントカレンダーで記事を2本執筆しました。
当時は対応リソースがまだ限定されていましたが、そこから1月に入ってチーム、転送設定、ワークフローと徐々に拡充されています。そこで今回は、Terraform活用の重要性が高いと思われる、ユーザー/チームの管理について取り上げることにします。
こんな方におすすめ
- Terraformを使ってTROCCO®のユーザー/チームを堅牢に管理したい
- 利用者の追加や削除、部署変更があったときにシンプルに対応できるようになりたい
- 既に登録済のリソースについて、Terraform管理下に移行したい(今回はユーザーを例に)
Terraformの基礎やCI/CDについては取り上げないので、基礎から抑えたい方は過去記事をご覧ください!
概要の説明
具体のコードは例によってサンプルリポジトリで公開しているので、まずは要点を掴んでおきましょう。
前提
以下のような組織を想定しています。
- ユーザーは部門、役職(=マネージャー/メンバー)、個々人という階層で管理する
- 部門のなかにデータマネジメント部門がある
- 部門と別で技術管理者がいる
そして、このようなことをやりたいとします。
- 各部門のマネージャーおよび技術管理者がアカウントの管理者権限を持つ
- 上記に加えてデータマネジメント部門のみが接続情報の編集権限を持つ
- データマネジメント部門のマネージャーのみが監査ログの取得権限を持つ(
よく考えたらこの前提おかしくないかと思ったのですが、まあ設定ということで苦笑) - 部門ごとにチームを作って権限管理を行いたい
実現方法
以下のような形でユーザー管理を実現しています。
- localsブロックでuserの階層構造を記載する
- 所属部門/役職に応じて権限やチームの所属を指定する
- ユーザーの新規追加時には、パスワードを発行する必要があるのでユーザーごとにTerraformで生成する
- 初期パスワードはStateに残ってしまうので、変更依頼をすること
- 新規追加時には、一度個別に生成したあとに、部門に書き加える形とする
- これは部門に対応させてチームに追加しようとしたときに、メールアドレスが未承認だとエラーになるため
- 部門に追加されたタイミングで個別に発行したパスワードのリソースはdestroyされる
- 管理するリソースが増えすぎてしまうので、都度削除する/なおユーザー側には残る
- 既存の取り込みの際には、tfmigrateかimportブロックを活用する
- こちらの詳細は後述します
詳細説明
それでは、具体の説明に入っていきます。
新規ユーザーを作成する
まずは新規ユーザーの作成です。./sample3_advanced/trocco/user_team_management/_resources.tf
のnewly_created_users
の部分に発行するユーザーを記載します。
サンプルでは以下のようになっています。
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
の部分をコメントアウトして、逆に部門ごとのコメントアウトされている部分を変更します。
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をすると、ユーザーの権限がそれぞれに応じたものに変更されるとともに、部門ごとの管理者のチームとメンバーのチームが生成されます。
チームの定義は以下のようになっています。
# 部門マネージャー+技術管理者による部門ごとの管理者チームを生成
# メールアドレスの認証前にはチームを作成できないので注意
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
]
}
なお、リソースグループは後日開発予定なので、もう少々お待ちください!
ユーザーの部署異動に対応する
ユーザーの部署異動に応じて権限変更を行いたい場合は、departments
のなかで対象のユーザーを変更したい部門/役職に移動します。既にメールアドレス認証は済んでいるので、applyをすれば部署/役職に応じた権限に変更されるとともに、所属するチームが変更されます。
既存ユーザーを取り込む
取り込みの運用を考えるにあたって、Terraformの基本的な挙動をおさらいしておきましょう。Terraformで既存リソースを取り込むには、
- importコマンド
- importブロック
がネイティブの機能としてあります。そして、これをより実運用にあたり便利にしたものとして、tfmigrateというツールがあります。
importコマンドでは事前検証なしにリソースを取り込んでしまうので運用上は望ましくなく、そのためimportブロックやtfmigrateを活用します。
もう少し詳しく理解したい人は、以下の記事をご覧ください。
tfmigrateの手順
ツールとしては、リソース定義を記載した上で移行処理の内容をまとめて記載することで、プルリクエストでの実行時に差分がないことを確認し、差分がなければmainブランチにマージすることで取り込みがされるようになっています。
対象ディレクトリは異なりますが、以下のようなイメージです。
具体の実装としては、下記のファイルで移行処理がどこで、処理結果をどこに保存するかという指定ができ、
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の処理ファイルを格納することで、これが処理対象になります。
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で取り込みができます。
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/
のディレクトリにそれぞれの素材が自動生成されます。
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を利用するとユーザーを削除できてしまうので、既存ユーザーの取り込みを試すなら管理者権限からにしておきましょう。
おわりに
一通り試してみて個人的にもこれは超絶便利では・・・!と思ったものです。これからさらに対応リソースは拡充されていきますし、私も活用方法を模索していくので、引き続きお楽しみに!