🎭

Google Cloud 組織、フォルダ、プロジェクトでの IAM ロール変更を検知して Slack に通知する

2024/07/02に公開

はじめに

こんにちは!クラウドエースの SRE 部に所属している小田です。

IAM(Identity and Access Management)は、Google Cloud リソースへのアクセスを制御する上で非常に重要な役割を持っていますが、IAM ロールの追加や削除により、意図せずセキュリティ上の脆弱性を生み出したり、システムの誤動作を招く可能性があるため監視が必要です。

本記事では、Google Cloud 組織、フォルダ、プロジェクトのどこかで IAM ロールが変更された際に、検知して通知する Slack Bot の実装方法をご紹介します。

Cloud Asset Inventory とは

Google Cloud 組織、フォルダ、プロジェクト内のすべての Google Cloud のアセット メタデータの履歴を保持し、リソースや IAM ポリシー、ランタイム情報などをリアルタイムでモニタリング、分析することができるサービスです。
https://cloud.google.com/asset-inventory/docs/overview?hl=ja

Cloud Asset Inventory vs Cloud Audit Logs

IAM ロールの変更を Slack に通知したい場合、Cloud Audit Logs から IAM ロールの変更操作ログを取得する方法と、Cloud Asset Inventory でアセットの変更を監視する方法があります。

Cloud Asset Inventory を使うメリットとしては、ログの集約シンクを設定することなく組織全体での IAM ロールの変更をシンプルに監視することができます。
デメリットとしては、Cloud Audit Logs のように IAM ロールの変更操作を誰がいつ行ったのかを特定することが出来ない点があります。

そのため、どちらのサービスを利用するべきかはユースケースによって異なります。

Slack Bot を作る

作成する Slack Bot は以下のような構成です。

まず最初にプロジェクトで必要な Cloud API を有効化しておきます。

  • Cloud Asset API (cloudasset.googleapis.com)
  • Cloud Pub/Sub API (pubsub.googleapis.com)
  • Cloud Resource Manager API (cloudresourcemanager.googleapis.com)
  • Cloud Functions API (cloudfunctions.googleapis.com)
  • Cloud Build API (cloudbuild.googleapis.com)
  • Artifact Registry API (artifactregistry.googleapis.com)
  • Eventarc API (eventarc.googleapis.com)

Cloud Shell で以下のコマンドを実行して、API を有効化します。

gcloud services enable cloudasset.googleapis.com pubsub.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com eventarc.googleapis.com

変更通知を受け取るための Pub/Sub トピックを作成します。

gcloud pubsub topics create <Pub/Sub トピック名>

次に Asset Feed を作成して、Resource Manager での変更通知を Pub/Sub トピックで受け取る設定をします。

gcloud asset feeds create <Asset Feed 名> \
--organization=<組織 ID> \
--content-type=iam-policy \
--asset-types="cloudresourcemanager.*" \
--pubsub-topic="projects/test-asset-inventory/topics/<Pub/Sub トピック名>"

Cloud Asset Service Agent のサービス エージェントを作成します。
service-PROJECT_NUMBER@gcp-sa-cloudasset.iam.gserviceaccount.com
https://cloud.google.com/asset-inventory/docs/faq?hl=ja#export-between-projects

gcloud beta services identity create \
--service=cloudasset.googleapis.com \
--project=test-asset-inventory

Pub/Sub トピックで Cloud Asset Service Agent のサービス エージェントに対して、Pub/Sub パブリッシャー ロールを付与します。

gcloud pubsub topics add-iam-policy-binding \
projects/test-asset-inventory/topics/grant-iam-role \
--member=serviceAccount:service-PROJECT_NUMBER@gcp-sa-cloudasset.iam.gserviceaccount.com \
--role=roles/pubsub.publisher

次に、こちらの公式ドキュメントを参考に Slack Bot を作成しておきます。
https://api.slack.com/quickstart

Bot Token Scopes に chat:write, chat:write.public を追加して発行された Bot User OAuth Token をメモします。
また、通知先の チャンネル ID もメモしておきます。

続いて Slack Bot 用のコードを作成します。
今回は Go 言語で Pub/Sub トピックからのメッセージを加工後、Slack に通知するコードを作成しました。

main.go
package example

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"os"
	"reflect"
	"strings"

	"cloud.google.com/go/functions/metadata"
	"github.com/slack-go/slack"
)

type PubSubMessage struct {
	Data []byte `json:"data"`
}

type Asset struct {
	AssetType string `json:"assetType"`
	Name      string `json:"name"`
	IamPolicy struct {
		Bindings []struct {
			Role    string   `json:"role"`
			Members []string `json:"members"`
		} `json:"bindings"`
	} `json:"iamPolicy"`
}

type Message struct {
	Asset      Asset `json:"asset"`
	PriorAsset Asset `json:"priorAsset"`
}

func IamAlert(ctx context.Context, m PubSubMessage) error {
	meta, _ := metadata.FromContext(ctx)
	fmt.Printf("Function triggered by message on topic: %s\n", meta.Resource)

	msgStr := string(m.Data)

	var msg Message
	json.Unmarshal([]byte(msgStr), &msg)

	delta := findBindingChanges(msg)

	if len(delta) > 0 {
		assetType, assetName := getAssetTypeName(msg.Asset.AssetType, msg.Asset.Name)
		return sendSlack(assetType, assetName, delta)
	}
	return nil
}

func findBindingChanges(msg Message) []map[string]interface{} {
	delta := make([]map[string]interface{}, 0)
	priorBindings := make(map[string][]string)
	for _, binding := range msg.PriorAsset.IamPolicy.Bindings {
		priorBindings[binding.Role] = binding.Members
	}

	for _, binding := range msg.Asset.IamPolicy.Bindings {
		priorMembers, exists := priorBindings[binding.Role]
		if !exists || !reflect.DeepEqual(priorMembers, binding.Members) {
			action := "Added"
			if exists {
				action = "Modified"
			}
			delta = append(delta, map[string]interface{}{
				"members":       binding.Members,
				"role":          binding.Role,
				"membersAction": action,
			})
		}
		delete(priorBindings, binding.Role)
	}

	for role, members := range priorBindings {
		delta = append(delta, map[string]interface{}{
			"members":       members,
			"role":          role,
			"membersAction": "Removed",
		})
	}
	return delta
}

func getAssetTypeName(assetType, assetName string) (string, string) {
	assetTypeParts := strings.Split(assetType, "/")
	assetNameParts := strings.Split(assetName, "/")
	return assetTypeParts[len(assetTypeParts)-1], assetNameParts[len(assetNameParts)-1]
}

func sendSlack(assetType, assetName string, delta []map[string]interface{}) error {
	slackToken := os.Getenv("SLACK_TOKEN")
	slackChannel := os.Getenv("SLACK_CHANNEL")

	api := slack.New(slackToken)

	headerTextBlock := slack.NewTextBlockObject("plain_text", "IAM ロール変更通知", false, false)
	headerSection := slack.NewHeaderBlock(headerTextBlock)

	assetField := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s:* %s", assetType, assetName), false, false)
	assetSection := slack.NewSectionBlock(assetField, nil, nil)

	blocks := slack.Blocks{
		BlockSet: []slack.Block{
			headerSection,
			assetSection,
		},
	}

	for _, binding := range delta {
		blocks.BlockSet = append(blocks.BlockSet, slack.NewDividerBlock())

		membersText := slack.NewTextBlockObject("mrkdwn",
			fmt.Sprintf("*Principal:*\n%s", strings.Join(binding["members"].([]string), "\n")), false, false)

		action := binding["membersAction"].(string)
		roleText := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s:* %s", action, binding["role"]), false, false)

		section := slack.NewSectionBlock(nil, []*slack.TextBlockObject{membersText, roleText}, nil)
		blocks.BlockSet = append(blocks.BlockSet, section)
	}

	urlStr := fmt.Sprintf("https://console.cloud.google.com/iam-admin/iam?%s=%s", strings.ToLower(assetType), url.QueryEscape(assetName))
	buttonText := slack.NewTextBlockObject("plain_text", "Open Google Cloud Console", false, false)
	button := slack.NewButtonBlockElement("", "", buttonText)

	button.URL = urlStr

	buttonSection := slack.NewActionBlock("", button)

	blocks.BlockSet = append(blocks.BlockSet, buttonSection)

	_, _, err := api.PostMessage(slackChannel, slack.MsgOptionBlocks(blocks.BlockSet...))
	return err
}

Pub/Sub トピックをトリガーにした Cloud Functions(第 2 世代)にデプロイします。
環境変数で Bot User OAuth Token と通知先のチャンネル ID を設定しています。

gcloud functions deploy iam-role-notification \
--gen2 \
--region=asia-northeast1 \
--runtime=go122 \
--entry-point=IamAlert \
--trigger-topic=<Pub/Sub トピック名> \
--set-env-vars SLACK_TOKEN=xoxb-xxxxxxxx-xxxxxxxx-ABCDEFG,SLACK_CHANNEL=C0123456ABC

動作確認

作成した Slack Bot の動作を行います。
組織、フォルダ、プロジェクトで IAM ロールを変更して Slack チャンネルに通知されるか確認してみます。

組織で user-01@example.com閲覧者 ロールを付与してみます。

ロールを付与するとすぐに通知されました。

フォルダで user-01@example.comCloud Run デベロッパー ロールを付与してみます。

プロジェクトで user-01@example.comBigQuery データ編集者Cloud SQL 編集者 ロールを付与してみます。

続いて BigQuery データ編集者 を削除し、Cloud SQL 編集者Cloud SQL 管理者 に変更してみます。

IAM ロールの変更内容が正しく Slack に通知されているようです。

まとめ

今回は Cloud Asset Inventory を利用して、Google Cloud の IAM ロールの変更内容を検知して通知する Slack Bot を作ってみました。

冒頭でも記載しましたが、Cloud Asset Inventory と Cloud Audit Logs どちらを利用しても同じように変更を通知することができるため、ユースケースに応じて使い分けてください。

Discussion