Google API のためにサービスアカウントを Go ライブラリで Impersonate する方法

2022-06-14

はじめに

プログラムから Google Group を取得するなどの目的で Google Admin API を呼び出すためには認証・認可の設定をする必要があります。 Web アプリや CLI として実行する場合、認証は 1. サービスアカウントとして認証 2. 利用ユーザーとして認証 の 2 通りの選択肢がありますが、 本記事では前者のサービスアカウント(以下 SA)として認証をする場合について記述します。

通常 GCE/GAE/Cloud Run などの GCP 上のコンピューティング基盤を利用するのであれば、SA にさえ権限を与えたらサービス or VM のデフォルトの SA の認証情報である Application Default Credentials (以下 ADC) を用いて各種サービスへの権限に基づいたアクセスが可能です。
しかし、Google Admin API (の Go クライアント) では ADC を用いることが出来ず、SA の JSON Key を利用する必要があり通常とは異なる形になるのでこれ関して調査した結果を共有します。

前提知識

ここでは Google Admin API を呼び出す場合に必要な事前知識を説明します。

Impersonate とは

Impersonate とは、SA によるアクセスを Google Workspace (以下 GWS) 上に実在するアカウントによるアクセスとして行うことを指します。
公式ドキュメントに詳しくありますが、例えば SA を用いて Google Group を扱う Directory API を利用する場合、 ドメイン全体の権限を SA に委任する Domain Wide Delegation を設定し、OAuth の Subject の値を実際に行為を行う権限を持つ GWS のアカウント (i.e. SA ではなく個人のアカウント) に設定する必要があります。 特に本記事では Impersonate と言う場合はこの Subject を上書きすることを指します。

公式ドキュメントのサンプルより


    jsonCredentials, err := ioutil.ReadFile(ServiceAccountFilePath)
    ...
    config, _ := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryUserScope)
    config.Subject = userEmail // Impersonate
    ts := config.TokenSource(ctx)

Application Default Credentials とは

ADC とはアプリケーションがデフォルトで用いる認証情報のことです (公式ドキュメント)。 アプリケーションが Cloud Run や GAE、GCE など GCP 環境内で実行されていて、その環境に SA が接続されている場合、 アプリケーションはその SA の認証情報を自動で取得し GCP の API やサービスを呼び出すのに用いることが出来ます。

例えば GCS 上のファイルを読み込むためには objectViewer のような権限が必要です。 Cloud Run 上で走るアプリケーションから GCS のクライアントライブラリを用いて GCS 上のファイルを読み込む場合、Run で利用する SA が objectViewer を持っていればクライアントライブラリの初期化時に SA の認証情報を自動で取得し必要な権限が与えられます。

ローカル環境から SA の認証情報を用いる場合、SA の JSON Key をダウンロードし GOOGLE_APPLICATION_CREDENTIALS に JSON Key のパスを設定することで GCP のクライアントライブラリは自動で SA の権限を利用することが出来ます。

Google API のための Impersonate 方法について

Go を用いて GCP サービスの API をプログラム経由で呼び出したい場合は通常 Google Cloud Client Libraries for Go (以下 GCP ライブラリ) を用いますが、Google API を利用する場合は Google APIs Client Library for Go (以下 API ライブラリ)を利用します。

GCP ライブラリに慣れている方は先の例を見た時にピンときたかもしれませんが、公式ドキュメントの例では生の JSON キーを読み取り利用しています。通常 GCP ライブラリを GCP サービス上で利用する場合、JSON キーは利用せず ADC を利用します。 この理由として、JSON キーをダウンロードすると漏洩などのセキュリティリスクが生まれるためできるだけ JSON キーをダウンロードしない形での認証・認可が望ましいからです1

私自身ここで違和感を感じいくつかドキュメントを探して見ましたが、JSON キーを利用する実装例しか見つかりません。 そこで、ADC を行う方法は本当に無いのか調査をしてみました。

FindDefaultCredentials による Token 発行の調査

公式ドキュメント の実装は config.Subject = userEmail の形で JSON キーを渡した golang.org/x/oauth2JWTConfigFromJSON 関数から返ってくる config の Subject に email を設定しており、その config を用いて JWT Token を発行します。 この config は oauth2 ライブラリで定義されている jwt.Config を利用しています。

ではこの jwt.Config を ADC を利用して生成する関数は API クライアントに無いのでしょうか?

...ありません。

oauth2 ライブラリで jwt.Config を返してくれるのは以下の 2 つのみです。

  1. JWTConfigFromJSON
  2. credentialsFile.jwtConfig

ここで jwt.Config から一旦離れ、 oauth2 ライブラリ内で ADC を利用できる関数を探すと FindDefaultCredentials が見つかります。 ちなみにこの関数は 2021 年に実装が変わっていますが、これには後ほど触れます。

この関数はその名の通り Default Credentials を利用でき、ローカル環境においては GOOGLE_APPLICATION_CREDENTIALS の設定先ファイル、GCP 上においては ADC よりアクセストークンを発行します。

// FindDefaultCredentials invokes FindDefaultCredentialsWithParams with the specified scopes.
func FindDefaultCredentials(ctx context.Context, scopes ...string) (*Credentials, error) {
    var params CredentialsParams
    params.Scopes = scopes
    return FindDefaultCredentialsWithParams(ctx, params)
}

// FindDefaultCredentialsWithParams searches for "Application Default Credentials".
//
// It looks for credentials in the following places,
// preferring the first location found:
//
//  1. A JSON file whose path is specified by the
//     GOOGLE_APPLICATION_CREDENTIALS environment variable.
//     For workload identity federation, refer to
//     https://cloud.google.com/iam/docs/how-to#using-workload-identity-federation on
//     how to generate the JSON configuration file for on-prem/non-Google cloud
//     platforms.
//  2. A JSON file in a location known to the gcloud command-line tool.
//     On Windows, this is %APPDATA%/gcloud/application_default_credentials.json.
//     On other systems, $HOME/.config/gcloud/application_default_credentials.json.
//  3. On Google App Engine standard first generation runtimes (<= Go 1.9) it uses
//     the appengine.AccessToken function.
//  4. On Google Compute Engine, Google App Engine standard second generation runtimes
//     (>= Go 1.11), and Google App Engine flexible environment, it fetches
//     credentials from the metadata server.
func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) {

FindDefaultCredentialsFindDefaultCredentialsWithParams は ADC を利用できるのになぜこれらの関数を用いて Google API の利用が出来ないのでしょうか?
理由は JWT Token の Subject に Impersonate するための email を差し込む余地が無いためです。

FindDefaultCredentials から返ってくる Credentials は JWT Token 発行に利用できるものとして TokenSource を持っています。

// Credentials holds Google credentials, including "Application Default Credentials".
// For more details, see:
// https://developers.google.com/accounts/docs/application-default-credentials
// Credentials from external accounts (workload identity federation) are used to
// identify a particular application from an on-prem or non-Google Cloud platform
// including Amazon Web Services (AWS), Microsoft Azure or any identity provider
// that supports OpenID Connect (OIDC).
type Credentials struct {
	ProjectID   string // may be empty
	TokenSource oauth2.TokenSource

	// JSON contains the raw bytes from a JSON credentials file.
	// This field may be nil if authentication is provided by the
	// environment and not with a credentials file, e.g. when code is
	// running on Google Cloud Platform.
	JSON []byte
}

この TokenSource は公式ドキュメントの例でも config.TokenSource(ctx) の形で出てきましたがどういったものでしょうか?

// A TokenSource is anything that can return a token.
type TokenSource interface {
	// Token returns a token or an error.
	// Token must be safe for concurrent use by multiple goroutines.
	// The returned Token must not be modified.
	Token() (*Token, error)
}

TokenSource は Token を発行するものに使われるインターフェイスです。

つまり Credentials はインターフェイスである TokenSource を持っているため FindDefaultCredentials での Credentials 受け取り以降は Token を発行するため基となるデータに手を加えることは出来ず、Subject の差し込みは出来ません。

FindDefaultCredentialsWithParams の調査

次に 2021 年と最近追加された FindDefaultCredentialsWithParams の調査を行います。 ちなみに以前私がこの Google API における Impersonate で SA を利用できない課題を調べたのは Belong 入社時の 2020 年頃でこの関数はありませんでした。

FindDefaultCredentialsWithParamsCredentialsParams を利用して以前から利用できた Scope 以外にも設定ができます。

/ CredentialsParams holds user supplied parameters that are used together
// with a credentials file for building a Credentials object.
type CredentialsParams struct {
	// Scopes is the list OAuth scopes. Required.
	// Example: https://www.googleapis.com/auth/cloud-platform
	Scopes []string

	// Subject is the user email used for domain wide delegation (see
	// https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority).
	// Optional.
	Subject string

	// AuthHandler is the AuthorizationHandler used for 3-legged OAuth flow. Optional.
	AuthHandler authhandler.AuthorizationHandler

	// State is a unique string used with AuthHandler. Optional.
	State string
}

CredentialsParams を見てみると Subject があり、コメントには

Subject is the user email used for domain wide delegation

と書いてあります。これはまさに求めていたものです。
「なんだ、FindDefaultCredentials では Subject を渡せないけど 2021 年から FindDefaultCredentialsWithParams で Impersonate できたのか」と話は終わりま......せん!!

Subject を渡して Impersonate できるようになっているのにまだ気にするべきことがあるのでしょうか? 実装を見てみましょう。

oauth2/google/default.go#L111:

func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) {
	// Make defensive copy of the slices in params.
	params = params.deepCopy()

	// First, try the environment variable.
	const envVar = "GOOGLE_APPLICATION_CREDENTIALS"
	if filename := os.Getenv(envVar); filename != "" {
		creds, err := readCredentialsFile(ctx, filename, params)

oauth2/google/default.go#L170:

func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsParams) (oauth2.TokenSource, error) {
	switch f.Type {
	case serviceAccountKey:
		cfg := f.jwtConfig(params.Scopes, params.Subject)

oauth2/google/google.go#L134:

func (f *credentialsFile) jwtConfig(scopes []string, subject string) *jwt.Config {
	cfg := &jwt.Config{
		Email:        f.ClientEmail,
		PrivateKey:   []byte(f.PrivateKey),
		PrivateKeyID: f.PrivateKeyID,
		Scopes:       scopes,
		TokenURL:     f.TokenURL,
		Subject:      subject, // This is the user email to impersonate
	}

FindDefaultCredentialsWithParams では GOOGLE_APPLICATION_CREDENTIALS が設定されている場合はファイル読み込みのフローに入ります。 このコードを追っていくと見覚えのある jwt.Config が出てきて Subject には CredentialsParams に渡した Subject が使われています。このフローでは Impersonate は期待通りに動きそうです。

次に ADC のフローを見てみましょう。 以下のコードの通り ADC のフローは metadata.OnGCE() で確認して入ります(GCE と書いてありますが GAE・Run なども当てはまります)。

func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) {
...
	// Fourth, if we're on Google Compute Engine, an App Engine standard second generation runtime,
	// or App Engine flexible, use the metadata server.
	if metadata.OnGCE() {
		id, _ := metadata.ProjectID()
		return &DefaultCredentials{
			ProjectID:   id,
			TokenSource: ComputeTokenSource("", params.Scopes...),
		}, nil
	}

DefaultCredentials の呼び出し中の ComputeTokenSource を見ると params の Scopes のみが利用されています。 つまり、ADC のフローでは FindDefaultCredentialsWithParams 呼び出し時に CredentialsParams の Subject に値を与えたとしてもトークンの生成に利用されず無視されます。

FindDefaultCredentialsWithParams の調査結果分かったことは以下です。

  1. JSON Key を用いる場合は CredentialsParams の Subject を利用して Impersonate が可能
  2. ADC を用いる場合は Token 生成に CredentialsParams の Subject が利用できないため Impersonate ができない

ADC で Subject は差し込めないのか?

ここまで見てくると FindDefaultCredentialsWithParams 内の ADC の処理で Subject は差し込めないのかが気になります。

ComputeTokenSource を追っていくとわかりますが、 computeSource 内の処理で default アカウント用の Token を metadata API 経由で取得しています。

oauth2/google/google.go#L229:

func (cs computeSource) Token() (*oauth2.Token, error) {
	if !metadata.OnGCE() {
		return nil, errors.New("oauth2/google: can't get a token from the metadata service; not running on GCE")
	}
	acct := cs.account
	if acct == "" {
		acct = "default"
	}
	tokenURI := "instance/service-accounts/" + acct + "/token"
	if len(cs.scopes) > 0 {
		v := url.Values{}
		v.Set("scopes", strings.Join(cs.scopes, ","))
		tokenURI = tokenURI + "?" + v.Encode()
	}
	tokenJSON, err := metadata.Get(tokenURI)
	if err != nil {
		return nil, err
	}
	var res struct {
		AccessToken  string `json:"access_token"`
		ExpiresInSec int    `json:"expires_in"`
		TokenType    string `json:"token_type"`
	}
	err = json.NewDecoder(strings.NewReader(tokenJSON)).Decode(&res)

このコードからわかることとして、 metadata API の呼び出し後には Token 化されているので metadata API 呼び出し時点で Subject を渡し、metadata API 内で Subject をセットした Token を返せるようにしないと呼び出し側からは Impersonate が出来ません。
想像の範囲ですが、クライアントや API を作っているチームは別だと思われ、関わるチームが増えるので変更を加えるのは大変そうです。 改修するには時間がかかりそうですね。

Google API 初期化の実装例

ここまでの調査では JSON キーを利用する場合はより洗練された形で Subject を設定して Impersonate が可能ですが、ADC の場合は Subject が設定できないということがわかりました。

この情報を用いて Google API クライアントの初期化を行うと以下のような形が考えられます。エラー処理は省略していますが、本番環境などで利用する場合はしっかりエラーハンドリングしてください。
そもそも main などで JSON Key を取得して初期化を統一化しても良いですが、 FindDefaultCredentialsWithParams が今後改善される期待を込めて、敢えて DefaultCredentials を使える場合とそうでない場合を分けてみました。

func NewGoogleAdminClient(ctx context.Context, jsonKey, userEmail string, scopes ...string) (*admin.Service, error) {
    opts := make([]option.ClientOption, 0, 2)
    if os.Getenv("K_SERVICE") == "" { // Check if not running in Cloud Run.
        credentials, _ := google.FindDefaultCredentialsWithParams(ctx, google.CredentialsParams{
            Scopes:  scopes,
            Subject: userEmail,
        })
        opts = append(opts, option.WithCredentials(credentials))
    } else {
        // NOTE: Once FindDefaultCredentialsWithParams becomes being capable to handle Subject, consolidate the initialization.
        config, _ := google.JWTConfigFromJSON([]byte(jsonKey), scopes...)
        config.Subject = userEmail
        ts := config.TokenSource(ctx)
        opts = append(opts, option.WithTokenSource(ts))
    }

    return admin.NewService(ctx, opts...)
}

おわりに

本記事では Go における Google API の利用時に ADC を用いることで SA の JSON キーを利用せず Impersonate が可能か調査しました。 結論として、2022 年 6 月現在 Go の API ライブラリでは ADC を用いる事ができず、JSON キーを読み込む必要があります。

クライアント作成の立場に立って対応策を考えると ADC 用の metadata API インターフェイスの変更や metadata API の呼び出しをやめて必要な情報だけ取得し、自身で JWT を組み立てるなどが考えられますが、 何れにせよ変更は簡単ではなさそうですね。

IssueTrackerではそもそも API を呼ぶために Admin など権限の強いユーザーに Impersonate するのは良くないんじゃないの、という議論もありそうです。

Footnotes

  1. そのため Workload Identity Federation の様なサービスが話題になる