Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

examples: add an example to illustrate authorization (authz) support #5920

Merged
merged 18 commits into from Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/examples_test.sh
Expand Up @@ -52,6 +52,7 @@ EXAMPLES=(
"helloworld"
"route_guide"
"features/authentication"
"features/authz"
"features/cancellation"
"features/compression"
"features/deadline"
Expand Down Expand Up @@ -101,6 +102,7 @@ declare -A EXPECTED_SERVER_OUTPUT=(
["helloworld"]="Received: world"
["route_guide"]=""
["features/authentication"]="server starting on port 50051..."
["features/authz"]="unary echoing message \"hello world\""
["features/cancellation"]="server: error receiving from stream: rpc error: code = Canceled desc = context canceled"
["features/compression"]="UnaryEcho called with message \"compress\""
["features/deadline"]=""
Expand All @@ -120,6 +122,7 @@ declare -A EXPECTED_CLIENT_OUTPUT=(
["helloworld"]="Greeting: Hello world"
["route_guide"]="Feature: name: \"\", point:(416851321, -742674555)"
["features/authentication"]="UnaryEcho: hello world"
["features/authz"]="UnaryEcho: hello world"
["features/cancellation"]="cancelling context"
["features/compression"]="UnaryEcho call returned \"compress\", <nil>"
["features/deadline"]="wanted = DeadlineExceeded, got = DeadlineExceeded"
Expand Down
16 changes: 16 additions & 0 deletions examples/features/authz/README.md
@@ -0,0 +1,16 @@
# RBAC authorization

This example uses the `StaticInterceptor` from the `google.golang.org/grpc/authz` package.
It uses a header based RBAC policy to match each gRPC method to a required role. For simplicity,
the context is injected with mock metadata which includes the required roles, but this should
be fetched from an appropriate service based on the authenticated context.

## Try it

```
go run server/main.go
```

```
go run client/main.go
dfawley marked this conversation as resolved.
Show resolved Hide resolved
```
132 changes: 132 additions & 0 deletions examples/features/authz/client/main.go
@@ -0,0 +1,132 @@
/*
*
* Copyright 2023 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

// Binary client is an example client.
package main

import (
"context"
"flag"
"fmt"
"io"
"log"
"time"

"golang.org/x/oauth2"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/oauth"
"google.golang.org/grpc/examples/data"
"google.golang.org/grpc/examples/features/authz/token"
ecpb "google.golang.org/grpc/examples/features/proto/echo"
"google.golang.org/grpc/status"
)

var addr = flag.String("addr", "localhost:50051", "the address to connect to")

func callUnaryEcho(ctx context.Context, client ecpb.EchoClient, message string, opts ...grpc.CallOption) error {
resp, err := client.UnaryEcho(ctx, &ecpb.EchoRequest{Message: message}, opts...)
if err != nil {
return status.Errorf(status.Code(err), "UnaryEcho RPC failed: %v", err)
}
fmt.Println("UnaryEcho: ", resp.Message)
return nil
}

func callBidiStreamingEcho(ctx context.Context, client ecpb.EchoClient, opts ...grpc.CallOption) error {
c, err := client.BidirectionalStreamingEcho(ctx, opts...)
if err != nil {
return status.Errorf(status.Code(err), "BidirectionalStreamingEcho RPC failed: %v", err)
}
for i := 0; i < 5; i++ {
if err := c.Send(&ecpb.EchoRequest{Message: fmt.Sprintf("Request %d", i+1)}); err != nil {
return status.Errorf(status.Code(err), "sending StreamingEcho message: %v", err)
}
}
c.CloseSend()
for {
resp, err := c.Recv()
if err == io.EOF {
break
}
if err != nil {
return status.Errorf(status.Code(err), "receiving StreamingEcho message: %v", err)
}
fmt.Println("BidiStreaming Echo: ", resp.Message)
}
return nil
}

func newCredentialsCallOption(t token.Token) grpc.CallOption {
tokenBase64, err := t.Encode()
if err != nil {
log.Fatalf("encoding token: %v", err)
}
oath2Token := oauth2.Token{AccessToken: tokenBase64}
return grpc.PerRPCCredentials(oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(&oath2Token)})
}

func main() {
flag.Parse()

// Create tls based credential.
creds, err := credentials.NewClientTLSFromFile(data.Path("x509/ca_cert.pem"), "x.test.example.com")
if err != nil {
log.Fatalf("failed to load credentials: %v", err)
}
// Set up a connection to the server.
conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("grpc.Dial(%q): %v", *addr, err)
}
defer conn.Close()

// Make an echo client and send RPCs.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := ecpb.NewEchoClient(conn)

// Make RPCs as an authorized user and expect them to succeed.
authorisedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "super-user", Secret: "super-secret"})
easwars marked this conversation as resolved.
Show resolved Hide resolved
if err := callUnaryEcho(ctx, client, "hello world", authorisedUserTokenCallOption); err != nil {
log.Fatalf("Unary RPC by authorized user failed: %v", err)
}
if err := callBidiStreamingEcho(ctx, client, authorisedUserTokenCallOption); err != nil {
log.Fatalf("Bidirectional RPC by authorized user failed: %v", err)
}

// Make RPCs as an unauthorized user and expect them to fail with status code PermissionDenied.
unauthorisedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "bad-actor", Secret: "super-secret"})
easwars marked this conversation as resolved.
Show resolved Hide resolved
if err := callUnaryEcho(ctx, client, "hello world", unauthorisedUserTokenCallOption); err != nil {
easwars marked this conversation as resolved.
Show resolved Hide resolved
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("Unary RPC by unauthorized user failed as expected: %v", err)
default:
log.Fatalf("Unary RPC by unauthorized user failed unexpectedly: %v, %v", c, err)
}
}
if err := callBidiStreamingEcho(ctx, client, unauthorisedUserTokenCallOption); err != nil {
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("Bidirectional RPC by unauthorized user failed as expected: %v", err)
default:
log.Fatalf("Bidirectional RPC by unauthorized user failed unexpectedly: %v", err)
}
}
}
215 changes: 215 additions & 0 deletions examples/features/authz/server/main.go
@@ -0,0 +1,215 @@
/*
*
* Copyright 2023 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

// Binary server is an example server.
package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"strings"

"google.golang.org/grpc"
"google.golang.org/grpc/authz"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/examples/data"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

"google.golang.org/grpc/examples/features/authz/token"
pb "google.golang.org/grpc/examples/features/proto/echo"
)

const (
unaryEchoWriterRole = "UNARY_ECHO:W"
streamEchoReadWriterRole = "STREAM_ECHO:RW"
authzPolicy = `
{
"name": "authz",
"allow_rules": [
{
"name": "allow_UnaryEcho",
"request": {
"paths": ["/grpc.examples.echo.Echo/UnaryEcho"],
"headers": [
{
"key": "UNARY_ECHO:W",
"values": ["true"]
}
]
}
},
{
"name": "allow_BidirectionalStreamingEcho",
"request": {
"paths": ["/grpc.examples.echo.Echo/BidirectionalStreamingEcho"],
"headers": [
{
"key": "STREAM_ECHO:RW",
"values": ["true"]
}
]
}
}
],
"deny_rules": []
}
`
)

var (
port = flag.Int("port", 50051, "the port to serve on")

errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
)

func newContextWithRoles(ctx context.Context, username string) context.Context {
md := metadata.MD{}
if username == "super-user" {
md.Set(unaryEchoWriterRole, "true")
md.Set(streamEchoReadWriterRole, "true")
}
return metadata.NewIncomingContext(ctx, md)
}

type server struct {
pb.UnimplementedEchoServer
}

func (s *server) UnaryEcho(ctx context.Context, in *pb.EchoRequest) (*pb.EchoResponse, error) {
fmt.Printf("unary echoing message %q\n", in.Message)
return &pb.EchoResponse{Message: in.Message}, nil
}

func (s *server) BidirectionalStreamingEcho(stream pb.Echo_BidirectionalStreamingEchoServer) error {
for {
in, err := stream.Recv()
if err != nil {
if err == io.EOF {
return nil
}
fmt.Printf("Receiving message from stream: %v\n", err)
return err
}
fmt.Printf("bidi echoing message %q\n", in.Message)
stream.Send(&pb.EchoResponse{Message: in.Message})
}
}

// isAuthenticated validates the authorization.
func isAuthenticated(authorization []string) (username string, err error) {
if len(authorization) < 1 {
return "", errors.New("received empty authorization token from client")
}
tokenBase64 := strings.TrimPrefix(authorization[0], "Bearer ")
// Perform the token validation here. For the sake of this example, the code
// here forgoes any of the usual OAuth2 token validation and instead checks
// for a token matching an arbitrary string.
var token token.Token
err = token.Decode(tokenBase64)
if err != nil {
return "", fmt.Errorf("base64 decoding of received token %q: %v", tokenBase64, err)
}
if token.Secret != "super-secret" {
return "", fmt.Errorf("received token %q does not match expected %q", token.Secret, "super-secret")
}
return token.Username, nil
}

// authUnaryInterceptor looks up the authorization header from the incoming RPC context,
// retrieves the username from it and creates a new context with the username before invoking
// the provided handler.
func authUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
easwars marked this conversation as resolved.
Show resolved Hide resolved
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMissingMetadata
}
username, err := isAuthenticated(md["authorization"])
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
easwars marked this conversation as resolved.
Show resolved Hide resolved
}
return handler(newContextWithRoles(ctx, username), req)
}

// wrappedStream wraps a grpc.ServerStream associated with an incoming RPC, and
// a custom context containing the username derived from the authorization header
// specified in the incoming RPC metadata
type wrappedStream struct {
grpc.ServerStream
ctx context.Context
}

func (w *wrappedStream) Context() context.Context {
return w.ctx
}

func newWrappedStream(ctx context.Context, s grpc.ServerStream) grpc.ServerStream {
return &wrappedStream{s, ctx}
}

// authStreamInterceptor looks up the authorization header from the incoming RPC context,
// retrieves the username from it and creates a new context with the username before invoking
// the provided handler.
func authStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
easwars marked this conversation as resolved.
Show resolved Hide resolved
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return errMissingMetadata
}
username, err := isAuthenticated(md["authorization"])
if err != nil {
return status.Error(codes.Unauthenticated, err.Error())
}
return handler(srv, newWrappedStream(newContextWithRoles(ss.Context(), username), ss))
}

func main() {
flag.Parse()

lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
log.Fatalf("Listening on local port %q: %v", *port, err)
}

// Create tls based credential.
creds, err := credentials.NewServerTLSFromFile(data.Path("x509/server_cert.pem"), data.Path("x509/server_key.pem"))
if err != nil {
log.Fatalf("Loading credentials: %v", err)
}

// Create an authorization interceptor using a static policy.
staticInteceptor, err := authz.NewStatic(authzPolicy)
easwars marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Fatalf("Creating a static authz interceptor: %v", err)
}
unaryInts := grpc.ChainUnaryInterceptor(authUnaryInterceptor, staticInteceptor.UnaryInterceptor)
streamInts := grpc.ChainStreamInterceptor(authStreamInterceptor, staticInteceptor.StreamInterceptor)
s := grpc.NewServer(grpc.Creds(creds), unaryInts, streamInts)

// Register EchoServer on the server.
pb.RegisterEchoServer(s, &server{})

if err := s.Serve(lis); err != nil {
log.Fatalf("Serving Echo service on local port: %v", err)
}
}