GAE/Pythonでサービスアカウントキーファイルを使わないようにした

GCPの各種サーバーレスサービスにアクセスするには、認証情報が必要となります。App EngineやCloud Functions上で動作している場合は、GCPの各種ライブラリを使っていれば特に何もしなくても認証が通った状態となり、FirestoreやCloud Storageなどを扱うことができるようになっています。

しかし、ローカルでの動作確認を行う場合など、GCP外で動く場合には認証情報を渡す必要があります。
これまで、ローカルでの認証のためには「サービスアカウントキー」というJSONファイルを取得することが多かったのですが、サービスアカウントキーファイルが漏洩した場合に検知が難しいなどの問題があり、最近は非推奨となっています。

今回は、GAE/Pythonをメインに運用しているピリカでとったサービスアカウントキー排除の手法をご紹介します。

## Application Default Credentials

GCPのクライアントライブラリはプログラム上で何も指定せずとも、App EngineにデプロイするとGCPへのアクセスが可能となっています。また、ローカルでも環境変数にサービスアカウントキーが置いてある場所を`GOOGLE_APPLICATION_CREDENTIALS`環境変数で指定することで同様の動作をさせることができます。

この認証はApplication Default Credentialsというもので行われており、以下のようなものが自動検出されて使われるようになっています。

- `GOOGLE_APPLICATION_CREDENTIALS` 環境変数に指定されている、サービスアカウントキー
- `gcloud auth application-default login` で認証された、ユーザーの認証情報
- App Engine や Cloud Functions で動作している時は、サービスが動作しているサービスアカウント

ですので、推奨されていない方法である、サービスアカウントキーファイルを発行せずとも`gcloud auth application-default login`でログインしておけば、GCP上と同じように動作することが可能になっています。

しかし、これはあくまでローカルで認証したユーザーの認証情報を使っているので、実際に動作するサービスアカウントと権限が異なる場合、実際にGAEに上げたときに権限が不足するといった問題の原因になってしまいます。

## サービスアカウントの権限借用

そこで使うのが、サービスアカウントの権限借用です。これは、ユーザーやサービスアカウントから、別のサービスアカウントの認証情報を得る方法です。この方法を使うことで、ローカルではユーザーの認証情報からGAE上で動作するときのサービスアカウントの認証情報を得て動作することができるようになります。

各ユーザーから、App Engineデフォルトサービスアカウントを借用することを目標とすると、これをするためには、事前に以下のような準備が必要です。

- IAM Service Account Credentials APIを有効にしておく
- IAMコンソールで、借用先のサービスアカウント(App Engineサービスアカウント)の「権限」に、借用するアカウント(ユーザーのアカウント)を「サービス アカウント トークン作成者」権限を追加する
    - そのユーザーが、当該プロジェクトの「オーナー」権限を持っていたとしても、権限の付与が必要です。

こうすると、各ユーザーの認証情報で以下のようなコードでCredentialsオブジェクトの取得が可能です。(google-auth ライブラリが必要です)

```python
import os

from google.auth import default as app_default_credentials
from google.auth import impersonated_credentials
from google.auth.credentials import Credentials

from google.cloud import ndb

project = '__YOUR_PROJECT_NAME__'
service_account = '__YOUR_PROJECT_NAME__@appspot.gserviceaccount.com'
scopes = [
    'https://www.googleapis.com/auth/cloud-platform',
    'https://www.googleapis.com/auth/datastore',
    'https://www.googleapis.com/auth/devstorage.read_write',
    'https://www.googleapis.com/auth/logging.read',
    'https://www.googleapis.com/auth/logging.write',
    'https://www.googleapis.com/auth/pubsub'
]

if 'GOOGLE_CLOUD_PROJECT' in os.environ or 'GCP_PROJECT' in os.environ:
    # GCP上で動いている(環境変数で判断) -> Application Default Credentialをそのまま使用
    credentials = None
else:
    # ローカルで動いている -> Application Default Credentialからサービスアカウントを借用
    source_credentials, default_project_id = app_default_credentials()
    credentials = impersonated_credentials.Credentials(
        source_credentials=source_credentials,
        target_principal=service_account,
        target_scopes=scopes,
        lifetime=lifetime)

# GCPのクライアントライブラリはコンストラクタにcredentials引数があるので、そこに渡す。
ndb_client = ndb.Client(project=project, credentials=credentials)
```

scopesに指定するスコープは、アプリケーションで使用するサービスのスコープを[GoogleAPIのOAuth2.0スコープ](https://developers.google.com/identity/protocols/oauth2/scopes)で確認して指定します。

この動作はよく使うので、ピリカではライブラリ化して使うことにしました。

https://github.com/pirika-inc/gae-impersonate-credentials

## GitHub Actionsからアクセスできるようにする

ローカルからはサービスアカウントで認証できるようになりましたが、GitHub Actionsでテストを動かすにあたってGCPへのアクセスをしていた場合はあわせてケアする必要があります。

こちらは、Workload Identityの機能を使うことで実現可能です。こちらの記事の内容がとてもわかりやすかったので、ご参考ください。


https://zenn.dev/vvakame/articles/gha-and-gcp-workload-identity


## Cloud StorageのSigned URL

Application Default Credentialsで取得できる認証情報がサービスアカウントキー以外の場合、Cloud StorageのSigned URL発行を、Cloud Storageライブラリのメソッドでできなくなってしまいます。

```python
# 元々はこういうことができた
bucket_name = '...'
file_path = '...'
content_type = '...'

blob = gcs_client.bucket(bucket_name).blob(file_path)
url = blob.generate_signed_url(version='v4',
                               virtual_hosted_style=True,
                               method='PUT',
                               expiration=datetime.timedelta(minutes=15),
                               content_type=content_type)
```

これを解決するには、Cloud Storageライブラリを使わずに、IAM Credentials APIのsignBlobを使って独自にSigned URLを発行する実装をすることになります。

signBlobをするには、その操作をするサービスアカウントが、署名するサービスアカウントの「サービスアカウントトークン作成者」の権限を持っている必要があります。App EngineのデフォルトサービスアカウントだけでsignBlobするには、サービスアカウントの権限に、同一のサービスアカウントのサービスアカウントトークン作成者権限を入れておくことになります。

さて、これを具体的にPythonでやるには、google-authだけではなくgoogle-cloud-iamライブラリが必要です。
実際にCloud StorageのSigned URLを発行するためのクラスを書いてみました。


```python
mport base64
import datetime
import time
from typing import Optional
from urllib import parse

from google.auth.credentials import Credentials
from google.cloud.iam_credentials_v1 import IAMCredentialsClient


class UrlSigner:
    def __init__(self, credentials: Credentials,
                 project: str,
                 service_account: str) -> None:
        super().__init__()
        self.credentials = credentials
        self.project = project
        self.service_account = service_account
        self.iam_client = None

    def sign(self,
             bucket_name: str, object_path: str,
             method='GET', expires=20,
             content_type: Optional[str] = None) -> str:
        gcs_filename = f'/{bucket_name}/{object_path}'
        content_md5 = None

        now = datetime.datetime.now()
        expiration = now + datetime.timedelta(seconds=expires)
        expiration = int(time.mktime(expiration.timetuple()))

        signature_string = '\n'.join([
            method,
            content_md5 or '',
            content_type or '',
            str(expiration),
            gcs_filename])

        iam_client = self._get_iam_client()
        result = iam_client.sign_blob(
            name=f'projects/-/serviceAccounts/{self.service_account}',
            payload=signature_string.encode('utf-8')
        )
        signature = base64.b64encode(result.signed_blob)

        query_params = {
            'GoogleAccessId': self.service_account,
            'Expires': str(expiration),
            'Signature': signature
        }

        endpoint = 'https://storage.googleapis.com'
        resource = gcs_filename
        querystring = parse.urlencode(query_params)

        return f'{endpoint}{resource}?{querystring}'

    def _get_iam_client(self) -> IAMCredentialsClient:
        credentials = self.credentials
        if self.iam_client is None:
            self.iam_client = IAMCredentialsClient(credentials=credentials)
        return self.iam_client
```

このクラスを使うと、先ほどのコードはこのようになります。

```python
credentials = None
project = '__YOUR_PROJECT_NAME__'
service_account = '__YOUR_PROJECT_NAME__@appspot.gserviceaccount.com'

bucket_name = '...'
file_path = '...'
content_type = '...'

signer = UrlSigner(credentials, project, service_account)
url = signer.sign(bucket_name, file_path,
                  method='PUT',
                  expiration=15 * 60,
                  content_type=content_type)
```

## 参考リンク

- https://cloud.google.com/iam/docs/impersonating-service-account
- https://zenn.dev/apstndb/articles/10349708f25985
- https://blog.pokutuna.com/entry/application-default-credentials