Protocol Buffers と connect-go による認可の自動化案
はじめに
こんにちは、koki-algebra と申します。
みなさん、スキーマ駆動開発していますでしょうか?
私は普段 gRPC サーバーを開発することが多いので, Protocol Buffers (Protobuf) をよく書いています。
Protobuf はシンプルで直感的に読みやすいため、誰でも簡単に API が定義できます。
そして protoc
というコンパイラによって、定義した Protobuf に対応する Class や構造体を生成することができます。protoc
がネイティブで対応していない言語に関しては、protoc plugin によってコードを自動生成が可能です。
Go 言語の場合は protoc-gen-go や protoc-gen-go-grpc というプラグインがあります。
例えば以下のように User の message を定義するとします。
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
によって以下のような構造体が生成されます。
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 の仕組みを知っておくことは重要なので、ざっくりと解説します。
-
protoc
はまず入力された.proto
ファイルを解析し、その内容をCodeGeneratorRequest
という Protobuf message にまとめます。 -
protoc
は--plugin
オプションで指定されたプラグインプログラムを呼び出します。プラグインプログラムはprotoc-gen-
というプレフィックスがついた実行ファイルである必要があります。 -
protoc
がシリアライズされたCodeGeneratorRequest
をプラグインの標準入力に書き込みます。 - プラグインは標準入力から
CodeGeneratorRequest
を読み込み、デシリアライズします。.proto
ファイルの情報に基づいて、任意の処理を行います。例えば、独自のコードを生成したり、バリデーションチェックを行ったり、ドキュメントを生成したりできます。 - プラグインは処理結果を
CodeGeneratorResponse
という Protobuf message にまとめ、シリアライズした結果を標準出力に書き込みます。CodeGeneratorResponse
には生成されたコードやエラーメッセージなどが含まれます。 -
protoc
はプラグインから受け取ったCodeGeneratorResponse
を元に、指定された出力ファイルにコードなどを書き込みます。
protoc plugin の概念図
このように、protoc plugin は protoc
と標準入出力を通して通信することで、protoc
の機能を拡張することができます。
より詳しい説明は以下の記事が参考になります。
Connect
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 を拡張することができます。
カスタムオプションについては以下の記事が詳しいです。
今回はこのカスタムオプションを使って認可を定義します。
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 に認可の情報を記述します。
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 を自動生成することによって行います。
// 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
によって定数として自動生成されます。
// 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
メソッドによって、User
が Resource
に対して Action
を実行する権限があるかどうかをチェックします。Authorizer
の実装についても本記事ではスコープ外とします。
protogen
でプラグインを書く
protoc plugin は google.golang.org/protobuf/compiler/protogen というライブラリを利用すると楽に実装することができます。protogen
は protoc-gen-go
や protoc-gen-connect-go
の実装にも利用されています。
今回は protoc-gen-connect-go
を参考にして実装を行いました。
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
関数です。
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 を一つ一つ処理していきます。
func generateFileContent(g *protogen.GeneratedFile, file *protogen.File) {
if len(file.Services) == 0 {
return
}
for _, service := range file.Services {
generateInterceptor(g, service)
}
}
実際に Interceptor を生成するのは以下の関数になります。
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 を用意しておきます。
#!/bin/bash
cd $(dirname "${BASH_SOURCE:-$0}")
go run ../cmd/plugin/protoc-gen-connect-auth
次に実際にコードを生成する方法を見ていきます。
buf
でコードを自動生成
最近は protoc
ではなく Buf CLI で Protobuf をコンパイルすることが増えていると思います。
Buf CLI は、protobuf を使用する開発者にとって非常に便利なツールです。Linting、Formatting、Breaking Change Detection などの機能により、開発ワークフローを改善し、コードの品質と一貫性を向上させることができます。
また、protoc
と異なり、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
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