Detailed analysis of gRPC examples-RBAC authentication-permission group management-based on custom Token

Detailed analysis of gRPC examples-RBAC authentication-permission group management-based on custom Token

What is RABC certification?

RBAC (Role-Based Access Control) authorization policy is a method used to control the access rights of users or entities to resources in a system or application. In RBAC, access control is based on roles rather than on individual users. The following are the core concepts of RBAC authorization policy:

  1. Roles: A role represents a group of users or entities that have similar permission requirements or role responsibilities in the system. For example, a system can define roles such as administrator, editor, ordinary user, etc.

  2. Permissions: Permissions refer to the actions a user or entity can perform or the resources they can access. Each role is assigned a set of permissions that determine the actions that role can perform.

  3. User Assigned Roles: Each user or entity is assigned to one or more roles, which determine their permissions in the system. The relationship between users and roles can be many-to-many.

To put it simply, we abstract users into groups, such as the administrator group and the ordinary user group. Users in the administrator group can access the backend management interface, but users in the ordinary user group do not have this right.

RBAC authorization

This example uses StaticInterceptor from the google.golang.org/grpc/authz package. It uses header-based RBAC policies to match each gRPC method to the required role. For simplicity, impersonation metadata is injected into the context, including the required roles, but this should be obtained from the appropriate service based on the authenticated context.

Server-side mind map

Image1

Server-side implementation process

First create a token package, in which the definition of the token structure and the decryption encryption method are implemented.

// Token is a simulated token used to bring to the RPC header when sent by the grpc client.
// And be verified by the server using a pre-established strategy
type Token struct {
// Secret is used by the server to verify the user
Secret string `json:"secret"`
// Username is used by the server to assign roles in authorization metadata.
Username string `json:"username"`
}

// Encode returns a Base64 encoded JSON token.
// returns a base64 encoded version of the JSON representation of token.
func (t *Token) Encode() (string, error) {
barr, err := json.Marshal(t)
if err != nil {
return "", err
}
s := base64.StdEncoding.EncodeToString(barr)
return s, nil
}

// Decode uses base64 to update the status of Token
// Represent the token in json form
func (t *Token) Decode(s string) error {
barr, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return err
}
return json.Unmarshal(barr, t)
}

The next thing to do is to define roles and their corresponding permissions on the server

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": []
}
`
)

Let me analyze the definition of this permission in detail

//- `unaryEchoWriterRole` represents a role,
// Allow the client to make unary (one-way) RPCs and have write permissions.
//"UNARY_ECHO:W" is the name of this role, indicating that it can perform write operations.
unaryEchoWriterRole = "UNARY_ECHO:W"
// streamEchoReadWriterRole represents another role,
// Allow the client to perform two-way streaming (two-way communication) RPC and have read and write permissions.
// "STREAM_ECHO:RW" is the name of this role, indicating that it can perform read and write operations.
 streamEchoReadWriterRole = "STREAM_ECHO:RW"

These roles can be used on the server side for authorization decisions. For example, if the client has the unaryEchoWriterRole role, the server will allow it to perform unary RPC write operations. If the client has the streamEchoReadWriterRole role, the server will allow it to perform bidirectional streaming RPC read and write operations.

Next, let’s explain authzPolicy. This code defines an authorization policy named authzPolicy, which is used to control which operations can be performed by the client and which roles can perform these operations. Let me explain in detail:

  • name: This is the name of the authorization policy, usually used to identify and reference this policy.

  • allow_rules: This is a list of allow rules that specify which operations are allowed.

    • allow_UnaryEcho: This is an allow rule named allow_UnaryEcho, which specifies the operations that the client can perform unary RPC (one-way communication).

      • request: This section specifies the conditions under which this operation is allowed to be performed.

        • paths: The allowed operation paths are defined here. In this example, it limits that only operations on the path /grpc.examples.echo.Echo/UnaryEcho can be performed.

        • headers: This is a list of headers that define the header conditions that need to be met in the request.

          • key: The name of this header is “UNARY_ECHO:W”, which matches the previously defined role unaryEchoWriterRole.

          • values: In this case, it specifies “true”, which means that this operation is only allowed if the client has the unaryEchoWriterRole role.

    • allow_BidirectionalStreamingEcho: This is an allow rule named allow_BidirectionalStreamingEcho, which specifies the operations that the client can perform bidirectional streaming RPC (bidirectional communication). Its structure is similar to allow_UnaryEcho, but applies to different operation paths and roles.

  • deny_rules: This is a list of deny rules that specify which operations are denied. In this example, no deny rules are defined, so all operations are allowed by default.

  • In summary, authzPolicy defines an authorization policy that allows the execution of unary RPCs and bidirectional streaming RPCs, but requires the client to have a specific role (specified in the header) to perform these operations.

    Next we should load the certificate in the server and implement a static interceptor

     // Create an encryption end based on TLS communication.
    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 a validation interceptor based on static policy
    staticInteceptor, err := authz.NewStatic(authzPolicy)
    if err != nil {
    log.Fatalf("Creating a static authz interceptor: %v", err)
    }
    

We pass in the authzPolicy we wrote earlier through NewStatic in “google.golang.org/grpc/authz” authz, and it will successfully register the static interceptor based on the policy we wrote.

Next we should implement the checksum verification of the token header. Let us look at the main function

 // grpc.ChainUnaryInterceptor is a function provided by the gRPC framework.
//'It is used to create a chain of Unary Interceptors.
//Unary interceptors are interceptors used in gRPC to intercept unary RPC calls. These interceptors can perform some additional logic before the request reaches the server or before the response is returned to the client.
// unaryInts is a variable name used to store the created unary interceptor chain.
// authUnaryInterceptor is a custom unary interceptor function
// It is passed to grpc.ChainUnaryInterceptor as the first parameter. The purpose of this interceptor is to block each unary RPC call before it reaches the server.
// Validate the client's authorization token and create a new context with the username for this call.
// staticInteceptor.UnaryInterceptor is another interceptor, this is a unary interceptor extracted from staticInteceptor.
// staticInteceptor is an authorization interceptor created by authz.NewStatic(authzPolicy), which is used to check whether a specific RPC call is allowed. This interceptor will be executed after authUnaryInterceptor.
unaryInts := grpc.ChainUnaryInterceptor(authUnaryInterceptor, staticInteceptor.UnaryInterceptor)
streamInts := grpc.ChainStreamInterceptor(authStreamInterceptor, staticInteceptor.StreamInterceptor)

Let’s look at the code of authUnaryInterceptor

// authUnaryInterceptor looks for authentication headers from the incoming RPC context
// Parse the username and create a new context to pass to the parsing function call
func authUnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
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())
}
//handler is a gRPC unary processing function (UnaryHandler). It represents the actual gRPC server-side processing logic, that is, the function to be called after executing the interceptor to handle the client's gRPC request.
//
//newContextWithRoles(ctx, username) is a custom function used to create a new context object, which contains the user's role information.
//The role of this function in the interceptor is to add the user's role information to the context based on the username (username) provided by the client.
//
//req is the parameter of the gRPC request sent by the client. In this context, the handler will use the new context containing the role information to handle the request.
return handler(newContextWithRoles(ctx, username), req)
}

According to the calling logic, let’s take a look at what it does.

  • username, err := isAuthenticated(md["authorization"])
    // The purpose of md["authorization"] is to extract the value of the authorization header from the metadata of the gRPC request.
    
  • We first call isAuthenticated to verify whether the value of the authorization header is valid and parse out the user name. The function returns two values: username and err. If validation and parsing are successful, username will contain the username and err will be nil; if an error occurs, err will contain a message describing the error, and username` will be an empty string.

  • When the header value is verified, newContextWithRoles is called after executing the interceptor to create a new context object, which contains the user's role information. The role of this function in the interceptor is to add the user's role information to the context based on the username (username) provided by the client.

Then we register these written services

s := grpc.NewServer(grpc.Creds(creds), unaryInts, streamInts)

//Register EchoServer in this service
pb.RegisterEchoServer(s, & amp;server{})

Client communication

Apart from the complex function implementation, let’s look directly at the process and results of the main function.

func main() {
flag.Parse()

// Create TLS-based credentials.
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)
}
// Establish a connection to the server.
conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("grpc.Dial(%q) failed: %v", *addr, err)
}
defer conn.Close()

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

// Make RPC requests as an authorized user, expecting them to complete successfully.
authorizedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "super-user", Secret: "super-secret"})
if err := callUnaryEcho(ctx, client, "hello world", authorizedUserTokenCallOption); err != nil {
log.Fatalf("Unary RPC failed for authorized user: %v", err)
}
if err := callBidiStreamingEcho(ctx, client, authorizedUserTokenCallOption); err != nil {
log.Fatalf("Bidirectional RPC failed for authorized user: %v", err)
}

// Make RPC requests as an unauthorized user, expecting them to fail and return a PermissionDenied status code.
unauthorizedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "bad-actor", Secret: "super-secret"})
if err := callUnaryEcho(ctx, client, "hello world", unauthorizedUserTokenCallOption); err != nil {
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("Unary RPC for unauthorized user failed as expected: %v", err)
default:
log.Fatalf("Unary RPC for unauthorized user failed with unexpected error: %v, %v", c, err)
}
}
if err := callBidiStreamingEcho(ctx, client, unauthorizedUserTokenCallOption); err != nil {
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("Bidirectional RPC failed for unauthorized user, as expected: %v", err)
default:
log.Fatalf("Bidirectional RPC for unauthorized user failed with unexpected error: %v", err)
}
}
}

Client communication results

UnaryEcho: hello world
BidiStreaming Echo: Request 1
BidiStreaming Echo: Request 2
BidiStreaming Echo: Request 3
BidiStreaming Echo: Request 4
BidiStreaming Echo: Request 5
2023/09/25 19:49:10 Unary RPC for unauthorized user failed as expected: rpc error: code = PermissionDenied desc = UnaryEcho RPC failed: rpc error: code = PermissionDenied desc = unauthorized RPC request rejected
2023/09/25 19:49:10 Bidirectional RPC for unauthorized user failed as expected: rpc error: code = PermissionDenied desc = receiving StreamingEcho message: rpc error: code = PermissionDenied desc = unauthorized RPC request rejected

Mind map

Image2

Project structure

.
├── client
│ └── main.go
├── README.md
├── server
│ └── main.go
└── token
 └── token.go

Try it out

The server requires the authenticated user to have the following roles to authorize the use of these methods:

  • UnaryEcho requires role UNARY_ECHO:W
  • BidirectionalStreamingEcho requires role STREAM_ECHO:RW

On receiving the request, the server first checks if a token is provided, then decodes the token and checks if the key is set correctly (for simplicity, the key is hardcoded here as super-secret, The correct authentication provider should be used in a production environment).

If the above steps are successful, it will use the username in the token to set the appropriate roles (for simplicity, if the username matches super-user, the roles will be hardcoded as 2 above required roles, but these should also be provided externally).

Start the server using the following command:

go run server/main.go

The client implementation demonstrates how to use a valid token (set username and secret) and each endpoint will return successfully. It also explains how using the wrong token will cause the service to return codes.PermissionDenied.

Start the client with the following command:

go run client/main.go

Authentication

In gRPC, authentication is abstracted as credentials.PerRPCCredentials. Often, it also includes authorization. Users can configure this on a per-connection or per-call basis.

Currently, examples of authentication include those using OAuth2 with gRPC.

Try

go run server/main.go

go run client/main.go

OAuth2

The OAuth 2.0 protocol is a widely used authentication and authorization mechanism today. gRPC provides a convenient API to configure OAuth for use with gRPC. Please refer to godoc: https://godoc.org/google.golang.org/grpc/credentials/oauth for details.

DialOption [WithPerRPCCredentials](https://godoc.org/google.golang.org/grpc#WithPerRPCCredentials). Alternatively, if the user wishes to apply an OAuth token for each call, then use the grpc RPC call configurationCallOption [PerRPCCredentials`](https://godoc.org/google.golang.org /grpc#PerRPCCredentials).

Note that OAuth requires that the underlying transport layer is secure (e.g., TLS, etc.).

Inside gRPC, the provided token is prefixed by the token type and a space, and then appended to metadata with the key "authorization".

Client instance
/*
 *
 * Copyright 2018 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.
 *
 */
// This client demonstrates how to provide an OAuth2 token per RPC.
package main

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

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

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

// callUnaryEcho calls a unary RPC function and handles the response.
func callUnaryEcho(client ecpb.EchoClient, message string) {

    // Create a context with a timeout (Context) to automatically cancel the operation after the specified time exceeds.
    // context.Background() creates a root context without any parent context.
    // 10*time.Second means the timeout is 10 seconds.
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

    // Use the defer keyword to ensure that the cancel function is called at the end of the function to cancel the context.
    defer cancel()

    // Use the client object client to call the unary RPC function UnaryEcho and pass the context ctx and request parameters.
    // In this example, we want to send a message to the server, and the content of the message is the value of the message variable.
    resp, err := client.UnaryEcho(ctx, & amp;ecpb.EchoRequest{Message: message})

    // Check if an error occurred.
    if err != nil {
        // If an error occurs, use the log.Fatalf function to log the error message and exit the program.
        log.Fatalf("client.UnaryEcho(_) = _, %v: ", err)
    }

    // If no errors occurred, print the response message returned from the server.
    fmt.Println("UnaryEcho: ", resp.Message)

}

func main() {
    flag.Parse()

    //Set the credentials for the connection.
    perRPC := oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(fetchToken())}
    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)
    }
    opts := []grpc.DialOption{
        // In addition to grpc.DialOption below, callers can also use grpc.CallOption grpc.PerRPCCredentials in RPC calls.
        // See: https://godoc.org/google.golang.org/grpc#PerRPCCredentials
        grpc.WithPerRPCCredentials(perRPC),
        // oauth.TokenSource needs to be configured to transmit credentials.
        grpc.WithTransportCredentials(creds),
    }

    conn, err := grpc.Dial(*addr, opts...)
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    rgc := ecpb.NewEchoClient(conn)

    callUnaryEcho(rgc, "hello world")
}

// fetchToken simulates a token lookup and omits details for correct token retrieval.
// For an example of obtaining an OAuth2 token, see:
// https://godoc.org/golang.org/x/oauth2
func fetchToken() *oauth2.Token {
    return &oauth2.Token{
        AccessToken: "some-secret-token",
    }
}

On the server side, the user usually gets the token inside the interceptor and validates it. To obtain a token, call metadata.FromIncomingContext passing in the given context. It will return the metadata map. Next, use the key "authorization" to get the corresponding value, which is a slice of string. For OAuth, the slice should contain only one element, which is a string in the format + " " + . Users can easily obtain the token by parsing the string and then verify its validity.

If the token is invalid, an error with error code codes.Unauthenticated is returned.

If the token is valid, the method handler is called to begin processing the RPC.

Server code example
/*
 * Copyright 2018 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.
 *
 */
//
syntaxbug.com © 2021 All Rights Reserved.