GCP 基盤ではない Node.js アプリからセキュアに Cloud Storage にアクセスする
概要
GCP 基盤ではない Node.js アプリ(実際はAWS Fargate)からセキュアに顧客の Google Cloud Storage にアクセスする方法を紹介する。
本記事で紹介するセキュアにアクセスする方法だと(これしか方法はないが)、2021/06/06現在 Node.js だとクライアントライブラリである@google-cloud/storageやgoogle-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プロジェクト上で好きに変更が可能。
また、なりすます有効期間も短いため、最小権限の原則を実装しやすい。つまりセキュアということだ。
全容を把握しやすいように概念レベルの手順を下記に示しておく。具体的な手順は後述する。
- 顧客 Service Account に Cloud Storage へのアクセス権限を付与してもらう
- 自社 Service Account に、顧客 Service Account の認証情報を発行する権限を付与してもらう
- アプリ上で、自社 Service Account を用いて 顧客 Service Account の認証情報(アクセストークン)を発行し、それを使って Cloud Storage API を呼び出す。
具体的な手順
以下より、原則 Service Account を SA と省略する。
- (自社)自社のGCPプロジェクトの IAM Service Account Credentials API を有効化する
- (自社)自社のGCPプロジェクトで、権限を委任されるための SA を作成(特に権限はいらない)し、Key を発行してダウンロードする
- (顧客)顧客のGCPプロジェクトで、権限を委任するための SA を作成し、2.に「Service Account Token Creator」権限を付与する
- 自社SA は 顧客SA の認証情報を発行する権限が与えられる
- (顧客)顧客のGCPプロジェクトで、IAM Service Account Credentials API と Cloud Storage API を有効化する
- (顧客)顧客のGCPプロジェクトで、Cloud Storage でバケットを作成し、顧客SA に「Storage Object Viewer」権限を付与する
- 自社SA は発行する認証情報(アクセストークン)を使ってこのバケットにアクセスできるようになる
- (顧客)アプリに 顧客SA のメールアドレス を登録する
- (自社)アプリで、2.と3.で自社 SA に付与された権限を利用し、6.を使って顧客SA の認証情報(アクセストークン)を発行する
- (自社)アクセストークンを用いて、バケットにアクセスする
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_CREDENTIALS
にservice-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