見出し画像

Go Protocol Buffer Message API V2 のReflectionとgRPC Server-side Interceptorを使ってAPIの呼び出し権限チェックを実現する

電通デジタルでバックエンド開発をしている齋藤です。弊社では社内/グループ会社向けのデジタル広告運用実績の管理システムのバックエンドAPIにgRPCを利用しています。

そこに、今年(2020年)の3月2日にGo公式のブログでGo Protocol Buffer Message API V2 の発表があり、4月13日にgolang/protobuf に Message API V2 対応版の v1.4.0 が正式リリースされました。これにより Go でも Reflectionが使えるようになりました。ブログ中で紹介されている、FieldOptionsを読み取ってフィールドをクリアする条件に使うなどより広いユースケースに利用できるようになりました。

本記事では Reflection と gRPC Interceptor を使って API の呼び出し権限をチェックする機能を実現する方法を紹介します。

ユースケース

広告運用実績の管理ツールを対象とするので様々な権限管理が必要となります。今回はそのうち API の呼び出し権限のチェックを対象にします。

ユーザとメソッドに対して以下を設定します。

・リソース:Response となる gRPC のメッセージのパッケージを含んだ名称
・権限:NONE(権限なし) / VIEW(閲覧) / EDIT(編集)

このうち、メソッドに対する権限設定を以下で設定します。

・Protocol Buffer ファイルの MethodOptions で権限を定義
・Protocol Buffer ファイルから gRPC の stub を生成
・gRPC Interceptor で Protocol Buffer Message から Reflection を使って MethodOptions を取得してチェック

をしていきます。

今回は Advertiser サービスに Client を取得する GetClient メソッドに権限を設定する例を挙げます。

なお、ユーザの権限は今回は取り扱いませんが、別途ユーザ管理システムが存在して、そこから権限が取得可能な前提とします。


Protocol Bufferファイルの MethodOptions で権限を設定

まずは Protocol Buffer のファイルに

・MethodOptions の定義
・メソッドの option に具体値の設定

を実施します。

MethodOptionsの定義

今回 Protocol Buffer の Custom Options を利用するのですが、Google 公式のドキュメントには

This is an advanced feature which most people don't need.

https://developers.google.com/protocol-buffers/docs/proto3#custom_options

と書かれていて詳しい説明は proto2 の側にしかありませんが、proto3 で仕様が変わっているわけではなさそうです。

MethodOptions の定義は以下のように行います。

import = "google/protobuf/descriptor.proto";

extend google.protobuf.MethodOptions {
   FIELD_TYPE FIELD_NAME = FIELD_NUM;
}

このさい気を付けることとしては FIELD_NUM を利用している Protocol Buffer 内で一意にすることです。

自システム内で FIELD_NUM はどの範囲を使えばよいかは公式の Custom Options に書かれており

In the examples above, we have used field numbers in the range 50000-99999. This range is reserved for internal use within individual organizations, so you can use numbers in this range freely for in-house applications.

と、50000-99999 の範囲を使えばよいようです。

なお、

Protocol Buffer 自体が999まで
公式に予約されている番号が概ね1100まで
・GoogleのAPI Improvement ProposalsAIP-127 HTTP and gRPC Transcodingでも推奨されているオプションの google.api.http は 72295728

となっていました。外部 Protocol Buffer ファイルで定義されていないか確認するのがよいでしょう。

今回は以下のような acl.proto に Emun と Message を定義してリソースの権限を扱うことにします。

syntax = "proto3";
package adsys;
import "google/protobuf/descriptor.proto";

option go_package="github.com/<ACCOUNT>/go-genproto/adsys/acl;acl";

enum Grant {
   NONE = 0;
   VIEW = 1;
   EDIT = 2;
}

message Acl {
   string resource = 1;
   adsys.Grant grant = 2;
}

extend google.protobuf.MethodOptions {
   adsys.Acl acl = 50000;
}

option go_package の設定にもルールがあるのですが、それは gRPC の stub を生成する段で説明します。


メソッドの option に具体値の定義

次に GetClient メソッドの option に具体的な値を設定します。GetClient メソッドは AIP-131 Standard methods: Get にしたがってリクエストに GetClientRequest メッセージを取り、Client メッセージを返すものとします。

advertiser.proto に GetClient メソッドの定義内に前項で作成したadsys.acl を option として以下のように定義します。

syntax = "proto3";
package adsys;

option go_package="github.com/<ACCOUNT>/go-genproto/adsys/advertiser;advertiser";

import "adsys/acl.proto";

service Advertiser {
   rpc GetClient(GetClientRequest) returns (Client) {
       option (adsys.acl) = {
           resource: "adsys.advertiser/Client"
           grant: VIEW
       }
   }
}

message Client {
   string client_id = 1;
   string name = 2;
}

message GetClientRequest {
   string client_id = 1;
}

Protocol BufferファイルからgRPCのstubを生成

次に Protocol Buffer ファイルから gRPC の stub を生成します。protoc のプラグインを使って生成するのですが、以下弊社の取り組みと、本記事執筆時点(4月末)での注意事項についてご紹介します。

・Protocol Buffer ファイルと gRPC の stub ファイルの管理方法
・Go Protocol Buffer Message API V2 対応バージョンのファイル生成時の注意

Protocol BufferファイルとgPRCのstubファイルの管理方法

弊社では以下の理由から Protocol Buffer のファイルと gRPC の stub を別のレポジトリに分けています。

・定義となる Protocol Buffer のファイルは一箇所にまとめ、管理やドキュメント生成を集中的に行う
・生成ファイルは各プログラミング言語のパッケージエコシステムに載せてバージョン管理・配布を行いたいため

この構成は GitHub にある Google APIs のレポジトリを参考にしました。

googleapis/googleapis:定義ファイルと生成用のCI/Docker/Makefileなどを配置
googleapis/go-genproto:生成ファイル(.pb.go)とパッケージ管理のための go.mod / go.sum を配置

更新フローとしては

1. Protocol Buffer ファイルのレポジトリで .proto ファイルを更新
2. 更新を受けて protoc-gen-go で gRPC の stub を生成し、対応バージョン/ハッシュ情報と併せて生成ファイルのレポジトリにコミットを作成して Pull Request を出す
3. 生成ファイルのレポジトリで Pull Request をマージしてバージョンを上げる
4. 利用者が生成ファイルのレポジトリのモジュールのバージョンを変更して、反映を取り込む

となります。

ここで、前述の option go_package の話に戻ります。上記の都合生成後の Go のパッケージ名に使われる option go_package に指定するのは生成ファイルをコミットする go-genproto の方のレポジトリになります。

Go Protocol Buffer Message API V2対応バージョンのファイル生成時の注意

これは本記事執筆時(4月3週目)にはまだ未解決な点で、そのうち解決されると思われますが、本記事執筆段階では gRPC の stub ファイル生成に注意が必要です。

冒頭に紹介した Go 公式ブログでも言及があるのですが、Message API V2に対応したバージョンからGo modulesが変わりました

Go Protocol Buffer Message API V2に対応したバージョンは

・GitHubレポジトリ: https://github.com/protocolbuffers/protobuf-go
・go get のパス: google.golang.org/protobuf

なのですが、v1.20のリリースに記載があるのですが、protoc-gen-go がgRPCのコードの生成プラグインを提供しなくなります。当面の間はGo Protocol Buffer Message API V2をサポートする golang/protobufv1.4.0 以降を使えるそうですが、gPRC 側の実装が今後は正式版になります。

gPRC 側の対応は本記事執筆時点では cmd/protoc-gen-go-grpc: add code generator #3453  でPull Request が出ていますが、まだマージされていません。

そのため、本記事執筆時点ではgPRC stubの生成に golang/protobuf の v1.4.0 を利用しています。v1.4.0 以前のバージョンでは今回の機能は利用できませんのでご注意ください。

gRPC Interceptor で Protocol Buffer MessageからReflectionを使って  MethodOptions を取得してチェック

前項までで gRPC の Go の stub の生成まで完了したので、最後に実装に入ります。まず簡単に gPRC Interceptor の説明と実装イメージを紹介し、その後Reflectionを使った MethodOptions の取得方法について紹介します。

gRPC Interceptorとは

Interceptor の名前の通り、処理の前後に処理を挟み込む機能です。Client-side / Server-side それぞれで Unary / Stream に対応しています。

今回は Server-side の Unary Interceptorを使います。Server-side の Unary  Interceptor の Go でのコードは以下のようになります。

func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
   // メソッドより前に呼ばれる処理

   // メソッドの処理
   m, err := handler(ctx, req)

   // メソッドの処理後に呼ばれる処理

   // レスポンスを返す
   return m, err
}

この関数を gRPC サーバの生成時に渡してやります。

server := grpc.NewServer(
   grpc.UnaryInterceptor(unaryInterceptor),
)

これで gRPC のメソッド呼び出し前後に処理を挟み込むことができます。より詳しくはgrpc/grpc-goのInterseptorの機能紹介フォルダを参照してください。

gPRC Interceptor の実装

今回の場合

1. ユーザの権限を取得する(今回は getUserGrants という関数で取得できるものとします)
2. 先ほどまでで紹介した MethodOptions に定義したメソッド側のオプションを取得する(今回 getMethodAcl として実装イメージを提示)
3. 権限をチェックしてメソッドを呼び出すかチェックする(今回は canAccessResource という関数で実施できるものとします)

をメソッド処理の前に実施して、権限がない場合はメソッドを呼び出す前にreturn するようにします。また、コードの見通しをよくするため、エラーハンドリングのコードを省略しています。

import (
   "context"
   "fmt"
   "strings"
   "google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/types/descriptorpb"
   aclpb "github.com/<ACCOUNT>/go-genproto/adsys/acl"
)

func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
   // メソッドより前に呼ばれる処理
   // ユーザの権限を取得 (実装略)
   userGrants, _ := getUserGrants(...)
   // メソッドオプションを呼び出して取得
   methodAcl, _ := getMethodAcl(req.(proto.Message), info.fullMethod)
   
   // リソースにアクセス可能かチェック(実装略)
   if ok, reason := canAccessResource(userGrants, methodAcl); !ok {
       return nil, fmt.Errorf("Access denied because: %s", reason)
   } 

   // メソッドの処理
   m, err := handler(ctx, req)

   // メソッドの処理後に呼ばれる処理

   // レスポンスを返す
   return m, err
}

// MessgeからReflectionを使って.pb.goのFileDescriptorからMethodOptionsを取得
func getMethodAcl(pb proto.Message, fm string) (*aclpb.Acl) {
   serviceName, methodName := parseFullMethod(fm)
   // ByNameで呼び出す対象が存在しなかった場合panicになるので、チェックと対応が必要
   opts := pb.ProtoReflect().Descriptor().ParentFile().Services().ByName(protoreflect.Name(serviceName)).Methods().ByName(protoreflect.Name(methodName)).Options().(*descriptorpb.MethodOptions)
   methodAcl := proto.GetExtension(opts, aclpb.E_Acl).(*aclpb.Acl)
   return methodAcl
}

// リクエストパスからサービス名とメソッド名を抽出する
func parseFullMethod(fm string) (string, string) {
   // 実装詳細略
   return serviceName, methodName
}

ポイントは getMethodAcl の以下の2行です。

opts := pb.ProtoReflect().Descriptor().ParentFile().Services().ByName(protoreflect.Name(serviceName)).Methods().ByName(protoreflect.Name(methodName)).Options().(*descriptorpb.MethodOptions)
methodAcl := proto.GetExtension(opts, aclpb.E_Acl).(*aclpb.Acl)

まず1行目からです。最初にリフレクションのための protoreflect の型に変換して protoreflect.Descriptor の階層をたどっていきます。この階層は Protocol Buffer のファイルに記載される階層を再現したものです。gPRC のInterceptor で保持しているのはリクエストの Message になるので、 Message から最上位の File までさかのぼり、そこから Service を経由して Method に降っていきます。より詳しくは protoreflectのドキュメントを参照してください。

メソッド1つ1つに何をしているかのコメントをつけると以下になります。

proto.ProtoReflect()                        // protoreflect.Message を取得
    .Descriptor()                           // protoreflect.MessageDescriotpr を取得
    .ParentFile()                           // protoreflect.FileDescriptor を取得
    .Services()                             // protoreflect.ServiceDescriptors を取得
    .ByName(protoreflect.Name(serviceName)) // serviceName の protoreflect.ServiceDescriptor を取得
    .Methods()                              // serviceName の Service の protoreflect.MethodDescriptorsを取得
    .ByName(protoreflect.Name(methodName))  // serviceName.methodName の protoreflect.MethodDescriptor を取得
    .Options()                              // serviceName.methodName の protoreflect.methodOptions を取得
    .(*descriptor.MethodOptions)            // descriptor.MethodOptions にキャスト (次の処理のため)

そして2行目ですが、 proto.GetExtention で第1引数で指定した MethodOptions の中から protoreflect.ExtensionType を持つものを返します。この場合 aclpb.E_Acl ですが、これは Protocol Buffer ファイルの

extend google.protobuf.MethodOptions {
   adsys.Acl acl = 50000;
}

の部分から protoc-gen-go の gRPC プラグインによって生成される値になります。そして、最後に実際の型である *aclpb.Acl にキャストします。

上記で Protocol Buffer ファイルで定義した MethodOptions を取り出せたので、あとはユーザの権限と比較して問題なければ gPRC メソッドの呼び出しに進み、権限がなければ、gRPC のメソッドを呼び出す前にクライアントにレスポンスを返します。


まとめ

本記事では Reflection と gRPC Interceptor を使って API の呼び出し権限をチェックする機能を実現する方法を紹介しました。Protocol Buffer にデータ定義だけでなく、設定などの処理の補助となる情報が書けるのはとても便利だと感じています。

調査・開発時には公式以外にあまり情報がなかったため(本記事公開時には情報が増えているといいのですが)、本記事がどなたかのお役に立てばと思います。