当記事は みずほリサーチ&テクノロジーズ × G-gen エンジニアコラボレーション企画 で執筆されたものです。
G-gen の又吉です。当記事では、Cloud Functions を用いて Google Workspace もしくは Cloud Identity (以下、Google Workspace 等) で管理している Google アカウント / グループの棚卸し を日次で行う方法を紹介します。
なお、Google Cloud 側の設定は Terraform を使用します。
概要
背景
Google Cloud を利用する際、企業の監査要件等で Google アカウント (ID) / グループの管理・棚卸しを定期的に行わければいけない時はないでしょうか?
同じパブリッククラウドの AWS では、 ID は IAM User 、グループは IAM Group と呼ばれ AWS アカウント (テナント) の中で管理されているため、AWS のインベントリサービスである AWS Config を用いることで、ID / グループ のスナップショットも AWS Config ひとつで取得できます。
しかし、Google Cloud の場合、Google アカウント / グループは Google Cloud からは分離 されており、Google Workspace 等で管理されております。
したがって、Google Cloud のインベントリサービスである Cloud Asset Inventory を用いても、Google Cloud の組織配下のリソースやポリシーのメタデータは取得できるものの、組織の Google アカウント / グループは取得できません。
Google Cloud の ID や IAM についての概念を、AWS との違いと合わせて詳しく知りたい方は以下の記事をご参照下さい。
今回やること
今回は、Cloud Functions を用いて Directory API を実行し、Google Workspace 等で管理されている Google アカウント / グループの棚卸しを日次で行う方法をご紹介します。
準備
フォルダ階層
フォルダ階層は以下の通りです。
cloud_identity_inventory ├── terraform │ └── main.tf └── python_source_code ├── main.py └── requirements.txt
cloud_identity_inventory
というフォルダ配下に、 Terraform を実行する terraform
フォルダと、Cloud Functions で使うソースファイルを格納している python_source_code
フォルダを作ります。
Cloud Functions を Terraform からデプロイする際、関数のソースコードの場所を Cloud Storage もしくは Cloud Source Repository から指定でき、今回は Cloud Storage から取得するように構成しています。
Terraform を実行すると、以下の流れで処理が進みます。
- python_source_code 配下のファイルを圧縮した ZIP ファイルを main.tf と同階層に作成
- ソースコード格納用 Cloud Storage バケットを作成し 1. で作成した ZIP ファイルを格納
- 2 でバケットに格納されたソースコードを指定して Cloud Functions をデプロイ
Python ソースコード
Python ソースコードは以下の通りです。
main.py
import os from datetime import datetime, timedelta import pandas as pd import functions_framework from googleapiclient.discovery import build from google.cloud import storage CUSTOMER_ID = os.environ["CUSTOMER_ID"] UPLOAD_BUKET_NAME = os.environ["UPLOAD_BUKET_NAME"] # storage クライアントのインスタンス化 storage_client = storage.Client() def gcs_upload(kind, file_name, df): # 本日の日付を取得し、["YYYY", "MM", "DD"] のリストで格納 now = (datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d") now_li = now.split("-") # file_name を作成 kind = kind file_name = f"{kind}/{now_li[0]}/{now_li[1]}/{now_li[2]}/{file_name}.csv" # csv に変換し GCS にアップロード content_type = "text/csv" bucket = storage_client.get_bucket(UPLOAD_BUKET_NAME) blob = bucket.blob(file_name) blob.upload_from_string( df.to_csv(index=False, header=True, sep=","), content_type=content_type ) @functions_framework.cloud_event def main(cloud_event): # service オブジェクトの作成 service = build("admin", "directory_v1") # ユーザー一覧を取得 users_list_result = service.users().list(customer=CUSTOMER_ID).execute() users = users_list_result.get("users", []) # ユーザー数が 0 のときは処理を終了 if len(users) == 0 : return "User does not exist." users_df = pd.DataFrame.from_records(users) gcs_upload(kind="users_list", file_name="users_list", df=users_df) # グループ一覧を取得 groups_list_result = service.groups().list(customer=CUSTOMER_ID).execute() groups = groups_list_result.get("groups", []) # グループ数が 0 のときは処理を終了 if len(groups) == 0 : return "Group does not exist." groups_df = pd.DataFrame.from_records(groups) gcs_upload(kind="groups_list", file_name="groups_list", df=groups_df) # グループ配下のメンバーを取得 for i in range(len(groups_df)): groupKey = groups_df.at[i, "id"] groupName = groups_df.at[i, "name"] members_list_result = service.members().list(groupKey=groupKey).execute() members = members_list_result.get("members", []) members_df = pd.DataFrame.from_records(members) gcs_upload(kind="members_list", file_name=groupName, df=members_df) return "ok"
requirements.txt
DateTime==5.0 pandas==1.5.2 functions-framework==3.3.0 google-api-python-client==2.72.0 google-auth==2.16.0 google-cloud-storage==2.7.0
説明
今回、Python クライアントライブラリを用いて以下の 3 つの Directory API を実行しています。
No | Method 名 | 説明 |
---|---|---|
1 | users.list | 削除されたユーザーまたはドメイン内のすべてのユーザーのリストを取得します。 |
2 | groups.list | ドメインまたはユーザーのすべてのグループのリストを取得します。 |
3 | members.list | グループ内のすべてのメンバーのリストを取得します。 |
groups.list のレスポンスからはグループの一覧は取得できますが、グループに所属しているユーザー情報が取得できないため、groups.list で取得したグループ数だけ members.list の処理を回すようにしています。 そうすることで、グループに所属しているメンバー一覧も取得できます。
データの形成については、後々分析しやすいようデータは csv 形式に変換して Cloud Storage へアップロードしています。
また、生データをそのままの形式で蓄積しつつ、分析しやすいようにデータ形成も行いたい場合は以下のアーキテクチャも検討できます。
今回はサンプルのため実装しておりませんが、 Directory API を実行する時の注意点として、1 回のリクエストで大量のレスポンスデータを取得することが保証されていない点です。 本番運用では、 nextPageToken/pageToken を考慮し、大量のレスポンスデータは複数回に分けて取得する必要があります。
Terraform コード
main.tf
# ローカル変数の定義 locals { project_id = “<プロジェクト ID>” customer_id = “<顧客 ID>” } # terraform / providers の設定 terraform { required_providers { google = { source = "hashicorp/google" version = ">= 4.0.0" } } required_version = ">= 1.3.0" backend "gcs" { bucket = "<state 管理バケット名>" prefix = "<任意の prefix>" } } ## プロジェクトの作成 resource "google_project" "poc" { name = local.project_id project_id = local.project_id folder_id = "<folder_id>" billing_account = "<billing_account>" } ## API 有効化 module "audit_project_services" { source = "terraform-google-modules/project-factory/google//modules/project_services" version = " 14.1.0 " project_id = google_project.poc.project_id enable_apis = true activate_apis = [ "run.googleapis.com", "cloudfunctions.googleapis.com", "eventarc.googleapis.com", "cloudscheduler.googleapis.com", "admin.googleapis.com", "artifactregistry.googleapis.com", "pubsub.googleapis.com", "cloudbuild.googleapis.com", "storage.googleapis.com", ] disable_services_on_destroy = false } ## ソースコード格納用バケットの作成 # バケットの作成 resource "google_storage_bucket" "source" { project = local.project_id location = "ASIA-NORTHEAST1" name = "ggen-matayuuu-admin-sdk-api-gcf-source-002" force_destroy = true } # Cloud Functions で使うソースコードを ZIP 化 data "archive_file" "source_code" { type = "zip" source_dir = "../python_source_code" output_path = "./source_code.zip" } # ZIP 化したソースコードをバケットに追加 resource "google_storage_bucket_object" "source_code" { name = "code/function-run-1.${data.archive_file.source_code.output_md5}.zip" bucket = google_storage_bucket.source.name source = data.archive_file.source_code.output_path } ## API で取得したデータ格納用バケットの作成 # バケットの作成 resource "google_storage_bucket" "cloud_identity_inventory" { project = local.project_id location = "ASIA-NORTHEAST1" name = "ggen-cloud-identity-inventory" force_destroy = true } ## Cloud Functions と Eventarc に付与するサービスアカウントを作成 # サービスアカウント作成 resource "google_service_account" "sa_gcf" { project = local.project_id account_id = "sa-gcf" display_name = "Cloud Functions & Eventarc 用サービスアカウント" } # cloud function 実行権限を付与 resource "google_project_iam_member" "invoke_gcf" { project = local.project_id role = "roles/run.invoker" member = "serviceAccount:${google_service_account.sa_gcf.email}" } # データ格納用バケットのストレージ管理者権限を付与 resource "google_storage_bucket_iam_member" "member" { bucket = google_storage_bucket.cloud_identity_inventory.name role = "roles/storage.admin" member = "serviceAccount:${google_service_account.sa_gcf.email}" } ## Cloud Functions の作成 # pub/subトピックの作成 resource "google_pubsub_topic" "topic" { project = local.project_id name = "cloud-scheduler" } # cloud schedulerの作成 resource "google_cloud_scheduler_job" "job" { project = local.project_id region = "asia-northeast1" name = "invoke-gcf" schedule = "0 10 * * *" pubsub_target { topic_name = google_pubsub_topic.topic.id data = base64encode("run") } } # cloud functionsの作成 resource "google_cloudfunctions2_function" "execute_admin_sdk_api" { project = local.project_id location = "asia-northeast1" name = "execute-admin-sdk-api" build_config { runtime = "python310" entry_point = "main" source { storage_source { bucket = google_storage_bucket.source.name object = google_storage_bucket_object.source_code.name } } } service_config { max_instance_count = 3 min_instance_count = 1 available_memory = "256M" timeout_seconds = 60 ingress_settings = "ALLOW_ALL" all_traffic_on_latest_revision = true service_account_email = google_service_account.sa_gcf.email environment_variables = { CUSTOMER_ID = local.customer_id UPLOAD_BUKET_NAME = google_storage_bucket.cloud_identity_inventory.name } } event_trigger { trigger_region = "asia-northeast1" event_type = "google.cloud.pubsub.topic.v1.messagePublished" retry_policy = "RETRY_POLICY_DO_NOT_RETRY" pubsub_topic = google_pubsub_topic.topic.id service_account_email = google_service_account.sa_gcf.email } }
当記事では Terraform の概要等について触れないため、コマンドや tfstate ファイル等については以下の記事をご参照ください。
blog.g-gen.co.jp blog.g-gen.co.jp
実行
Terraform 実行
main.tf ファイルのある階層に移動した後、terraform init
で初期化し、terraform plan
でドライランを行い問題がなければ、terraform apply
で実環境に適用します。
Apply complete! Resources: 20 added, 0 changed, 0 destroyed.
と表示されれば成功です。
matayuuu@penguin:~/cloud_identity_inventory/terraform$ terraform apply data.archive_file.source_code: Reading... 〜省略〜 Plan: 20 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes 〜省略〜 Apply complete! Resources: 20 added, 0 changed, 0 destroyed.
Google Workspace 管理コンソールでの設定
今回使用する Directory API は、 Google Workspace 等のユーザー読み取り権限とグループ読み取り権限が必要です。 したがって、必要最小限の権限を持ったカスタムロールを作成し、Cloud Functions のサービスアカウントに付与します。
カスタムロールの作成
- Google Workspace ( Cloud Identity ) 管理コンソール > [アカウント] > [管理者ロール] > [新しいロールを作成] をクリック
- 任意の[名前]と[説明]を入力し、[続行]をクリック
- [ユーザー / 読み取り]と[グループ / 読み取り]を選択し、[続行]をクリック
- [ロールを作成] をクリック
ロールの付与
- Google Workspace ( Cloud Identity ) 管理コンソール > [アカウント] > [管理者ロール] > [先程作成したカスタムロール] をクリック
- [ロールを割り当て] をクリック
- [サービスアカウントへの割り当て]をクリック
- [<Cloud Functions のサービスアカウント>]を入力し、[追加]を選択
- [ロールを割り当て]をクリック
ここまでで設定は完了です。
確認
日次で Cloud Scheduler が実行されるのですが、強制的に即時実行させてみます。
- Google Cloud コンソール > [Cloud Scheduler] > [操作] の「︙」をクリック
- [ジョブを強制実行する] をクリック
すると Cloud Scheduler から Pub / Sub を経由し Cloud Functions が実行されます。
Cloud Storage バケットを確認すると、3 つのフォルダができてました。
また、それぞれのフォルダ配下には、年 / 月 / 日 / csv ファイル が存在しました。
このように、組織で管理する Google アカウント / グループ の棚卸しを自動化することができます。
又吉 佑樹(記事一覧)
クラウドソリューション部
はいさい、沖縄出身のクラウドエンジニア!
セールスからエンジニアへ転身。Google Cloud 全 11 資格保有。Google Cloud Champion Innovator (AI/ML)。Google Cloud Partner Top Engineer 2024。Google Cloud 公式ユーザー会 Jagu'e'r でエバンジェリスト。好きな分野は生成 AI。
Follow @matayuuuu