私が Service Account をプログラムに組み込んだ理由 + おまけ (Cloud IAM Conditions)

## 提案

- プログラムで使用するサービスアカウントを埋め込んでみませんか？
- プロダクトのソースで GCP を操作する時はServiceAccountのアクセストークンを生成しませんか？

## GCP 使ってて時々思うこと

### デフォルトのServiceAccountの権限が広い

![スクリーンショット 2019-12-23 18.44.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39372/28abe0f4-5575-3774-0efb-ab2561aaba4e.png)

いくつかのサービスは有効化すると、そのサービスとして使用するServiceAccountが自動で作られてIAMでプロジェクトレイヤーの権限が割り当てられるけど、**Editor**とか超広範囲の権限が設定されてたりして頭が痛い。

<img width="360" alt="スクリーンショット 2019-12-23 18.58.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39372/642eace8-10ef-c6d2-a156-8d4fc263d074.png">

2019/12/25 の段階で2263個ものパーミッションが設定されてるEditorロールから適切な権限範囲のロールに直していく気力は自分には無い。
いくらOver granted permissionsで使ってる権限の参考情報が見れても流石に多すぎる。

### デフォルトのServiceAccountのロールが何の権限があるんだか分からない

![スクリーンショット 2019-12-23 18.48.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39372/2a37f64f-5be5-2253-3bd6-482951e17af9.png)

<img width="160" alt="スクリーンショット 2019-12-23 18.51.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39372/643dc414-127e-bdba-84bd-4ec3787caab6.png">

例えば Cloud Build を起動させた時、その内部で設定されていて変更ができないServiceAccountには Cloud Build だけではなく GCS や PubSub など他のサービスのパーミッションも含まれるから、CIから本来公開したくないファイルとかも見れたりしちゃうかもしれない。(Cloud Build は Cloud Source Repositories と組み合わせれば外部リポジトリ連携ができるので)

### このプログラムはどのServiceAccountを使ってるのか？ or このServiceAccountはどの (ry

Cloud Functions でも Cloud Run でも GKE Workload Identity でもなんでも良いけどプログラムやOS内部からしたら何かしらの ServiceAccount の認証情報が設定されているだけ。

だからアプリケーションと ServiceAccount は疎結合なんだけど、そのせいで ServiceAccount の棚卸しで気付けば権限剥奪されてたりする。

気付くのが遅くて ServiceAccount の IAM 設定が完全消滅していたら、元の権限を与えるためには、トライ＆エラーでパーミッションエラー出しまくって都度付与するか、プログラム読んで設定するか、無駄に広い権限を与えることになる。

## そこでどうしたかというと

- プログラムで使用するServiceAccountを作成する
- プログラムの中でそのServiceAccountに対する**GenerateAccessToken**を行う
    - そうすると、ServiceAccountのアクセストークンが生成される
- プログラムの中ではそのアクセストークンで GCP を操作する
- Cloud Functions でも Cloud Run でも GKE Workload Identity でも、外部から設定される ServiceAccount にはプログラム内の ServiceAccount の **Service Account Token Creator** ロールのみ与える

### 利点とか

- サービスに紐づくServiceAccountとプログラムに紐づくServiceAccountを分離できる
    - それはつまりプログラムは **Service Account Token Creator** ロールを持ちさえすれば、エントリーポイントを選ばない実装ができるということになる
    - バッチをCloudFunctionsで動かして、何かあった時は手動で動かすという時も、IAMで設定すべきは **Service Account Token Creator** ロールのみ
- プログラム内のServiceAccountの**Service Account Token Creator** ロールがないと実行できないので、プログラム単位の実行権限設定に繋がる (不正なユーザーによる手動実行の防止)
- プログラムの所要時間に合わせてアクセストークンには有効期限を設定しておけば JSON などの認証ファイルよりは短命だしプログラム外部に認証情報を漏らす必要がないので安全
- アクセストークンを作成するときにスコープを指定するのでどのサービスを使うのか明確になる
- ソースから使ってるServiceAccountが明確になるので多少は間違って消されにくくなる？

### GenerateAccessTokenの仕方 (Golang)

```go
package main

import (
	"context"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/xerrors"

	"cloud.google.com/go/storage"

	iamcredentials "google.golang.org/api/iamcredentials/v1"
	"google.golang.org/api/option"
)

func main() {
	src, err := GenerateAccessToken(context.Background(), "processer@example.com", "60s", []string{storage.ScopeFullControl})
	if !xerrors.Is(err, nil) {
		log.Fatalf("%+v", err)
	}

	token, err = src.Token()
	if !xerrors.Is(err, nil) {
		log.Fatalf("%+v", err)
	}

	log.Printf("%#v", token)
}

func GenerateAccessToken(ctx context.Context, serviceAccountEmail string, ttl string, scopes []string, opts ...option.ClientOption) (oauth2.TokenSource, error) {
	srv, err := iamcredentials.NewService(ctx, opts...)
	if !xerrors.Is(err, nil) {
		return nil, xerrors.Errorf("Failed to setup IAM Credentials service: %w", err)
	}

	resource := fmt.Sprintf("projects/-/serviceAccounts/%s", serviceAccountEmail)
	request := &iamcredentials.GenerateAccessTokenRequest{
		Lifetime:  ttl,
		Scope:     scopes,
		Delegates: []string{},
	}
	res, err := srv.Projects.ServiceAccounts.GenerateAccessToken(resource, request).Do()
	if !xerrors.Is(err, nil) {
		return nil, xerrors.Errorf("Failed to generate access token: %w", err)
	}

	expiry, err := time.Parse("2006-01-02T15:04:05.999999999Z07:00", res.ExpireTime)
	if !xerrors.Is(err, nil) {
		return xerrors.Errorf("Failed to parse expiration time for access token: %w", err)
	}
	return oauth2.StaticTokenSource(&oauth2.Token{
		TokenType:   "bearer",
		AccessToken: res.AccessToken,
		Expiry:      expiry,
	}), nil
}
```

## おまけ

本当は [Cloud IAM Conditions](https://cloud.google.com/iam/docs/conditions-overview) について書こうと思ったけど、限定公開ベータ版なのでまだ使えない人がいると悲しいのでおまけにします。

### Cloud IAM Conditions とは

- IAM でユーザーやServiceAccountにロールを割り当てる際に条件を指定できる
    - そのロールが適用される範囲におけるリソースの操作に対して、指定した条件に合致するかを更に判定し、当てはまる場合のみそのロールが適用されるようになる

### 用途

文章で機能を説明しても難しいので例を挙げると、

- 指定した曜日／時刻帯／期間内のみ操作権限を与える
    - GCS に平日の業務時間帯しかアクセスできないようにする
    - GCS に契約期間終了までアクセスできるようにする
- 指定したバケット／オブジェクト／拡張子／採番にのみ操作権限を与える
    - バケットごとに管理者を分ける
        - バケットのIAMでも良いけどそれだとバケットが無い状態では設定できないから設定順序を意識する必要がある
    - プログラム(のServiceAccount)にのみ特定のフォルダ(という名の接頭語)のオブジェクト作成権限を与えて、デフォルトでは全員閲覧権限のみとする
    - 削除バッチが不要なファイルを消さないように **.bk** ファイルのみ権限を与える
    - 社員番号の末尾と同じ採番がされたファイルのみアクセスできるようにする

とか、GCSだけでも色々できますが、Cloud IAM Conditions で使える対象は多分随時増えるので夢いっぱいです。

### やり方

```iam-policy.json
{
  "bindings": [
    {
      "members": [
        "user:admin@example.com"
      ],
      "role": "roles/storage.admin",
      "condition": {
        "title": "condition",
        "expression": "resource.type == 'storage.googleapis.com/Bucket' && resource.name == 'hoge-project-data'"
      }
    },
    {
      "members": [
        "user:controller@example.com"
      ],
      "role": "roles/storage.objectViewer",
      "condition": {
        "title": "condition",
        "expression": "resource.type == 'storage.googleapis.com/Object' && resource.name.startsWith('results/')"
      }
    },
    {
      "members": [
        "user:processer@example.com"
      ],
      "role": "roles/storage.objectCreator",
      "condition": {
        "title": "condition",
        "expression": "resource.type == 'storage.googleapis.com/Object' && resource.name.startsWith('results/') && resource.name.endsWith('.tsv.gz')"
      }
    }
  ]
}
```

上記のようなconditionフィールドを追加したIAMポリシーファイルを用意して

```bash
$ gcloud projects set-iam-policy exmaple-project iam-policy.json
```

と設定するだけ