Protocol Buffers と connect-go による認可の自動化案

2024/12/30に公開

はじめに

こんにちは、koki-algebra と申します。

みなさん、スキーマ駆動開発していますでしょうか?

私は普段 gRPC サーバーを開発することが多いので, Protocol Buffers (Protobuf) をよく書いています。

https://protobuf.dev/

Protobuf はシンプルで直感的に読みやすいため、誰でも簡単に API が定義できます。

そして protoc というコンパイラによって、定義した Protobuf に対応する Class や構造体を生成することができます。protoc がネイティブで対応していない言語に関しては、protoc plugin によってコードを自動生成が可能です。

Go 言語の場合は protoc-gen-goprotoc-gen-go-grpc というプラグインがあります。
例えば以下のように User の message を定義するとします。

proto/directory/v1/user.proto
syntax = "proto3";

package directory.v1;

option go_package = "backend/pkg/connect/gen/directory/v1;directoryv1";

message User {
  string user_id = 1;
  string external_id = 2;
  string tenant_id = 3;
}

この protobuf から protoc-gen-go によって以下のような構造体が生成されます。

backend/pkg/connect/gen/directory/v1/user.pb.go
package directoryv1

type User struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	UserId     string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
	ExternalId string `protobuf:"bytes,2,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
	TenantId   string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"`
}

また、protoc plugin は自分でカスタムプラグインを作ることができるので、Protobuf から任意のコードを自動生成することができます。この拡張性の高さが Protobuf が好まれる理由の一つでしょう。

そこで本記事では、Protobuf で service の method に認可 (authorization) の情報を記述し、カスタムプラグインによって認可処理を行うコードを自動生成する例を紹介したいと思います。

また、今回は gRPC と互換性のある Connect で実装を行います。

protoc plugin

protoc plugin の仕組みを知っておくことは重要なので、ざっくりと解説します。

  1. protoc はまず入力された .proto ファイルを解析し、その内容を CodeGeneratorRequest という Protobuf message にまとめます。
  2. protoc--plugin オプションで指定されたプラグインプログラムを呼び出します。プラグインプログラムは protoc-gen- というプレフィックスがついた実行ファイルである必要があります。
  3. protoc がシリアライズされた CodeGeneratorRequest をプラグインの標準入力に書き込みます。
  4. プラグインは標準入力から CodeGeneratorRequest を読み込み、デシリアライズします。.proto ファイルの情報に基づいて、任意の処理を行います。例えば、独自のコードを生成したり、バリデーションチェックを行ったり、ドキュメントを生成したりできます。
  5. プラグインは処理結果を CodeGeneratorResponse という Protobuf message にまとめ、シリアライズした結果を標準出力に書き込みます。CodeGeneratorResponse には生成されたコードやエラーメッセージなどが含まれます。
  6. protoc はプラグインから受け取った CodeGeneratorResponse を元に、指定された出力ファイルにコードなどを書き込みます。


protoc plugin の概念図

このように、protoc plugin は protoc と標準入出力を通して通信することで、protoc の機能を拡張することができます。

より詳しい説明は以下の記事が参考になります。

https://qiita.com/yugui/items/87d00d77dee159e74886

Connect

https://connectrpc.com/docs/introduction

Connect は Buf Technologies が開発した gRPC 互換の新しいプロトコルです。gRPCの優れた機能を継承しつつ、よりシンプルで使いやすく、モダンな開発に適した設計になっています。

gRPC が HTTP/2 のみをサポートするのに対し、Connect は HTTP1.1, HTTP/2, HTTP3 をサポートするため、ブラウザなどの HTTP/2 をサポートしていない環境でも使用できます。そのため、gRPC-Web のようにプロキシサーバーを用意する必要はありません。

Connect のサーバー実装は現在 Node.js と Go のみをサポートしています。

Go の場合は protoc-gen-connect-go という protoc plugin を用いて Protobuf から Go の Interface や Client などを生成します。

Protobuf で認可を定義する

Protobuf には Option という機能があります。Option は .proto ファイルで定義された message、field、enum、service、method などにメタ情報を付加するためのメカニズムです。

extend キーワードを使って、標準で定義された Option を拡張することができます。

カスタムオプションについては以下の記事が詳しいです。

https://qiita.com/yugui/items/29adefab34f7f1a3c3c6

今回はこのカスタムオプションを使って認可を定義します。

proto/options/v1/options.proto
syntax = "proto3";

package options.v1;

import "google/protobuf/descriptor.proto";

option go_package = "backend/pkg/connect/gen/options/v1;optionsv1";

message AuthOptions {
  Resource resource = 1;
  Action action = 2;
}

enum Resource {
  RESOURCE_UNSPECIFIED = 0;
  RESOURCE_TENANT = 1;
  RESOURCE_USER = 2;
}

enum Action {
  ACTION_UNSPECIFIED = 0;
  ACTION_CREATE = 1;
  ACTION_READ = 2;
  ACTION_UPDATE = 3;
  ACTION_DELETE = 4;
}

extend google.protobuf.MethodOptions {
  AuthOptions auth_options = 50000;
}

新しく定義した auth_options を使って service の method に認可の情報を記述します。

proto/directory/v1/user_service.proto
syntax = "proto3";

package directory.v1;

import "directory/v1/user.proto";
import "options/v1/auth_options.proto";

option go_package = "backend/pkg/connect/gen/directory/v1;directoryv1";

service UserService {
  rpc GetMe(GetMeRequest) returns (GetMeResponse) {
    option (options.v1.auth_options) = {
      resource: RESOURCE_USER
      action: ACTION_READ
    };
  }
}

message GetMeRequest {}

message GetMeResponse {
  User user = 1;
}

これは、「directory.v1.UserService/GetMe を呼び出すには RESOURCE_USER に対して ACTION_READ を行う権限が必要である」という意味です。

自動生成したいコード

認可処理は Protobuf から service ごとに以下のような Interceptor を自動生成することによって行います。

backend/pkg/connect/gen/directory/v1/directoryv1connect/user_service.auth.go
// Code generated by protoc-gen-connect-auth. DO NOT EDIT.
//
// Source: directory/v1/user_service.proto

package directoryv1connect

import (
	authusecase "backend/internal/application/usecase/authusecase"
	actormodel "backend/internal/domain/model/actormodel"
	roleactionmodel "backend/internal/domain/model/roleactionmodel"
	_ "backend/pkg/connect/gen/directory/v1"
	connect "connectrpc.com/connect"
	context "context"
	errors "errors"
)

// UserServiceAuthInterceptor provides an authentication and authorization interceptor.
func UserServiceAuthInterceptor(authorizer authusecase.Authorizer) connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			actor, ok := actormodel.UserActorFromContext(ctx)
			if !ok {
				return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated user"))
			}

			switch req.Spec().Procedure {
			case UserServiceGetMeProcedure:
				authorized, err := authorizer.Authorize(
					ctx,
					actor.GetUserID(),
					roleactionmodel.ResourceUser.ResourceID,
					roleactionmodel.ActionRead.ActionID,
				)
				if err != nil {
					return nil, connect.NewError(connect.CodeInternal, errors.New("failed to authorize"))
				}
				if !authorized {
					return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
				}
			}

			return next(ctx, req)
		}
	}
}

UserActor は API を呼び出す User を指し、actor.GetUserID() によって、UserID を取得します。UserServiceAuthInterceptor が呼び出される前に、認証を行うことによって UserActor を特定し、context にセットしておきます。認証 (authentication) については本記事ではスコープ外とします。

req.Spec().Procedure によって呼び出された method のパスが取得できます。例えば GetMe では /directory.v1.UserService/GetMe というパスになります。

各 method のパスは protoc-gen-connect-go によって定数として自動生成されます。

backend/pkg/connect/gen/directory/v1/directoryv1connect/user_service.connect.go
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: directory/v1/user_service.proto

package directoryv1connect

const (
	// UserServiceGetMeProcedure is the fully-qualified name of the UserService's GetMe RPC.
	UserServiceGetMeProcedure = "/directory.v1.UserService/GetMe"
)

req.Spec().Procedure を switch 文によって条件分岐し、各 method ごとに必要な認可処理を実行します。

Authorizer は以下のような Interface です。

type Authorizer interface {
	// Authorize validate whether the user is authorized to perform the action on the resource.
	Authorize(
		ctx context.Context,
		userID uuid.UUID,
		resourceID roleactionmodel.ResourceID,
		actionID roleactionmodel.ActionID,
	) (bool, error)
}

Authorize メソッドによって、UserResource に対して Action を実行する権限があるかどうかをチェックします。Authorizer の実装についても本記事ではスコープ外とします。

protogen でプラグインを書く

protoc plugin は google.golang.org/protobuf/compiler/protogen というライブラリを利用すると楽に実装することができます。protogenprotoc-gen-goprotoc-gen-connect-go の実装にも利用されています。

今回は protoc-gen-connect-go を参考にして実装を行いました。

https://github.com/connectrpc/connect-go/blob/main/cmd/protoc-gen-connect-go/main.go

protoc-gen-connect-auth という名前でプラグインを実装することにします。では実装を見ていきます。

main.go についてはほとんど同じです。

backend/cmd/plugin/protoc-gen-connect-auth/main.go
package main

import (
	"bytes"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"strings"
	"unicode/utf8"

	optionsv1 "backend/pkg/connect/gen/options/v1"

	"golang.org/x/text/cases"
	"golang.org/x/text/language"
	"google.golang.org/protobuf/compiler/protogen"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/types/descriptorpb"
	"google.golang.org/protobuf/types/pluginpb"
)

const (
	contextPackage         = protogen.GoImportPath("context")
	errorsPackage          = protogen.GoImportPath("errors")
	connectPackage         = protogen.GoImportPath("connectrpc.com/connect")
	authUseCasePackage     = protogen.GoImportPath("backend/internal/application/usecase/authusecase")
	actorModelPackage      = protogen.GoImportPath("backend/internal/domain/model/actormodel")
	roleActionModelPackage = protogen.GoImportPath("backend/internal/domain/model/roleactionmodel")

	generatedFilenameExtension = ".auth.go"
	generatedPackageSuffix     = "connect"

	commentWidth = 97 // leave room for "// "

	protoPackageFieldNum = 2
)

func main() {
	protogen.Options{}.Run(func(p *protogen.Plugin) error {
		p.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
		p.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_2023
		p.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2024
		for _, file := range p.Files {
			if file.Generate {
				generate(p, file)
			}
		}
		return nil
	})
}

p.Files に入力された Protobuf の情報が入っているので、ループによって一つ一つ処理していきます。以下が各 .proto ファイルを処理する generate 関数です。

backend/cmd/plugin/protoc-gen-connect-auth/main.go
func generate(plugin *protogen.Plugin, file *protogen.File) {
	if len(file.Services) == 0 {
		return
	}
	file.GoPackageName += generatedPackageSuffix

	generatedFilenamePrefixToSlash := filepath.ToSlash(file.GeneratedFilenamePrefix)
	file.GeneratedFilenamePrefix = path.Join(
		path.Dir(generatedFilenamePrefixToSlash),
		string(file.GoPackageName),
		path.Base(generatedFilenamePrefixToSlash),
	)
	generatedFile := plugin.NewGeneratedFile(
		file.GeneratedFilenamePrefix+generatedFilenameExtension,
		protogen.GoImportPath(path.Join(
			string(file.GoImportPath),
			string(file.GoPackageName),
		)),
	)
	generatedFile.Import(file.GoImportPath)
	generatePreamble(generatedFile, file)
	generateFileContent(generatedFile, file)
}

.proto ファイルから生成ファイルのファイル名や import パスなどを設定しています。
generatePreamble()package までの序文を生成する関数ですが、protoc-gen-connect-auth のものとほとんど同じなので省略します。

generateFileContent() はファイルに含まれる service を一つ一つ処理していきます。

backend/cmd/plugin/protoc-gen-connect-auth/main.go
func generateFileContent(g *protogen.GeneratedFile, file *protogen.File) {
	if len(file.Services) == 0 {
		return
	}

	for _, service := range file.Services {
		generateInterceptor(g, service)
	}
}

実際に Interceptor を生成するのは以下の関数になります。

backend/cmd/plugin/protoc-gen-connect-auth/main.go
func generateInterceptor(g *protogen.GeneratedFile, service *protogen.Service) {
	serviceName := service.GoName
	g.P("// ", serviceName, "AuthInterceptor provides an authentication and authorization interceptor.")
	g.P("func ", serviceName, "AuthInterceptor(",
		"authorizer ", authUseCasePackage.Ident("Authorizer"),
		") ", connectPackage.Ident("UnaryInterceptorFunc"), " {")
	g.P("return func(next ", connectPackage.Ident("UnaryFunc"), ") ", connectPackage.Ident("UnaryFunc"), " {")
	g.P("return func(ctx ", contextPackage.Ident("Context"), ", req ", connectPackage.Ident("AnyRequest"),
		") (", connectPackage.Ident("AnyResponse"), ", error) {",
	)
	g.P("actor, ok := ", actorModelPackage.Ident("UserActorFromContext"), "(ctx)")
	g.P("if !ok {")
	g.P("return nil, ", connectPackage.Ident("NewError"), "(",
		connectPackage.Ident("CodeUnauthenticated"), ", ", errorsPackage.Ident("New"), "(\"unauthenticated user\"))",
	)
	g.P("}")
	g.P()
	g.P("switch req.Spec().Procedure {")

	for _, method := range service.Methods {
		if proto.HasExtension(method.Desc.Options(), optionsv1.E_AuthOptions) {
			ext := proto.GetExtension(method.Desc.Options(), optionsv1.E_AuthOptions)

			opts, ok := ext.(*optionsv1.AuthOptions)
			if ok {
				resource := toCamelCase(opts.GetResource().String())
				action := toCamelCase(opts.GetAction().String())

				g.P("case ", procedureConstName(method), ":")
				g.P("authorized, err := authorizer.Authorize(")
				g.P("ctx,")
				g.P("actor.GetUserID(),")
				g.P(roleActionModelPackage.Ident(resource), ".", "ResourceID,")
				g.P(roleActionModelPackage.Ident(action), ".", "ActionID,")
				g.P(")")
				g.P("if err != nil {")
				g.P("return nil, ", connectPackage.Ident("NewError"),
					"(", connectPackage.Ident("CodeInternal"), ", ", errorsPackage.Ident("New"), "(\"failed to authorize\"))")
				g.P("}")
				g.P("if !authorized {")
				g.P("return nil, ", connectPackage.Ident("NewError"), "(",
					connectPackage.Ident("CodePermissionDenied"), ", ", errorsPackage.Ident("New"), "(\"permission denied\"))")
				g.P("}")
			}
		}
	}
	g.P("}")
	g.P()
	g.P("return next(ctx, req)")
	g.P("}")
	g.P("}")
	g.P("}")
	g.P()
}

重要なのは以下の部分です。

for _, method := range service.Methods {
    if proto.HasExtension(method.Desc.Options(), optionsv1.E_AuthOptions) {
        ext := proto.GetExtension(method.Desc.Options(), optionsv1.E_AuthOptions)

        opts, ok := ext.(*optionsv1.AuthOptions)
        if ok {
            // 省略
        }
    }
}

method.Desc.Options() で method に設定されている Options を取得し、それが AuthOptions かどうかを判定しています。

opts.GetResource().String() で resource、opts.GetAction().String() で action の enum の文字列がそれぞれ取れるので、toCamelCase() によって Upper Snake Case から Camel Case に変換しています。

これによって、Protobuf の enum に対応する domain model の変数を指定することができます。

また、protoc plugin は実行可能ファイルである必要があるので、以下のような wrapper script を用意しておきます。

backend/script/protoc-gen-connect-auth
#!/bin/bash

cd $(dirname "${BASH_SOURCE:-$0}")

go run ../cmd/plugin/protoc-gen-connect-auth

次に実際にコードを生成する方法を見ていきます。

buf でコードを自動生成

https://buf.build/product/cli

最近は protoc ではなく Buf CLI で Protobuf をコンパイルすることが増えていると思います。

Buf CLI は、protobuf を使用する開発者にとって非常に便利なツールです。Linting、Formatting、Breaking Change Detection などの機能により、開発ワークフローを改善し、コードの品質と一貫性を向上させることができます。

また、protoc と異なり、buf では yaml ファイルに設定を書くことができます。

proto/buf.yaml
# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
version: v2
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
backend/buf.gen.yaml
version: v2

plugins:
  - local: protoc-gen-go
    out: ./pkg/connect/gen
    opt: paths=source_relative

  - local: protoc-gen-connect-go
    out: ./pkg/connect/gen
    opt: paths=source_relative

  # Custom Plugin
  - local: ./script/protoc-gen-connect-auth
    out: ./pkg/connect/gen
    opt: paths=source_relative

この設定ファイルを元にコード生成を行います。
ディレクトリ構成は以下のようになっています。(一部省略しています)

├── backend
│   ├── buf.gen.yaml
│   ├── cmd
│   │   └── plugin
│   │       └── protoc-gen-connect-auth
│   │           └── main.go
│   ├── go.mod
│   ├── go.sum
│   ├── pkg
│   │   └── connect
│   │       └── gen
│   │           ├── directory
│   │           │   └── v1
│   │           │       ├── directoryv1connect
│   │           │       │   ├── user_service.auth.go
│   │           │       │   └── user_service.connect.go
│   │           │       ├── user.pb.go
│   │           │       └── user_service.pb.go
│   │           └── options
│   │               └── v1
│   │                   └── auth_options.pb.go
│   ├── script
│   │   └── protoc-gen-connect-auth
└── proto
    ├── buf.yaml
    ├── directory
    │   └── v1
    │       ├── user.proto
    │       └── user_service.proto
    └── options
        └── v1
            └── auth_options.proto

backend/ において、以下を実行することによってコード生成を行います。

buf generate --config ../proto/buf.yaml ../proto

おわりに

今回は Protobuf に記述した認可の情報から、protogen を用いてユーザーの実行権限を検証する Connect の Interceptor を自動生成する実装例を紹介しました。

スキーマを見るだけで実行するのに必要な権限が一目でわかるだけでなく、コードを自動生成することで、スキーマと実装が乖離することを防ぐことができます。

スキーマが読みやすさと拡張性が高さは Protobuf の強みの一つです。

今回は認可に焦点を当てましたが、Protobuf のスキーマ定義を拡張することで、バリデーションやロギング、メトリクス収集など、様々な処理を自動化できる可能性を秘めていると感じています。

私としてはまだまだ Protobuf や gRPC (Connect) に対する理解が足りていないので、今後もキャッチアップを続けて行こうと思います。

最後まで読んでいただいてありがとうございました!

Discussion