跳转至主要内容

在 Go 中实现 gRPC 授权

在 gRPC 服务中,每次调用都对应一个完整的方法名,例如 /library.v1.BookService/DeleteBook。这让方法级授权变得很简单:决定哪些角色可以调用哪些方法,在处理函数运行前用服务端拦截器检查即可。本教程在 Go 中用 Casbin 实现这一点,同时覆盖一元调用和流式调用。

1. 定义服务​

示例是一个小型图书馆服务。保存为 proto/library/v1/library.proto:

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

package library.v1;

option go_package = "example.com/library/gen/library/v1;libraryv1";

service BookService {
rpc GetBook(GetBookRequest) returns (Book);
rpc CreateBook(CreateBookRequest) returns (Book);
rpc DeleteBook(DeleteBookRequest) returns (DeleteBookResponse);
}

message Book {
string id = 1;
string title = 2;
}

message GetBookRequest {
string id = 1;
}

message CreateBookRequest {
string title = 1;
}

message DeleteBookRequest {
string id = 1;
}

message DeleteBookResponse {}

用 protoc(或 buf generate)生成 Go 代码:

go mod init example.com/library
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

protoc -Iproto --go_out=. --go_opt=module=example.com/library --go-grpc_out=. --go-grpc_opt=module=example.com/library proto/library/v1/library.proto

go get github.com/casbin/casbin/v3 google.golang.org/grpc

2. 编写模型​

保存为 model.conf:

[request_definition]
r = sub, method

[policy_definition]
p = sub, method

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch(r.method, p.method)
  • r = sub, method:每个请求由调用方和完整的 gRPC 方法名组成。
  • g = _, _:调用方可以被分配到角色,角色也可以被分配到其他角色。
  • keyMatch 允许规则以 * 结尾,这样一行就能覆盖一个服务的所有方法。

3. 编写策略​

保存为 policy.csv:

p, reader, /library.v1.BookService/GetBook
p, librarian, /library.v1.BookService/CreateBook
p, admin, /library.v1.BookService/*

g, librarian, reader
g, admin, librarian

g, alice, admin
g, bob, librarian
g, carol, reader

reader 可以调用 GetBook,librarian 还可以调用 CreateBook,admin 可以调用 BookService 的所有方法,包括以后新增的方法。角色之间的 g 行构成层级,所以每个角色只需列出本层新增的权限。

4. 编写拦截器​

server/authz.go
package main

import (
"context"

"github.com/casbin/casbin/v3"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

// authorize asks Casbin whether the caller may invoke fullMethod, such as /library.v1.BookService/DeleteBook.
func authorize(ctx context.Context, e *casbin.Enforcer, fullMethod string) error {
// Replace this with the identity from your authentication layer,
// such as a verified JWT in the "authorization" metadata or the client's mTLS certificate.
md, _ := metadata.FromIncomingContext(ctx)
users := md.Get("x-user")
if len(users) == 0 {
return status.Error(codes.Unauthenticated, "missing user")
}

allowed, err := e.Enforce(users[0], fullMethod)
if err != nil {
return status.Error(codes.Internal, err.Error())
}
if !allowed {
return status.Errorf(codes.PermissionDenied, "%s may not call %s", users[0], fullMethod)
}
return nil
}

func UnaryAuthz(e *casbin.Enforcer) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := authorize(ctx, e, info.FullMethod); err != nil {
return nil, err
}
return handler(ctx, req)
}
}

func StreamAuthz(e *casbin.Enforcer) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := authorize(ss.Context(), e, info.FullMethod); err != nil {
return err
}
return handler(srv, ss)
}
}

未认证的调用方得到 Unauthenticated,已认证但没有权限的调用方得到 PermissionDenied,这正是 gRPC 客户端和网关所期望的。

备注

示例用 x-user 元数据保持简短。 Casbin 只负责授权。实际服务中,应校验 authorization 元数据中的令牌;使用 mTLS 时,用 peer.FromContext 从客户端证书中获取身份。如果已经有认证拦截器做了这件事,把它串在授权拦截器之前。

5. 在服务器上注册​

server/main.go
package main

import (
"context"
"log"
"net"

"github.com/casbin/casbin/v3"
"google.golang.org/grpc"

libraryv1 "example.com/library/gen/library/v1"
)

type bookService struct {
libraryv1.UnimplementedBookServiceServer
}

func (bookService) GetBook(ctx context.Context, req *libraryv1.GetBookRequest) (*libraryv1.Book, error) {
return &libraryv1.Book{Id: req.GetId(), Title: "The Go Programming Language"}, nil
}

func (bookService) CreateBook(ctx context.Context, req *libraryv1.CreateBookRequest) (*libraryv1.Book, error) {
return &libraryv1.Book{Id: "2", Title: req.GetTitle()}, nil
}

func (bookService) DeleteBook(ctx context.Context, req *libraryv1.DeleteBookRequest) (*libraryv1.DeleteBookResponse, error) {
return &libraryv1.DeleteBookResponse{}, nil
}

func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
log.Fatal(err)
}

s := grpc.NewServer(
grpc.ChainUnaryInterceptor(UnaryAuthz(e)),
grpc.ChainStreamInterceptor(StreamAuthz(e)),
)
libraryv1.RegisterBookServiceServer(s, bookService{})

lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
log.Fatal(s.Serve(lis))
}

grpc.ChainUnaryInterceptor 和 grpc.ChainStreamInterceptor 可以接收多个拦截器,所以日志、指标和认证都可以和授权检查放在一起。

6. 试一试​

启动服务器:

go run ./server

这个客户端以四个不同的用户调用每个方法:

client/main.go
package main

import (
"context"
"fmt"
"log"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

libraryv1 "example.com/library/gen/library/v1"
)

func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := libraryv1.NewBookServiceClient(conn)

for _, user := range []string{"alice", "bob", "carol", "dave"} {
ctx := metadata.AppendToOutgoingContext(context.Background(), "x-user", user)

_, getErr := client.GetBook(ctx, &libraryv1.GetBookRequest{Id: "1"})
_, createErr := client.CreateBook(ctx, &libraryv1.CreateBookRequest{Title: "New book"})
_, deleteErr := client.DeleteBook(ctx, &libraryv1.DeleteBookRequest{Id: "1"})

fmt.Printf("%-6s GetBook=%-16v CreateBook=%-16v DeleteBook=%v\n", user,
status.Code(getErr), status.Code(createErr), status.Code(deleteErr))
}
}
go run ./client
alice  GetBook=OK               CreateBook=OK               DeleteBook=OK
bob GetBook=OK CreateBook=OK DeleteBook=PermissionDenied
carol GetBook=OK CreateBook=PermissionDenied DeleteBook=PermissionDenied
dave GetBook=PermissionDenied CreateBook=PermissionDenied DeleteBook=PermissionDenied

使用 grpcurl 时,把用户作为请求头传入:grpcurl -plaintext -H "x-user: bob" ...。

进一步​

  • 公开方法。 健康检查和服务反射通常不需要用户。对它们在 authorize 开头直接返回,例如 if strings.HasPrefix(fullMethod, "/grpc.health.v1.Health/") { return nil }。
  • 针对单条记录的规则。 拦截器在处理函数运行前只能看到方法名。依赖记录本身的规则,例如“图书管理员只能删除本馆的书”,应在处理函数中加载记录后再检查;见 ABAC 教程。
  • 多租户服务。 从元数据中取出租户作为第三个参数,并使用 带域的 RBAC。
  • 把策略存到数据库中。 用 适配器 代替 policy.csv,多个副本提供同一服务时再加上 watcher。
  • HTTP API。 Go 中的 REST 接口见 在 Go 中用 Gin 实现 RBAC。

Comments are powered by giscus and GitHub Discussions. They load from giscus.app and GitHub only after you click the button below.