🗂

node.jsのLambdaからパスワードレスなWorkload Identityを使ってGoogle Driveへアクセスをする

2022/03/01に公開約7,000字4件のコメント

はじめに

Gmailを使っていると、Google Driveは皆が利用可能で、チームで扱うにはわかりやすく便利です。
AWSを使っていると、基本的にはAWS S3をつかって色々をやりたいわけですが、可視化したデータだけ共有するためにアカウントを追加したり、都度preSigned Urlを発行したりするのは負荷が高いということもあるのではないでしょうか。
今回このような目的のために、LambdaからGoogle Driveへアクセスする方法を調べたので共有したいと思います。
サービスアカウントのアカウントキーを使う例はいくつか見かけたのですが、Workload Identityを使うことで、パスワードナシでGCPとの連携ができるとのことで、こちらを試してみました。
筆者はGCPにそこまで明るくないため、説明に不備があったら是非コメントにてご指摘いただけたらうれしいです。

2022.03.04 google driveのcreateのところ、callbackを使わずにそのままawaitできることに気づいたので修正いたしました。

参考

https://dev.classmethod.jp/articles/call-google-apis-from-aws-lambda-by-service-account/
https://cloud.google.com/iam/docs/access-resources-aws?hl=ja#settings

概要

参考文献を基本に、

  1. Classmethodさんの記事のフローのうち、サービスアカウントキーを作成している部分を、workload identityを利用するようにした
  2. 実行環境をNode.jsに

というのが大体の変更点です。
Node.jsでGoogleDriveにアクセスするためにworkload identityを使う部分がパッとわからなかったのが主なはまりポイントです。

大まかな作業の流れとしては
1.GCPのサービスアカウント(人に紐づかないAWSのIAMユーザ的なものと理解しています)を作成
 こちら作成するとメールアドレスができるのでこちらにGoogle driveのアクセス権を付与する
2. 上記サービスアカウントを接続したworkload identity プールを作成
3. プールの中にawsのサービスと紐づけたworkload identity プロバイダを作成
 ここでAWSアカウントIDやroleArnを制限することになる
4. アプリケーション構成が書かれたJSONファイルをダウンロード
5. LambdaからJSONファイルを読み込んでアクセスする

という流れになります。

やってみた

こちらではGCPの設定を行い、Lambdaからテストファイルをアップロードするということをできるようにする手順を紹介します。

サービスアカウントの作成

https://dev.classmethod.jp/articles/call-google-apis-from-aws-lambda-by-service-account/#toc-4
こちらの手順を「サービスアカウントキーの作成」手前まで実行します(サービスアカウントキーは作成しません)
作成ができると、下記のようにサービスアカウントのところにメールアドレスが出てくると思います

Google Drive APIの有効化

コンソールのAPIとサービス、ダッシュボードの右上にAPIとサービスの有効化、というのがあるので
こちらから「Google Drive API」を有効化しました

Google Driveのアクセス権設定

今回は特定のフォルダへのアクセス権を付けてそのフォルダにファイルを書きこみます
書き込みを行うため、編集者で設定をしました。
目的のGoogleDriveのフォルダを右クリックし共有をクリックして下記ダイアログを開いてサービスアカウントのアドレスを入力します。

また、以下のアドレス欄にある文字列は後で利用します。

Workload Identityプールの作成

https://cloud.google.com/iam/docs/access-resources-aws?hl=ja#create
こちらを実施。
7番のAWSアカウントIDはLambdaが実行される環境のアカウントID(12ケタ)です。

Workload Identity プロバイダの作成

https://cloud.google.com/iam/docs/access-resources-aws?hl=ja#impersonate
こちらを実施
AWSのLambda実行ロールで制限をかける場合は下記のように条件設定のところに
attribute.aws_role == "arn:aws:sts::{アカウントID}:assumed-role/{Lambdaの実行ロール名}
と書くことで制限が可能です。
複数のプロバイダを設定することでアカウントID・実行ロールの組み合わせを増やすことが可能ではないかと思います(複数設定はしたものの未検証)

権限借用のためのJSONファイルの作成

https://cloud.google.com/iam/docs/access-resources-aws?hl=ja#generate-automatic
こちらを実施。
コンソールではダウンロード元が意外とわかりづらいところにあるので、注意です。
ダウンロード時にプロバイダを選択することになるので、制限に合わせてダウンロード、利用します。

Lambdaの準備(ファイルや環境変数等)

以下を実施します
1.Lmabdaのアップロードパッケージに上記でダウンロードしたJSONファイルを含めるようにします。
2.環境変数に「GOOGLE_APPLICATION_CREDENTIALS」を作成し、"./xxxx.json"のように上記jsonファイルのパスを設定する
 こちらの環境変数は後述するauthの読み出しの際のkeyFilenameとしてデフォルトで扱う値のようです。
3.npm install googleapisでgoogle apiを触るためのライブラリをインストール(Lambdaのフォルダで)
 こちらはtypescriptの型も対応したライブラリのようで、そのまま利用できました

Lambdaのコーディング

以下のようなコードになるかと思います。
tsconfigやlogger等、Lambda開発自体に関わる設定や細かいコードは含めていないので、都度必要なものは皆さんの環境や開発スタイルに合わせて設定してください
@types/aws-lambdaは型定義が面倒で利用してしまいました。

import { Handler } from 'aws-lambda';
import { GaxiosResponse } from 'gaxios';
import {drive_v3, google} from 'googleapis'
//const keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS ?? ""

const auth = new google.auth.GoogleAuth({
  //keyFilename,
  projectId: "今回サービスアカウントやWorkload Itentityを設定したプロジェクトIDです",
  scopes: ['https://www.googleapis.com/auth/drive']
})
const drive = google.drive({version: 'v3', auth})

const fileMetadata:drive_v3.Schema$File = {
  name: 'test.txt',
  parents: ['Google Driveのアクセス権設定に出てきた文字列、file idです']
}
const media = {
  mimeType: 'text/plain',
  body: "test",
}

export const handler:Handler = async (event, context, callback) => {
  await drive.files.create({
    requestBody: fileMetadata,
    media: media,
  }).then((res) => {
    console.log('response: ', res);
    callback(null, res?.data.id ?? "")
  }).catch((err) => {
    console.error(err);
    callback("error", null)
  })
}

実行結果

制限した実行ARN通りのlambdaを実行すると、指定したフォルダの中に無事test.txtができていました。
こちら、実行した分だけ同名ファイルが生成されるようです。

また、別の実行ARNを持つLambdaを実行すると、こんな感じでエラーになってくれるようです。

Error: unauthorized_client
    at Gaxios._request (/var/task/node_modules/gaxios/build/src/gaxios.js:129:23)
    at processTicksAndRejections (internal/process/task_queues.js:95:5)
    at async StsCredentials.exchangeToken (/var/task/node_modules/google-auth-library/build/src/auth/stscredentials.js:89:30)
    at async AwsClient.refreshAccessTokenAsync (/var/task/node_modules/google-auth-library/build/src/auth/baseexternalclient.js:296:29)
    at async AwsClient.getAccessToken (/var/task/node_modules/google-auth-library/build/src/auth/baseexternalclient.js:149:13)
    at async AwsClient.getRequestHeaders (/var/task/node_modules/google-auth-library/build/src/auth/baseexternalclient.js:166:37)
    at async AwsClient.requestAsync (/var/task/node_modules/google-auth-library/build/src/auth/baseexternalclient.js:226:36) {
  response: {
    config: {
      url: 'xxx',
      method: 'POST',
      headers: [Object],
      data: 'xxx',
      responseType: 'json',
      paramsSerializer: [Function: paramsSerializer],
      body: 'xxx',
      validateStatus: [Function: validateStatus]
    },
    data: {
      error: 'unauthorized_client',
      error_description: 'The given credential is rejected by the attribute condition.'
    },
    headers: {
      //省略
    },
    status: 400,
    statusText: 'Bad Request',
    request: { responseURL: 'xxx' }
  },
  config: {
    url: 'xxx',
    method: 'POST',
    headers: {
      //省略
    },
    data: 'xxx',
    responseType: 'json',
    paramsSerializer: [Function: paramsSerializer],
    body: 'xxx',
    validateStatus: [Function: validateStatus]
  },
  code: '400'
}

ハマりポイント

一番はまったのは

const auth = new google.auth.GoogleAuth({
  //keyFilename,
  projectId: "サービスアカウントやWorkload Identityを設定したGCPのプロジェクトID",
  scopes: ['https://www.googleapis.com/auth/drive.file']
})
const drive = google.drive({version: 'v3', auth})

このあたりでした。
1.そもそもgoogle.driveに渡すべきauthの書き方がこれでよいかがわからなかった
2.scopesを適切に設定しないと動かなかった
3.projectIdを指定しておかないと最初の実行時にエラーになる
 (2回目以降動くのが若干謎ですが)自動でprojectIdを調べる機構があるようなのですが、その権限が足らなくて403エラーが返ってきているようにログからは読み取れました。

ちなみに、scopesは

https://developers.google.com/identity/protocols/oauth2/scopes#docs
どうやらこちらにまとまっているようです。

返り値について

コールバック関数の返り値のresの中身は

export interface GaxiosResponse<T = any> {
    config: GaxiosOptions;
    data: T;
    status: number;
    statusText: string;
    headers: Headers;
    request: GaxiosXMLHttpRequest;
}

こんな構造だそうで、今回の場合だとres.data.idにファイルのID、URLに含まれている文字列が入っているようです。

その他

  • 今回arm64(開発環境はx86)で動かしてみましたが、問題なく動くようです
  • メモリサイズを128MBで実行するといっぱいまでメモリを使っているようでした
     512MBで実行すると140MB程度最大メモリの使用量があるようです

Discussion

私も最近 Google API(Sheet Drive) 、サービスアカウント、Workload Identity 関連の記事を書きまして、同じトピックスのこちらの記事にたどり着きました。

本題とは関係ないところで恐縮ですが以下の変更点について。

2022.03.04 google driveのcreateのところ、callbackを使わずにそのままawaitできることに気づいたので修正いたしました。

私は AWS に疎いことと(&実際の環境で試していない)、変更前のコードがわからなかったので推測で書いてしまいますが、記事中のコードを見ると callback を利用された状態になっています。

export const handler:Handler = async (event, context, callback) => {
  await drive.files.create({
    requestBody: fileMetadata,
    media: media,
  }).then((res) => {
    console.log('response: ', res);
    callback(null, res?.data.id ?? "")
  }).catch((err) => {
    console.error(err);
    callback("error", null)
  })
}

Node.js の AWS Lambda 関数ハンドラーのドキュメントによると非同期ハンドラーの場合は Promise を返せるようなので、以下のように変更される予定だったのでは思いましてコメントしました。

export const handler: Handler = async (event) => {
  try {
    const res = await drive.files.create({
      requestBody: fileMetadata,
      media: media
    })
    console.log('response: ', res)
    return res?.data.id ?? ''
  } catch (err) {
    console.error(err)
    throw 'error'
  }
} 

私の見当違いでしたらすみません。

コメントどうもありがとうございます。
すみません、ややこしい文章を書いてしまいました。変更前のモノを残さなかったのはよくなかったですね。
こちら、drive.files.createメソッドがasync/awaitを使うpromise形式で書くことももcallbackを渡して呼ぶこともできる、というのを知らなかった、という話でして、元々の掲載コードではnew Promiseしてcallbackの中でresolve/rejectするコードにしてしまっていました。

Lambdaのほうの話で言うと、確かに本来は同期・非同期の関数ごとにcallbackするのか、promiseをreturnするのかそろえたほうがいいのかもしれないですね。
以前このあたり色々試したことがあったのですが、少なくともエラーをthrowするよりは、callbackでエラーを返したほうが処理が早い気がしていて、このように書くのが癖になっていました。

new Promise() を使わなくしたというお話でしたか。失礼いたしました。

ログインするとコメントできます