gRPC Service to Service on Cloud Run with Authentication
Part 2
This is Part 2 of the article series.
Link to Part 1: gRPC service to service on Cloud Run
Link to Part 3: gRPC service to service on Cloud Run and private networking
Wait! I’m not interested in the story, just show me the code. https://github.com/edyw/cloudrun-grpc
git clone https://github.com/edyw/cloudrun-grpc.git
Part 2: gRPC service to service, with Cloud Run authentication enabled
We want to secure gRPC service to service call, in this scenario we configure go-contact to authenticate and authorize both go-api and node-api services, making sure no other services or clients accessing the gRPC on go-contact.
Cloud Run has feature to enforce IAM users authorization, we will enable this on go-contact service and grant Cloud Run Invoker roles/run.invoker
to go-api and node-api using their IAM Service Account.
It is a security best practice to assign different Service Account to each Cloud Run, so we can selectively grant necessary access to each service.
Because calling go-contact gRPC service from go-api and node-api will not automatically include the credentials of the respective Cloud Run’s Service Account, we need to change the code to use gRPC and other libraries to extract the Service Account and attach it to gRPC call.
If you code half way and come here to copy/paste this part, go to Step 2.4 for Go, Step 2.5 for Node JS
For the rest of Part 2 we will cover the these steps:
- Step 2.1 Change go-contact Cloud Run to enable authentication and test access
- Step 2.2 Create Service Account for all Cloud Run services
- Step 2.3 Grant go-api and node-api Service Account to access go-contact service
- Step 2.4 Change Go code on go-api to add Service Account credentials
- Step 2.5 Change Node JS code on node-api to add Service Account credentials
- Step 2.6 Deploy the latest go-api and node-api with their Service Account
- Step 2.7 End to end test
Step 2.1
Change go-contact Cloud Run to enable authentication and test access
In Part 1, we deployed Cloud Run service with --allow-unauthenticated
parameter, this will add allUsers
to roles/run.invoker
. Let’s enable authentication by removing that IAM binding:
// Set Project ID if you haven't done it
gcloud config set project ${PROJECT_ID}
gcloud run services remove-iam-policy-binding go-contact \
--region=${REGION} \
--member=allUsers \
--role=roles/run.invoker
Replace $REGION
with your Cloud Run service region, eg. asia-southeast1. ${PROJECT_ID}
is the Google Cloud project ID.
You can also do this through Google Cloud Console:
If you still have go-api or node-api services from Part 1, you can test it out:
curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022
go-api should return: Service Unavailable%
and node-api should return: {}%
Logs on the Cloud Run explains the reason, and it is expected.
Step 2.2
Create Service Account for all Cloud Run service
In order to identify the callers, let’s create Service Accounts.
gcloud iam service-accounts create sa-run-go-api
gcloud iam service-accounts create sa-run-node-api
The Service Account is created in this format: ${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com
. We will be using this naming convention as Service Account for the rest of article.
Step 2.3
Grant go-api and node-api Service Account to access go-contact service
gcloud run services add-iam-policy-binding go-contact \
--region=${REGION} \
--member=serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \
--role=roles/run.invoker
${SA_NAME}
is the Service Account name, sa-run-go-api
and sa-run-node-api
.
You need to do for each Service Account if you have both go-api and node-api.
Step 2.4
Change Go code on go-api to add Service Account credentials
We are going to create a new route to call go-contact with authentication, let’s name it GET /contact-auth/:phone
/go-api/main.go
...
func main() {
r := gin.Default()
r.GET("/contact/:phone", getContactHandler)
r.GET("/contact-auth/:phone", getContactAuthHandler) // New route
r.Run(":8081")
}
...
The new handler function getContactAuthHandler
will call the same getContact
function from Part 1, but this time we need to use a context ctx
with Service Account credentials.
/go-api/main.go
import {
...
"google.golang.org/api/idtoken"
grpcMetadata "google.golang.org/grpc/metadata"
...
}
...
...
func getContactAuthHandler(c *gin.Context) {
ctx, err := getAuthContext()
if err != nil {
log.Printf("%v", err)
c.JSON(http.StatusInternalServerError, err)
return
}
contactReply, err := getContact(c.Param("phone"), ctx)
if err != nil {
log.Printf("%v", err)
c.JSON(http.StatusInternalServerError, err)
} else {
log.Printf("%v", contactReply)
c.JSON(http.StatusOK, contactReply)
}
}
func getAuthContext() (context.Context, error) {
ctx := context.Background()
tokenSource, err := idtoken.NewTokenSource(ctx, "https://"+contactServerHost)
if err != nil {
return nil, err
}
token, err := tokenSource.Token()
if err != nil {
return nil, err
}
return grpcMetadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token.AccessToken), nil
}
...
...
getAuthContext()
returns context with credentials from idtoken
library. This library automatically gets Service Account credentials that the Cloud Run service is running, so in this case it is the credentials of sa-run-go-api@${PROJECT_ID}.iam.gserviceaccount.com
, and we append it to the context as gRPC Metadata.
The rest of the process follows the same getContact
function for unauthenticated version in Part 1.
For complete code: https://github.com/edyw/cloudrun-grpc
Step 2.5
Change Node JS code on node-api to add Service Account credentials
We are going to create a new route to call go-contact with authentication, let’s name it GET /contact-auth/:phone
/node-api/index.js
const express = require('express')
// import the new getContactAuthHandler we are going create next step
const { getContactHandler, getContactAuthHandler } = require('./contact-handler')
const app = express()
app.get('/contact/:phone', getContactHandler)
app.get('/contact-auth/:phone', getContactAuthHandler) // Add this route
app.listen('8082', () => {
console.log('node-api listening..')
})
/node-api/contact-handler.js
...
...
const getContactAuthHandler = async (req, res) => {
// Prepare grpc metadata with Service Account credentials
// from getIdTokenFromMetadataServer function
var metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer ' + await getIdTokenFromMetadataServer())
// From here it is the same as Part 1,
// with exception of additional metadata when calling getContact
const client = new services.ContactClient(contactServerHost + ':443', grpc.credentials.createSsl())
const grpcReq = new messages.ContactRequest()
grpcReq.setPhonenumber(req.params.phone)
// Notice the metadata with Credentials when calling getContact
client.getContact(grpcReq, metadata, (err, grpcReply) => {
console.log('grpcReply: ' + JSON.stringify(grpcReply, ' ', 2))
res.send(grpcReply.getName())
})
}
// Google Auth library fetch idtoken
const getIdTokenFromMetadataServer = async () => {
const googleAuth = new GoogleAuth()
const googleClient = await googleAuth.getClient()
token = await googleClient.fetchIdToken('https://' + contactServerHost)
console.log('token: ' + JSON.stringify(token, ' ', 2)) // For debug
return token
}
module.exports = {
getContactHandler,
getContactAuthHandler // Export the new function
}
Similar to Go version, getIdTokenFromMetadataServer
uses Google Auth library to fetch idtoken which automatically identify the Service Account assigned to the Cloud Run. The token is stored to gRPC metadata variable, and pass it over to getContact
gRPC call.
For complete code: https://github.com/edyw/cloudrun-grpc
Step 2.6
Deploy the latest go-api and node-api with their Service Account
Deploy go-api, execute this from main directory:
docker build . -f ./go-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api
docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api
gcloud run deploy go-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8081 \
--service-account=sa-run-go-api@${PROJECT_ID}.iam.gserviceaccount.com
For node-api:
docker build . -f ./node-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api
docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api
gcloud run deploy node-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8082 \
--service-account=sa-run-node-api@${PROJECT_ID}.iam.gserviceaccount.com
${REPO}
is the Google Artifact Registry repository used to store container image. Refer to Part 1 for the detail.
Step 2.7
End to end test
Final verification before testing it end to end. We check the Google Cloud Console.
Verify go-contact Cloud Run:
- Authentication: Require authentication
- Permission: Cloud Run Invoker
roles/run.invoker
has both Service Accounts added
Verify node-api (do th same for go-api) Cloud Run:
- Service Account is set to
sa-run-node-api@${PROJECT_ID}.iam.gserviceaccount.com
Test the service using go-api or node-api Cloud Run Service URL with Service Account credentials:
curl ${CLOUD_RUN_SERVICE_URL}/contact-auth/555-110022
You should get: Mike F%
Test without Service Account credentials:
curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022
You should get: Service Unavailable%
or {}%
Hooray!