
Google Application Default Credentialsが急に動かなくなったときの対処法
Google Cloudの認証は「とにかく動いてくれるもの」——そう思っていませんか? 新しいVMを立ち上げてgcloudを入れれば、まるで魔法のようにGoogle Cloud Storageのバケットにアクセスできてしまいます。
Google Cloudが掲げる大きな魅力のひとつは、認証をシンプルにしてくれる点です。「Application Default Credentials」(ADC)を取得しさえすれば、開発マシンではユーザーの認証情報、Google Cloud上ではサービスアカウントの認証情報が自動的に使われ、どんな環境でも動いてくれる——実際、これは本当によくできた仕組みです。
Google Cloudの中だけで完結し、他のGoogleサービスにアクセスしようとしない限り、ADCはこの約束をきちんと果たしてくれます。しかし、この夢のような話を一気に崩す2つのキーワードがあります。それが SCOPE と SUBJECT です。
スコープ(Scope)とは
この用語、おそらく一度は耳にしたことがあるはずです。実体のあるWebサイトが裏にあるわけでもない、こんな感じのURLのことです :)
https://www.googleapis.com/auth/cloud-platform
https://www.googleapis.com/auth/compute.readonlyこれらはoAuth 2.0のスコープで、GCPやその他のGoogle API(Drive、G Suiteディレクトリなど)に対して、大まかなアクセス制御を設定するためのものです。一覧はこちらにまとまっています。
Google Cloudの黎明期には、現在のようなきめ細かい権限制御ができる柔軟なIAMはまだありませんでした。当時あったのはViewer、Editor、Ownerといった「プリミティブ」ロールだけで、VMの権限を絞るためにスコープが使われていたのです。
たとえば、Cloud Storageにデータを書き込みつつ、他のVMをリスト(=「探索」)する必要があるVMを起動したい場合、手順は次のようになります。
- プロジェクトに対してEditorロールを持つサービスアカウントでVMを起動する(Cloud Storageへの書き込みが必要なので、Viewerでは足りません)
- VM起動時にスコープを明示的に指定する:
compute.readonlyとdevstroage.read_write(なぜ *dev*storage という名前なのか、ご存じの方はいますか?)
その後Googleは、スコープによるアクセス制御では粒度が粗すぎることに早々に気づき、IAM権限が主役となって、ほとんどのケースでスコープは過去のものになりました。実際、現在_カスタム_サービスアカウントでVMを起動すると、スコープのデフォルトはcloud-platform(つまり「GCP全体へのフルアクセス」)になりますが、実際のアクセスはVMのサービスアカウントに付与されたIAM権限で制御されるため、これ自体に大きな意味はありません。
スコープを意識すべきなのはどんなとき?
GCPの世界から一歩外に出るとき——おそらく、初めてGCP外のGoogleサービスにアクセスしようとした瞬間に、スコープというものに出くわしたのではないでしょうか。要するに、Google Driveにあるスプレッドシートを読みたいときのように、cloud-platformスコープでカバーされていないAPIを使う場面です。
Google Driveにアクセスするには、アプリの認証情報に https://www.googleapis.com/auth/drive スコープが必要です。とはいえこれも、Google Driveに_アクセスできる_ 余地 を与えるだけにすぎません。実際に動かすには、対象のスプレッドシートをVMのサービスアカウントのメールアドレスと共有する必要があります。
まとめると——GCP外のGoogle APIを使う場合は、認証情報を用意する段階でスコープも考慮しなければならない、ということです。
スコープを指定する
おさらいすると、Pythonユーザーなら通常はこう書くはずです。
import google.authcreds, project = google.auth.default()これだけで動きます。ところが、追加のスコープが必要になると、途端に話がややこしくなります。
GCEとGKE
Compute EngineとKubernetes Engineは比較的恵まれていて、VMやノードプールの作成時にスコープを指定できます。ただし、これはgcloudを使う場合のみです(—-scopesオプションを参照)。Webコンソールでは、自前のサービスアカウントを使うとスコープ指定の手段そのものが消えてしまい、それ以外でもGCP関連のスコープしか調整できません。
Webコンソールではスコープを設定できない
簡単なCLIデモを示します。
gcloud compute instances create instance-1 ... \ --scopes=cloud-platform,https://www.googleapis.com/auth/drive
gcloud compute ssh instance-1 -- python3>>> import google.auth>>> creds, project = google.auth.default()>>> from google.auth.transport import requests>>> creds.refresh(requests.Request())>>> creds.scopes['\```\\[https://www.googleapis.com/auth/cloud-platform'](https://www.googleapis.com/auth/cloud-platform%27)\\```\, '\```\\[https://www.googleapis.com/auth/drive'](https://www.googleapis.com/auth/drive%27)\\```\]````
GCEとGKEで「比較的恵まれている」と書いたのは、スコープが作成時にしか指定できないからです。VMが1台ならまだしも、GKEではスコープを1つ追加するためだけにノードプール全体を作り直す羽目になることもあります。
結局のところ、コード自体は変わらないものの、事前にDevOps的な仕込みが欠かせない、というわけです。
とはいえ、VMにあらかじめ許可されて_いない_スコープでも認証情報を取得する方法はあります。これについては記事の後半で解説します。
#### Google App Engine
App EngineはGCPでも最古参のサービスのひとつで、多くの面で時代を先取りした本格的なサーバーレスフレームワークです。ただし、長い歴史にはレガシーな制約もつきものです。そのひとつが、すべてのApp Engineアプリのスコープが`cloud-platform`に固定されていて、簡単には抜け出せないという点です。
回避策のひとつは、デフォルトのApp Engineサービスアカウントから別のサービスアカウント、あるいは自分自身に「なりすまし(impersonate)」を行いつつ、新しいスコープを要求する方法です。Google Cloudの公式ドキュメントは[**こちら**](https://cloud.google.com/iam/docs/understanding-service-accounts#acting_as_a_service_account)。Pythonでの書き方を見てみましょう。import google.auth from google.auth import impersonated_credentials as ic
````SCOPES = ["\```\\[https://www.googleapis.com/auth/drive](https://www.googleapis.com/auth/spreadsheets.readonly)\\```\"]svcacc = "test````
[@my-project-id.iam.gserviceaccount.com](mailto:[email protected])"
adc, _ = google.auth.default() creds = ic.Credentials(source_credentials=adc, target_principal=svcacc, target_scopes=SCOPES)
service = build("sheets", "v4", credentials=creds, cache_discovery=False)
`cache_discovery=False`引数に注意してください——[バグ](https://github.com/googleapis/google-api-python-client/issues/299)のため、これを付けないと動きません。上のコードを動かすには、プロジェクト内のAppEngineデフォルトサービスアカウントから対象のサービスアカウントへ、Token Creatorロールを付与する必要もあります。gcloud iam service-accounts add-iam-policy-binding
test@{$PRJ_ID}.iam.gserviceaccount.com
--member="serviceAccount:{$PRJ_ID}@
[appspot.gserviceaccount.com](https://console.cloud.google.com/iam-admin/serviceaccounts/details/108759703281763060579?authuser=1&project=zaar-playground)"
--role=roles/iam.serviceAccountTokenCreator
**このアプローチには重要なセキュリティ上の注意点がある**ことを忘れないでください——同じプロジェクト内のApp Engineサービスはすべて _同一のサービスアカウント_ で動作します。つまり _そのすべて_ がなりすましを実行できてしまうため、セキュリティ上の懸念になり得ます。
残念ながら手軽な回避策はありませんが、解決策は記事の後半で紹介します。
**2021年11月のアップデート:** 現在App Engineでは、サービスごとに[カスタムサービスアカウントを指定できる](https://cloud.google.com/appengine/docs/standard/python3/user-managed-service-accounts)ようになりました。
#### Cloud Run / Functions
Cloud RunサービスやCloud Functionをデプロイするときには、スコープに関する話は一切登場しません——ようやくです! 必要なスコープを次のようにその場で要求できるなら、わざわざ事前に縛り付けておく必要はありませんよね。
````import google.authSCOPES = ["\```\\[https://www.googleapis.com/auth/drive](https://www.googleapis.com/auth/spreadsheets.readonly)\\```\"]creds, _ = google.auth.default(scopes=SCOPES)````
あとは、お好みのAPIで普段どおりに処理を進めるだけです。完全な例は[こちら](https://medium.com/@guillaume.blaquiere/yes-my-experience-isnt-the-same-63ac3c783864)を参照してください。
個人的には、GKE/GCE/GAEでも本来こうあるべきだったと思っています。
念のため——GCE/GKEで同じことをやっても期待した動きにはなりません。GCE/GKEでは、アプリ内で要求したスコープは無視され、VMやノードプールに設定されたスコープに上書きされてしまいます。
#### **ローカル開発環境**
Google Directory APIを触り始めたとき、APIを呼び出すには**必ず**GCPサービスアカウントが必要で、対象APIを有効化したGCPプロジェクトもきちんと用意しなければならないと知って、驚いたのを覚えています。
ローカル開発環境(つまり手元のノートPC)で取得したADC認証情報は、通常は個人ユーザーに紐づいているため、Directory APIをはじめとする多くの非GCP APIではほぼ使い物になりません。
結局のところ、ここでもなりすましを使うしかなく、上のGAEのセクションで紹介したコードがそのまま流用できます。
## サブジェクト(Subject)とは
サブジェクトは、認証のブートストラップコードをさらに複雑にする、ちょっとした要素です。_Directory API_にアクセスする(たとえばG Suiteドメインのユーザー一覧を取得する)場合、ディレクトリ内の特定ユーザーの代理として動作することになり、そのユーザーを認証情報の_subject_として指定する必要があるのです。
これを踏まえると、ローカル開発環境からの実行は、なりすましがぐるりと一周する構図にもなり得ます——ローカルで認証された`[email protected]`が、自ドメインのDirectory APIと通信するためにサービスアカウントになりすまし、そのサービスアカウントが今度はディレクトリ内で`[email protected]`として動作する、というわけです。
いずれにせよ、この「act as」のメールアドレスを認証情報のサブジェクトとして指定する必要があり、認証セットアップのコードは次のように膨らみます。import google.auth import google.auth.iam from google.auth.transport.requests import Request from google.oauth2 import service_account as sa
TOKEN_URI = "
[https://accounts.google.com/o/oauth2/token](https://accounts.google.com/o/oauth2/token)
````"SCOPES = ["\```\\[https://www.googleapis.com/auth/drive](https://www.googleapis.com/auth/spreadsheets.readonly)\\```\"]svcacc = "test````
[@my-project-id.iam.gserviceaccount.com](mailto:[email protected])" subject = "[email protected]"
adc, _ = google.auth.default() signer = google.auth.iam.Signer(Request(), adc, svcacc) creds = sa.Credentials(signer, svcacc, TOKEN_URI, scopes=SCOPES, subject=subject) service = build("sheets", "v4", credentials=creds, cache_discovery=False)
このコードはここまで紹介したすべてのケースで動きます——Directory APIを使わない場合は`subject=None`を渡し、Token Creatorロールを適切に割り当てるのを忘れないでください。
**これで解決策が手に入りました!** ……とはいえ、このアプローチを「本番運用」レベルに仕上げ、_identity_トークンまで絡めると、認証情報を取得するだけでおよそ200行の[コード](https://gist.github.com/haizaar/fcf8ee4b98b2452c618582bca632a338)になってしまうのが難点です。しかも半分はコメント。これは大抵、複雑さの裏返しです。
## もっと良い方法はないのか?
App Engineの潜在的なセキュリティ懸念を除けば、どこでも動く解決策は手に入りました。しかし、以前のシンプルな`google.auth.default()`呼び出しと比べると、なんとも込み入った印象は否めません。別の道はないのでしょうか?
人によってはこちらの方がシンプルに感じられるかもしれない、ひとつの提案があります。少なくとも、なりすましにまつわるApp Engineのセキュリティ懸念には対処できます。手順は次のとおりです。
- なりすまし対象のサービスアカウントについて、キーファイルを作成します。そう、あの昔ながらの「不格好な」キーファイルです。- [Mozilla sops](https://github.com/mozilla/sops) + GCP KMSでファイルを暗号化し、コードと一緒に保管します。- サービスアカウントキーファイルの暗号化に使ったGCP KMSキーに対して、運用用サービスアカウントにdecryptorロールを付与します。
ブートストラップコードはこのようになります。import json import os import subprocess from google.oauth2 import service_account as sa
path = os.getenv("SERVICE_ACCOUNT_KEY_PATH") json_data: str = subprocess.run(["sops", "-d", path], check=True, capture_output=True, timeout=10).stdout sa_info = json.loads(json_data) creds = sa.Credentials.from_service_account_info(sa_info)
得られた`creds`オブジェクトには便利な`with_scopes()`と`with_subject()`というメソッドが用意されており、スコープやサブジェクトを差し替えた新しい認証情報オブジェクトを返してくれます。
セットアップの手間は確かに増えます(Cloud Functionsやコンテナイメージに30MiBほどの`sops`バイナリを組み込む必要もあります)。とはいえ、フローが格段に追いやすいのが大きな利点です(先ほどのアプローチとは対照的です)。
この方法はApp Engineの問題も解決してくれます——確かに、App Engineのデフォルトサービスアカウントはすべてのキーファイルを復号できる可能性こそ残るものの、別々のgitリポジトリからデプロイされたGAEサービス同士は、互いに暗号化されたサービスアカウントキーファイルにアクセスできません。つまり、シークレットの分離が実現できるのです!
#### 認証のトレーサビリティ
ここからはおまけのパートです——ここまで読んでくださってありがとうございます。
不格好なサービスアカウントキーファイルに対して、なりすましには嬉しい副次的な利点があります。サービスアカウントへのなりすましを行った元のプリンシパルを追跡できる、という点です。
監査のチートシートはこちらです。
- IAMサービスのデータアクセス監査ログを[有効化](https://cloud.google.com/iam/docs/audit-logging#enabling_audit_logging)する- なりすましを実行する。例: `gcloud --impersonate-service-account=<email> projects list`- [Logs Explorer](https://console.cloud.google.com/logs)を開き、「Resource」ドロップダウンから「Service Account」を、「Log name」ドロップダウンから「data\_access」を選択する
あるいは、次のクエリを直接入力してもかまいません。resource.type="service_account"
logName="projects/
なりすましの方法によりますが、`methodName`が`SignBlob`または`GenerateAccessToken`になっているペイロードが、探しているログです。
なりすまし行為の監査ログ
では、サービスアカウントキー + SOPSのアプローチでは振り出しに戻ってしまうのでしょうか? 結局はサービスアカウントキーを使うので、操作を行った実体を直接示すものはありません。_とはいえ_、まずはCloud KMSでJSONキーファイルを復号する必要があり(これはSOPSが裏で行っています)、ここで「パンくず」を残せます。
- Cloud KMSサービスのデータアクセス監査ログを有効化する
- SOPSでJSONキーファイルを復号する
- `resource.type="cloudkms_cryptokey" resource.labels.location="global"` でクエリすると、手がかりが得られます。

つまり、誰がそのキーを使って復号したかを把握できるわけです。それ単体では決定的な証拠とは言えませんが、適切な命名規則と「KMSキー1つにつきサービスアカウント1つ」という運用を組み合わせれば、容疑者の特定に役立つ強い相関情報になります。
## エピローグ
本記事が、GCPだけの世界から、より広いGoogle APIエコシステムに足を踏み出した瞬間に認証が壊れてしまう理由について、少しでも光を当てられたなら幸いです。
まとめると、oAuthスコープは、本格的なIAMを備えた現代の多くのサービスにとって本当の意味でのセキュリティ機構ではないにせよ、システムを動かすうえでは依然として欠かせません。そして、お使いのプラットフォーム——App Engine、GCE/GKEなど——によっては、そこにたどり着くまでに相応の手間がかかることもあります。
**_Zaar Haiは_** [**_DoiT International_**](https://www.doit.com/) **_のStaff Cloud Architectです。ZaarやDoiT InternationalのSenior Cloud Architectsと一緒に働きたい方は、ぜひ_** [**_採用ページ_**](http://careers.doit.com/) **_をご覧ください!_**