gRPC Interceptors and in-memory gRPC connections
- 5 minutes read - 938 wordsFor… reasons, I wanted to pre-filter gRPC requests to check for authorization. Authorization is implemented as a ‘micro-service’ and I wanted the authorization server to run in the same process as the gRPC client.
TL;DR:
- Shiju’s “Writing gRPC Interceptors in Go” is great
- This Stack overflow answer ostensibly for writing unit tests for gRPC got me an in-process server
What follows stands on these folks’ shoulders…
A key motivator for me to write blog posts is that helps me ensure that I understand things. Writing this post, I realized I’d not researched gRPC Interceptors and, as luck would have it, I found some interesting content, not on grpc.io
but on the grpc-ecosystem
repo, specifically Go gRPC middleware. But, I refer again to Shiju’s clear and helpful “Writing gRPC Interceptors in Go”
Interceptors, intercept gRPC requests and, in my case, I’m intercepting every request to ensure that the request is authorized. The go-grpc-middleware
page says “[Interceptors are] a perfect way to implement common patterns: auth, logging, message, validation, retries or monitoring”. In this case, I assume “auth” refers to authentication but it is often used to refer both to authentication and authorization.
In my case, authentication is performed by Firebase Auth as discussed here and here. In addition to Firebase Auth, I’m using Cloud Endpoints, in part, to authenticate users to the gRPC service (see Cloud Endpoints: Auth). This adds a metadata key to gRPC requests representing a JWT-like JSON structure that can be used to extract user details.
So I have 2 challenges:
- Extract user “claims” (properties) from incoming requests
- Authorize the user with some subset of the claims
func SomeInterceptor(
ctx context.Context,
rqst interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {}
claims, _ := extractor(md)
authorized, _ := authorizer(claims)
if authorized {
h, err := handler(ctx, rqst)
return h, err
}
return nil, status.Errorf(
codes.PermissionDenied,
"permission denied",
)
}
The function signature for gRPC (UnaryServer)Interceptors is complex but straightforward. We receive some context (ctx
), the incoming request (rqst
), accompanying info (info
), and the intended gRPC method (handler
).
In my case, I’m extracting claims
from the metadata
pulled from the incoming context (ctx
) and I use these claims to authorize the user.
In order to test my Interceptor, I’m applying extractor
and authorizer
methods through a closure that returns a suitably-configured Interceptor:
type Extractor func(md metadata.MD) (Claims, error)
type Authorizer func(email string) (bool, error)
func AuthorizationInterceptorGenerator(
extractor Extractor,
authorizer Authorizer.
) func(
ctx context.Context,
rqst interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (
interface{},
error
) {
return func(
ctx context.Context,
rqst interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {}
NOTE I hope that layout makes sense, the
Generator
takes the 2 functions (Extractor
andAuthorizer
) and returns an Interceptor function.
I can then create an Interceptor:
interceptor := AuthorizationInterceptorGenerator(
extractor,
authorizer,
)
The gRPC server configuration is straightforward:
opts := []grpc.ServerOption{
gprc.WithUnaryInterceptor(interceptor),
}
grcpServer := grpc.NewServer(opts)
First problem solved.
The second problem was more challenging for me. I want the aforementioned authorizer
to itself be a gRPC client and its server must be running in the same process.
This requirement is partly because I don’t want to incur network (and deployment) overhead of going over a network to a remote service, and partly because, using Cloud Run, I’m unable to run this service as a sidecar. So, in-memory it is.
This Stack overflow answer is great and should be part of the gRPC documentation. The repo doesn’t include a specific example of how to do this either.
Honestly, I find the Golang client-server gRPC configuration difficult to remember because it’s asymmetric:
server:
lis, _ := net.Listen("tcp", port)
server := grpc.NewServer()
pb.RegisterSomeServer(s, &server{})
if err := server.Serve(lis); err != nil {}
client:
ctx := context.Background()
dialOpts := []grpc.DialOption{
grpc.WithInsecure(),
}
conn, _ := grpc.Dial(address, dialOpts...)
defer conn.Close()
client := pb.NewSomeClient(conn)
rqst := &pb.SomeMethodRequest{}
resp, _ := client.SomeMethod(ctx, rqst)
The connection between the two is some underlying network. The client uses grpc.Dial
to dial the server’s address (host:port
) and the server on host
uses net.Listen
to listen on the port
.
The Stack overflow answers shows how to use bufconn.Listener
with a shared buffer to connect the two:
shared:
const bufSize = 1024 * 1024
var lis = bufconn.Listen(bufSize)
server:
server := grpc.NewServer()
pb.RegisterServer(s, &server{})
if err := server.Serve(lis); err != nil {}
client:
ctx := context.Background()
d := func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}
dialOpts := []grpc.DialOption{
grpc.WithContextDialer(d),
grpc.WithInsecure(),
}
conn, _ := grpc.DialContext(ctx, "in-memory", dialOpts...)
defer conn.Close()
client := pb.NewSomeClient(conn)
rqst := &pb.SomeMethodRequest{}
resp, _ := client.SomeMethod(ctx, rqst)
NOTE I’ve laid out the code to try to make it as easy as possible to compare the 2 sets of code. In practice, of course, you can’t share a listener across two distinct processes (two
main
functions) and so I created a struct to share the listener with 2 methods:Serve()
(that runs the server in a Go routine); andGetClient()
that returns a client methods on it:type Foo struct { const size int = 1024 * 1024 lis *bufconn.Listener } func NewFoo() *Foo { const size int = 1024 * 1024 lis := bufconn.Listen(size) return &Authorization{ lis: lis, } } func GetClient() *pb.SomeClient { ... return pb.NewSomeClient(conn) } func Server() { ... go func() { if err := server.Serve(lis); err != nil {} }() }
My Authorization service is straightforward. It has a single method Authorized
. One I’m running an Authorization gRPC server in-memory, I create a client and pass its Authorized
method (!) to the Interceptor function generator:
extractor := some.Extract
f := NewFoo()
f.Serve()
authorizer := f.GetClient().Authorized
interceptor := AuthorizationInterceptorGenerator(
extractor,
authorizer,
)
That’s all!