こんにちは。今月SREチームにてインターン中の cookie-s です。
今回のインターンでは、とある小さなサービスをGoで実装し、Google Cloud Functionsの関数としてデプロイをしました。その際に得た、Cloud IAMを利用した、Cloud Functions関数コールの保護についての知見をお届けしたいと思います。
背景
メルカリでは、商品画像など大量の画像をCDNから配信しており、そのほとんどはキャッシュされます。キャッシュは当分の間保持され、その間リソースオリジンへのアクセスは行われなくなるわけですが、オリジンでのリソースの更新が起こった際などCDN上のキャッシュを強制的にパージすることが必要になることがあります。
今回のインターンでは、今まで手作業が残っていた、このパージ作業の自動化のためのURLを受け取って各CDNに対してそのキャッシュのパージAPIをコールするようなサービスの実装を行いました。実行環境については、このサービスが提供する機能は十分小さく、管理のコスト等を考えた結果Google Cloud Functionsを採用することにしました。
ここで、CDNのキャッシュパージAPIのコールには各CDNから発行された認証トークンを利用するので、このCloud Functions関数のデプロイにあたって、関数コール元が信頼できるかが判定できるような認証機能はぜひ入れたいものです。
認証機能の実現方針
まず、Google Cloud PlatformにはIAM: Identity & Access Managementという認証・認可の仕組みがあり、普段のGoogleアカウントとは別に、サービスアカウントという種類のアカウントを自由に発行できるほか、それらのアカウントそれぞれに権限の集合であるところの役割を割り当てることができます。
メルカリではすでにIAMが利用されている箇所もあり、今回のサービスにもこの仕組みが簡単に適用できれば理想です。
そのために一番単純には、Cloud Functionsの権限として"関数のコール可能性"に関するものがあることを期待するのですが、現在、残念ながらドキュメントにそのような記述は見当たりません。
他の手立てとして、Cloud Functions関数をコール可能な経路を一本に制限し、認証用のプロキシをその経路前段に立てることで保護する方法が考えられます。
これに似たものを実現するものとして、Google Cloud PlatformではIAP: Identity-Aware Proxyという仕組みがあります。このリソースにはiap.webServiceVersions.accessViaIAP
というプロキシ先へのアクセス可能性に関する権限がサポートされているため、これをIAMでアカウントに紐付けることによってアクセス制御ができるというわけです。
しかしIAPが現在バックエンドとして直接対応しているのはApp EngineとHTTPS Load Balancer下にいるGCEインスタンスグループやGKEクラスタに限られるため、今回のケースではうまくいきません。
しかし実は、このような状況での1つのソリューションがGoogleによって公開されています。
これは認可のために使う専用のCloud Storageバケットを作成し、そのstorage.buckets.get
権限をもとのCloud Functions関数コール可能性を表現する権限として代わりに利用するというものです。
今回はこの方針を採用することにしました。
※追記(2019/07/30): 現在、上記ソリューション記事にはアクセスできなくなっているほか、まだbeta段階ですが"関数のコール可能性"に関するIAM role cloudfunctions.functions.invoke
の追加されているようです。
実践
流れはGoogleによるソリューション記事のとおりです。
- まず、専用のCloud Storageバケットを作成する必要があります。今回の使い方ではバケットにオブジェクトは保存せず、クラスBオペレーションの
storage.*.testIamPermissions
APIコールが主であるため、Multi-RegionalかRegionalクラスがよさそうです。 -
次に適当なIAM Roleを作成し、
storage.buckets.get
権限を加えてください。
そして専用バケットに対するそのRoleを、Cloud Functions関数コールを許可したいユーザアカウントやサービスアカウントに与えます。 -
関数のハンドラに、認証をしてアクセスを制御するコードを書き足します。コードはソリューション記事にあるとおりですが、Goで書き直せば以下のようになると思います(一部略)。
Access Tokenがまともなものでない場合、testPermissionsコール段階で失敗するので、対処してやる必要があります。
const TestPermissionName = "storage.buckets.get" func IsAuthorized(ctx context.Context, r *http.Request) (bool, error) { accessToken := getAccessToken(r) if accessToken == "" { return false, nil } toksrc := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}) storage, err := storage.NewClient(ctx, option.WithTokenSource(toksrc)) if err != nil { return false, err } bucket := storage.Bucket(BucketName) perms, err := bucket.IAM().TestPermissions(ctx, []string{TestPermissionName}) if googleErr, ok := err.(*googleapi.Error); ok && googleErr.Code == http.StatusUnauthorized { // maybe invalid credential return false, nil } if err != nil { return false, err } for _, v := range perms { if v == TestPermissionName { return true, nil } } return false, nil }
以上によって、役割を与えたアカウントのアクセストークンによってのみ*1、このCloud Functions関数をコール可能にすることができます!
まとめ
この記事では、Cloud Functionsの保護方法についてお届けしました。
結論としては、IAPは使えないので、少し奇妙な気もしますが専用のCloud Storageを作成し、関数のハンドラに少しロジックを書き足すことで、IAMを利用したアクセス権の管理ができるようになるということです。
補足
- もちろん、「Cloud Storageの
storage.buckets.get
権限」というのは本質ではなくて、ここは任意の権限を利用することができると思います。しかしそのときは、その権限本来の操作である、たとえば今回の場合ならバケットのメタデータ読み出しも同時に許していることになるので、そのような副作用による影響の範囲が小さい権限を選択する必要があります。 - 今回の方針とIAPでは、認証に使うトークンの種類が異なります。今回の方針では、APIコールのためのaccess tokenを利用していますが、IAPの場合、Cloud IAPによって発行/署名されたJWT表現のOIDCトークンを利用することになるはずです[参考]。もし将来、このCloud Functions関数を拡張したサービスをGKE上等で開発した場合、サービスとしては同じインタフェースを保ったとしても、IAPを有効にするにはクライアント側の認証部分の変更は必要そうです。
*1:もちろん、別な役割で同じ権限を与えている人も許可されます。