自作protocプラグインで実現するスキーマベースの認可処理

ogp

はじめに

こんにちは、enechainのApplication Platform Deskでエンジニアをしているendoです。

Application Platform Deskは、全プロダクトが横断で抱える課題を解決するチームです。
※「開発者体験の向上を目指す」という意味ではPlatform Engineeringチームと近い位置づけですが、もう少しアプリケーション開発寄りの領域を担当しています。

私達のチームでは、protobufで自動生成したGolangコードを使ってAPI Endpoint毎にロール単位の認可処理ができる仕組みを構築しました。
今回はこの中で、Golangで開発したProtocol Buffers(以下protobuf)のプラグインについてご紹介します。

なぜprotobufに認可設定を組み込もうと思ったのか?

enechainではAPI間の通信プロトコルとしてprotobufを採用しています。

API毎にコード上に認可処理を書いても良いのですが、その方法だと人によって書き方がバラバラになるリスクが有り、レビュー側の認知負荷が高いという問題があります。
そこで、protobufの定義を拡張して認可設定を組み込むことを考えました。

protobufに認可設定を組み込むことで、スキーマのレビュータイミングで認可処理もレビュー出来て、コード上のどこかに書いてある認可処理を毎回探す必要はなくなり、運用上大きなメリットがあります

前提知識

実装の詳細についてお話しする前に、ptorocプラグインに関して簡単に紹介します。

protobufのプラグインの仕組み

protobufではprotocコマンドを使ってスキーマファイルをコンパイルします。

コンパイルの流れは下図のようになっています。多言語に対応させる拡張性を持たせるために、プラグインが言語ごとのコードを生成する役割を持っています。

protoc-plugin

protocコマンドは、標準入力からプラグインに解析データを渡し、プラグインはその入力データを元に処理を行います。
プラグインが出力データを返すとprotocはそれを元にしてファイルにデータを書き出します。

このようなprotocの作法に則って入出力を処理できる実行ファイルやスクリプトを作れば、protobufに独自処理をビルトインすることが出来ます。

protocコマンドのインターフェース

protocへの入力として、os.StdinにはCodeGeneratorRequestで定義されている構造のバイナリデータが渡ってきます。

CodeGeneratorRequestは以下のような構造になっていますが、この構造体の中のFileDescriptorProto.protoファイルで定義しているスキーマ定義が入っています。

type CodeGeneratorRequest struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields
    FileToGenerate []string
    Parameter *string
    ProtoFile []*descriptorpb.FileDescriptorProto
    SourceFileDescriptors []*descriptorpb.FileDescriptorProto
    CompilerVersion *Version
}

プラグインを開発する時にはこの FileDescriptorProto を使っていくことになります。

実装

続いて、今回行った実装について紹介します。

今回のお題

今回作成するプラグインでは、ユーザのロールに基づいたエンドポイントレベルの認可(ACL)を取り扱います。

以下が入力となるprotoファイルのイメージです。
option (acls) = {}のようにprotoファイルに認可情報を定義すると、endpoint毎にパーミッションのmapが生成される状態を目指します。

// Define your custom option
extend google.protobuf.MethodOptions {
  AclOptions acls = 55555;
}

message AclOptions {
  repeated string role = 1;
}

service SampleService {
    rpc GetSampleMessage (SampleMessage) returns (SampleMessage) {
        option (acls) = {
            role: "admin"
            role: "user"
        };
    }
}

実際にprotocをコンパイルすると、以下のように出力されるイメージです。

// [acls]:{role:"admin"  role:"user"}
var SampleServiceMap = map[string][]string{
    "/sample.v1.SampleService/GetSampleMessage": {
        "admin",
        "user",
    },
}

このACL Mapを利用することで、各エンドポイントの認可処理が簡単に実現出来ます。

プラグインの開発

1.インプットデータの読み込み
os.Stdinからインプットデータを読み込んでも良いのですが、今回は処理を簡素化するためにprotogenを利用します。
使い方としては以下のようになります。

func main() {
    protogen.Options{}.Run(func(gen *protogen.Plugin) error {
        for _, protoFile := range gen.Files {
            if !protoFile.Generate {
                continue
            }
            generate(gen, protoFile)
        }
        return nil
    })
}

protogenの内部でos.Stdinから読み込みんだデータをオブジェクトに変換してくれるので、簡単に扱えるようになります。

2.出力ファイルの生成
protoFileの中にはprotoで定義したserviceが入っています。serviceの中にあるrpcを取得していくためにこの配列をループして処理します。

func generate(gen *protogen.Plugin, protoFile *protogen.File) {
    filename := protoFile.GeneratedFilenamePrefix + "_acl.pb.go"
    g := gen.NewGeneratedFile(filename, protoFile.GoImportPath)

    g.P("package ", protoFile.GoPackageName)
    g.P("")

    for _, service := range protoFile.Services {
        generateAclMap(g, protoFile, service)
    }
}

gen.NewGeneratedFileで新規ファイルを作成します。
第一引数に作成するファイル名を、第二引数には作成先のファイルパスを指定します。

3.出力ファイルの中身の書き出し
作成した時点ではファイルは空ですが、以下のようにg.P()関数を使用して生成したファイルに愚直に一行ずつコードを書き出していきます。

func getPathName(protoFile *protogen.File, method *protogen.Method) string {
    return fmt.Sprintf("/%s.%s/%s", protoFile.Desc.FullName(), method.Parent.Desc.Name(), method.Desc.Name())
}

func generateAclMap(g *protogen.GeneratedFile, protoFile *protogen.File, service *protogen.Service) bool {
  // 定義したservice名を変数名にする、`,`で区切った文字列は連結されて出力される
    g.P("var ", lowerFirstChar(string(service.Desc.Name())), "Map", " = ", "map[string][]string {")

    for _, method := range service.Methods {
        path := getPathName(protoFile, method)
        opts := method.Desc.Options().(*descriptorpb.MethodOptions)
        ext := proto.GetExtension(opts, sample.E_Acls)

        optRoles := ext.(*sample.AclOptions).GetAclName()
        if len(optRoles) == 0 {
            continue
        }

        // "path" : {"role1", "role2"} の形式で出力
        g.P(`"`+path+`"`, ": {")

        for _, r := range optRoles {
            g.P(`"` + r + `",`)
        }

        g.P("},")
    }

    g.P("}")
    g.P("")

    return true
}

service定義の中のrpc定義を取得するためにはservice.Methodsを使います。
methodの中からrpcで定義したOptionを取得するためにはmethod.Desc.Options().(*descriptorpb.MethodOptions)のようにCastする必要があります。

さらにproto.GetExtension(opts, sample.E_Acls)のようにGetExtensionを使うことでprotoにて独自定義したオプションを取得できます。
※ ここで使っているsample.E_Aclsは「今回のお題」に記載してあるsample.protoをコンパイルしたsample.pb.goの中に定義されています

4.ビルド
コードを書き終わったらいつものGolangのビルドコマンドを実行して実行バイナリファイルを作成します。

go build -o protoc-acl

拡張プラグインの実行

今回はbuf cliを使ってコンパイルします。

version: v1
managed:
  enabled: true
plugins:
  - name: acl
    out: gen/proto
    opt: paths=source_relative
    path: ./protoc-acl

buf.gen.yamlの中で、go buildで生成した実行バイナリファイル(protoc-acl)をプラグインとして設定します。

buf.gen.yamlがあるフォルダ以下で下記コマンドを実行すると、gen/protoフォルダ配下にファイルが自動生成されます。

buf generate

生成されたmapはgolangのコードになっているため、importしてユーザーのロール情報などと比較することで認可処理を行うことが出来ます。

生成したmapを使った認可処理

今回はInterceptorを使って認可処理を実装します。

APIサーバはconnect-goを使って立ち上げている前提とします。
connect-goのinterceptorについてはここでは詳しく紹介しませんので、公式のドキュメントを参照してください。

func NewAuthenticateInterceptor() connect.UnaryInterceptorFunc {
  return connect.UnaryFunc(func(
            ctx context.Context,
            req connect.AnyRequest,
        ) (connect.AnyResponse, error) {
      procedure := req.Spec().Procedure
      if roles, ok := sample.SampleServiceMap[procedure]; ok {
        if ok := slices.Contains(roles, "guest"); !ok {
          return connect.NewError(connect.CodePermissionDenied, errors.New("このエンドポイントはゲストは利用出来ません")) 
        }
      }

      return next(ctx, req)
    }
}

req.Spec().ProcedureにはAPIリクエストのエンドポイント名が入っています。
protobufから生成したACL Mapはエンドポイント名をキーとしているため、Procedureを利用して要素にアクセスすることで、該当エンドポイントでアクセスを許可したいロールの一覧を取得できます。

NewAuthenticateInterceptor内ではアクセスしているユーザがguestのロールを割り当てられているという前提でベタ書きしていますが、実際にはJWTやDBなどからユーザが持っているパーミッションを取得することになります。

こうして、protoファイルに定義した認可情報から、APIベースのロールごとの認可処理を実現することが出来ます。

最後に

今回はprotobufのプラグイン開発を紹介させていただきました。

現在、enechainのいくつかのプロダクトにて試験的に上記のような仕組みを取り入れています。
今後はこの仕組みを発展させ、BFFや認証認可のコードを完全に自動生成して、開発者が今回のようにInterceptorを実装しなくても良い状態にすることを目指しています。
完成した際にはGitHub上での公開も考えて取り組んでいますので、ご期待ください。

enechainではサービス開発のみではなく、100兆円規模のエネルギー市場を支えるサービスとなるため、未来の運用を見据えた基盤開発への投資も行っております。
Application Platform Deskでは、今回のような取り組みを始めとして、将来に向けて様々な仕組みやサービス基盤を作成しています。
興味がありましたら話を聞いてみるだけでも良いので、是非下記リンクからご応募ください。

tech.enechain.com
herp.careers
herp.careers