🗝️

GCP 基盤ではない Node.js アプリからセキュアに Cloud Storage にアクセスする

2021/06/07に公開

概要

GCP 基盤ではない Node.js アプリ(実際はAWS Fargate)からセキュアに顧客の Google Cloud Storage にアクセスする方法を紹介する。

本記事で紹介するセキュアにアクセスする方法だと(これしか方法はないが)、2021/06/06現在 Node.js だとクライアントライブラリである@google-cloud/storagegoogle-auth-libraryをいい感じに使えない(※)ため、API呼び出しを基本とする。

なおJava、Python、Goのクライアントライブラリではサポートされている模様。私は Node.js でできないはずないと考えここでかなり時間を浪費してしまった。。

※Impersonated Credentialsを発行できないため、Cloud Storage Client を初期化する方法がない。
参考 Using ImpersonatedCredentials for Google Cloud APIs

主な登場人物

  • 自社のAWS基盤の Node.js アプリケーション(以下、アプリ)
  • 自社のGCPプロジェクト(Service Account)
  • 顧客のGCPプロジェクト(Service Account、Cloud Storage)

結論

簡単にいうと、自社 Service Account に 顧客 Service Account の権限を委任してもらい、顧客 Service Account になりすますという方法をとる。

顧客は Service Account のメールアドレスのみをアプリに登録すればよく(シークレット情報をアプリに登録する必要がない)、権限の管理も顧客のGCPプロジェクト上で好きに変更が可能。

また、なりすます有効期間も短いため、最小権限の原則を実装しやすい。つまりセキュアということだ。

全容を把握しやすいように概念レベルの手順を下記に示しておく。具体的な手順は後述する。

  1. 顧客 Service Account に Cloud Storage へのアクセス権限を付与してもらう
  2. 自社 Service Account に、顧客 Service Account の認証情報を発行する権限を付与してもらう
  3. アプリ上で、自社 Service Account を用いて 顧客 Service Account の認証情報(アクセストークン)を発行し、それを使って Cloud Storage API を呼び出す。

参考 有効期間が短いサービス アカウント認証情報の作成

具体的な手順

以下より、原則 Service Account を SA と省略する。

  1. (自社)自社のGCPプロジェクトの IAM Service Account Credentials API を有効化する
  2. (自社)自社のGCPプロジェクトで、権限を委任されるための SA を作成(特に権限はいらない)し、Key を発行してダウンロードする
  3. (顧客)顧客のGCPプロジェクトで、権限を委任するための SA を作成し、2.に「Service Account Token Creator」権限を付与する
    • 自社SA は 顧客SA の認証情報を発行する権限が与えられる
  4. (顧客)顧客のGCPプロジェクトで、IAM Service Account Credentials API と Cloud Storage API を有効化する
  5. (顧客)顧客のGCPプロジェクトで、Cloud Storage でバケットを作成し、顧客SA に「Storage Object Viewer」権限を付与する
    • 自社SA は発行する認証情報(アクセストークン)を使ってこのバケットにアクセスできるようになる
  6. (顧客)アプリに 顧客SA のメールアドレス を登録する
  7. (自社)アプリで、2.と3.で自社 SA に付与された権限を利用し、6.を使って顧客SA の認証情報(アクセストークン)を発行する
  8. (自社)アクセストークンを用いて、バケットにアクセスする

7.と8.については実際のソースコードを紹介する。

混乱した代理問題を防ぐ

混乱した代理問題とは

顧客Aが具体的な手順6.で登録するSAメールアドレスが悪意ある別の顧客Bに漏洩したとする。この場合、悪意ある顧客BがそのSAメールアドレスをアプリに登録すると、顧客Aのデータにアクセスできてしまうことになる。

このように、悪意あるユーザーが、アプリ(代理サービス)を通して別のユーザーのデータなどに不正にアクセスすることを「混乱した代理問題」と呼ぶ。

参考 https://ja.wikipedia.org/wiki/Confused_deputy_problem

対策

ユーザーごとにID(AWSではこれを外部IDと呼んでいる)を払い出し、具体的な手順3.で作成するSAメールアドレスにそのIDを含めてもらうことでこの問題を解決する。

アプリ側でこの制御を設けることで、SAメールアドレスが流出しても、別のユーザーがアプリを通して不正なアクセスを行うことができなくなる。

TypeScriptによる実装

最後にTypeScriptによる実装を紹介する。APIリクエストにはaxiosを使う。

顧客SAのアクセストークン発行

具体的な手順7.の実装。呼び出すAPIはgenerateAccessToken

validateExternalIdでSAのメールにアプリが顧客ごとに発行したIDが含まれるかを判定することで混乱した代理問題に対処している。

自社 SA アクセストークンの発行には環境変数GOOGLE_APPLICATION_CREDENTIALSservice-account.jsonを指定し、具体的な手順2.で発行したKeyを用いる。

export class GCPCloudStorageService {

  async getAccessToken(serviceAccountEmail: string, externalId: string): Promise<string | undefined> {
    // 混乱した代理問題に対処
    if (!this.validateExternalId(serviceAccountEmail, externalId)) {
      return undefined;
    }
    const axios = await this.createGCPIAMCredentialsBase();
    // 顧客SA アクセストークンを取得
    const response = await axios
      .post<{ accessToken: string; expireTime: string }>(`${serviceAccountEmail}:generateAccessToken`, {
        scope: ["https://www.googleapis.com/auth/cloud-platform"],
        lifetime: "300s", // アクセストークンの有効期限
      })
      .catch((e) => {
        return undefined;
      });
    return response?.data?.accessToken;
  }

  private validateExternalId(serviceAccountEmail: string, externalId: string): boolean {
    return serviceAccountEmail.includes(externalId);
  }

  private async createGCPIAMCredentialsBase(): Promise<AxiosInstance> {
    // 顧客SA アクセストークンを取得するAPI呼び出しのための自社SAアクセストークンを取得
    const auth = new GoogleAuth({
      scopes: ["https://www.googleapis.com/auth/cloud-platform"],
    });
    const accessToken = await auth.getAccessToken();

    // 顧客SA アクセストークンを取得するAPI呼び出しのベースを作成
    return axios.create({
      baseURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
        Authorization: `Bearer ${accessToken}`,
      },
      responseType: "json",
    });
  }
}

アクセストークンを用いたバケットへのアクセス

具体的な手順8.の実装。呼び出すAPIはobjects/list

顧客SAのアクセストークンを用いて指定されたバケットのObject一覧を取得する。MAXで1,000件の取得になるため、nextPageTokenを引数や返り値に含めておく。

export class GCPCloudStorageService {
  async listObjects(
    accessToken: string,
    bucketName: string,
    params: { prefix?: string; maxResults?: number; pageToken?: string }
  ): Promise<{ items: any[]; nextPageToken: string | undefined }> {
    const axios = this.createGCPCloudStorageBase(accessToken);
    const response = await axios.get(`b/${bucketName}/o`, { params }).catch((e) => {
      return undefined;
    });
    if (response && response.data.items) {
      return { items: response.data.items, nextPageToken: response.data.nextPageToken };
    }
    return { items: [], nextPageToken: undefined };
  }

  private createGCPCloudStorageBase(accessToken: string): AxiosInstance {
    return axios.create({
      baseURL: "https://storage.googleapis.com/storage/v1/",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
  }
}

Discussion