Securing Cloud Run services tutorial

This tutorial walks through how to create a secure two-service application running on Cloud Run. This application is a Markdown editor which includes a public "frontend" service which anyone can use to compose markdown text, and a private "backend" service which renders Markdown text to HTML.

Diagram showing the request flow from the frontend 'editor' to the backend 'renderer'.
The "Renderer" backend is a private service. This allows guaranteeing a text transformation standard across an organization without tracking changes across libraries in multiple languages.

The backend service is private using Cloud Run (fully managed)'s built-in, IAM-based service-to-service authentication feature, that limits who can call the service. Both services are built with the principle of least privilege, with no access to the rest of Google Cloud except where necessary.

You can use this tutorial with Cloud Run (fully managed).

Objectives

  • Create a dedicated service account with minimal permissions for service-to-service authentication and service access to the rest of Google Cloud.
  • Write, build, and deploy two services to Cloud Run which interact.
  • Make requests between a public and private Cloud Run service.

Costs

This tutorial uses billable components of Google Cloud, including:

Use the Pricing Calculator to generate a cost estimate based on your projected usage.

New Google Cloud users might be eligible for a free trial.

Before you begin

  1. Sign in to your Google Account.

    If you don't already have one, sign up for a new account.

  2. In the Cloud Console, on the project selector page, select or create a Cloud project.

    Go to the project selector page

  3. Make sure that billing is enabled for your Google Cloud project. Learn how to confirm billing is enabled for your project.

  4. Enable the Cloud Run API.

    Enable the API

  5. Install and initialize the Cloud SDK.
  6. Install curl to try out the service

Setting up gcloud defaults

To configure gcloud with defaults for your Cloud Run service:

  1. Set your default project:

    gcloud config set project PROJECT-ID

    Replace PROJECT-ID with the name of the project you created for this tutorial.

  2. If you are using Cloud Run (fully managed), configure gcloud for your chosen region and specify managed as your Cloud Run platform:

    gcloud config set run/region REGION
    gcloud config set run/platform managed

    Replace REGION with the supported Cloud Run region of your choice.

Cloud Run locations

Cloud Run is regional, which means the infrastructure that runs your Cloud Run services is located in a specific region and is managed by Google to be redundantly available across all the zones within that region.

Meeting your latency, availability, or durability requirements are primary factors for selecting the region where your Cloud Run services are run. You can generally select the region nearest to your users but you should consider the location of the other Google Cloud products that are used by your Cloud Run service. Using Google Cloud products together across multiple locations can affect your service's latency as well as cost.

Cloud Run is available in the following regions:

Subject to Tier 1 pricing

  • asia-east1 (Taiwan)
  • asia-northeast1 (Tokyo)
  • asia-northeast2 (Osaka)
  • europe-north1 (Finland)
  • europe-west1 (Belgium)
  • europe-west4 (Netherlands)
  • us-central1 (Iowa)
  • us-east1 (South Carolina)
  • us-east4 (Northern Virginia)
  • us-west1 (Oregon)

Subject to Tier 2 pricing

  • asia-east2 (Hong Kong)
  • asia-northeast3 (Seoul, South Korea)
  • asia-southeast1 (Singapore)
  • asia-southeast2 (Jakarta)
  • asia-south1 (Mumbai, India)
  • australia-southeast1 (Sydney)
  • europe-west2 (London, UK)
  • europe-west3 (Frankfurt, Germany)
  • europe-west6 (Zurich, Switzerland)
  • northamerica-northeast1 (Montreal)
  • southamerica-east1 (Sao Paulo, Brazil)

Note that it is not possible to use the domain mapping feature of Cloud Run (fully managed) for services in these regions:

  • asia-east2
  • asia-northeast2
  • asia-northeast3
  • asia-southeast1
  • asia-southeast2
  • asia-south1
  • australia-southeast1
  • europe-west2
  • europe-west3
  • europe-west6
  • northamerica-northeast1
  • southamerica-east1
You can use Cloud Load Balancing with a serverless NEG to map a custom domain to Cloud Run (fully managed) services in these regions.

If you already created a Cloud Run service, you can view the region in the Cloud Run dashboard in the Cloud Console.

Retrieving the code sample

To retrieve the code sample for use:

  1. Clone the sample app repository to your local machine:

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

    Python

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

    Go

    git clone https://github.com/GoogleCloudPlatform/golang-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

    Java

    git clone https://github.com/GoogleCloudPlatform/java-docs-samples.git

    Alternatively, you can download the sample as a zip file and extract it.

  2. Change to the directory that contains the Cloud Run sample code:

    Node.js

    cd nodejs-docs-samples/run/markdown-preview/

    Python

    cd python-docs-samples/run/markdown-preview/

    Go

    cd golang-samples/run/markdown-preview/

    Java

    cd java-docs-samples/run/markdown-preview/

Reviewing the private Markdown rendering service

From the perspective of the frontend there is a simple API specification for the Markdown service:

  • One endpoint at /
  • Expects POST requests
  • The body of the POST request is Markdown text

You may want to review all of the code for any security concerns or just to learn more about it by exploring the ./renderer/ directory. Note that the tutorial does not explain the Markdown transformation code.

Shipping the private Markdown rendering service

To ship your code, build with Cloud Build, upload to Container Registry, and deploy to Cloud Run (fully managed):

  1. Change to the renderer directory:

    cd renderer/
  2. Run the following command to build your container and publish on Container Registry.

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    Where PROJECT_ID is your GCP project ID, and renderer is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    Where PROJECT_ID is your GCP project ID, and renderer is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Go

    gcloud builds submit --tag gcr.io/PROJECT_ID/renderer

    Where PROJECT_ID is your GCP project ID, and renderer is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Java

    This sample uses Jib to build Docker images using common Java tools. Jib optimizes container builds without the need for a Dockerfile or having Docker installed. Learn more about building Java containers with Jib.

    mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/renderer

    Where PROJECT_ID is your GCP project ID, and renderer is the name you want to give your service.

    Upon success, you will see a BUILD SUCCESS message. The image is stored in Container Registry and can be re-used if desired.

  3. Deploy as a private service with restricted access.

    Cloud Run (fully managed) provides out-of-the-box access control and service identity features. Access control provides an authentication layer that restricts users and other services from invoking the service. Service identity allows restricting your service from accessing other Google Cloud resources by creating a dedicated service account with limited permissions.

    1. Create a service account to serve as the "compute identity" of the render service. By default this has no privileges other than project membership.

       gcloud iam service-accounts create renderer-identity
      

      The Markdown rendering service does not integrate directly with anything else in Google Cloud. It needs no further permissions.

    2. Deploy with the renderer-identity service account and deny unauthenticated access.

      gcloud run deploy renderer \
        --image gcr.io/PROJECT_ID/renderer \
        --service-account renderer-identity \
        --no-allow-unauthenticated

      Cloud Run can use the short form service account name instead of the full email address if the service account is part of the same project.

Trying out the private Markdown rendering service

Private services cannot be directly loaded by a web browser. Instead, use curl or a similar HTTP request CLI tool that allows injecting an Authorization header.

To send some bold text to the service and see it convert the markdown asterisks to HTML <strong> tags:

  1. Get the URL from the deployment output.

  2. Use gcloud to derive a special development-only identity token for authentication:

    TOKEN=$(gcloud auth print-identity-token)
  3. Create a curl request that passes the raw Markdown text as a URL-escaped query string parameter:

    curl -H "Authorization: Bearer $TOKEN" \
       -H 'Content-Type: text/plain' \
       -d '**Hello Bold Text**' \
       SERVICE_URL
  4. The response should be an HTML snippet:

     <strong>Hello Bold Text</strong>
    

Reviewing the integration between editor and rendering services

The editor service provides a simple text-entry UI and a space to see the HTML preview. Before continuing, review the code retrieved earlier by opening the ./editor/ directory.

Next, explore the following few sections of code that securely integrates the two services.

Node.js

The render.js module creates authenticated requests to the private renderer service. It uses the Google Cloud metadata server in the Cloud Run environment to create an identity token and add it to the HTTP request as part of an Authorization header.

In other environments, render.js uses Application Default Credentials to request a token from Google's servers.


const {GoogleAuth} = require('google-auth-library');
const got = require('got');
const auth = new GoogleAuth();

let client, serviceUrl;

// renderRequest creates a new HTTP request with IAM ID Token credential.
// This token is automatically handled by private Cloud Run (fully managed) and Cloud Functions.
const renderRequest = async (markdown) => { 
  if (!process.env.EDITOR_UPSTREAM_RENDER_URL) throw Error('EDITOR_UPSTREAM_RENDER_URL needs to be set.');
  serviceUrl = process.env.EDITOR_UPSTREAM_RENDER_URL;

  // Build the request to the Renderer receiving service.
  const serviceRequestOptions = { 
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain'
    },
    body: markdown,
    timeout: 3000
  };

  try {
    // Create a Google Auth client with the Renderer service url as the target audience.
    if (!client) client = await auth.getIdTokenClient(serviceUrl);
    // Fetch the client request headers and add them to the service request headers.
    // The client request headers include an ID token that authenticates the request.
    const clientHeaders = await client.getRequestHeaders();
    serviceRequestOptions.headers['Authorization'] = clientHeaders['Authorization'];
  } catch(err) {
    throw Error('could not create an identity token: ', err);
  };

  try {
    // serviceResponse converts the Markdown plaintext to HTML.
    const serviceResponse = await got(serviceUrl, serviceRequestOptions);
    return serviceResponse.body;
  } catch (err) { 
    throw Error('request to rendering service failed: ', err);
  };
};

Parse the markdown from JSON and send it to the Renderer service to be transformed into HTML.

app.post('/render', async (req, res) => {
  try {
    const markdown = req.body.data;
    const response = await renderRequest(markdown);
    res.status(200).send(response);
  } catch (err) {
    console.log('error: markdown rendering:', err);
    res.status(500).send(err);
  }
});

Python

The new_request method creates authenticated requests to private services. It uses the Google Cloud metadata server in the Cloud Run environment to create an identity token and add it to the HTTP request as part of an Authorization header.

In other environments, new_request requests an identity token from Google's servers by authenticating with Application Default Credentials.

import os
import urllib

import google.auth.transport.requests
import google.oauth2.id_token


def new_request(data):
    """
    new_request creates a new HTTP request with IAM ID Token credential.
    This token is automatically handled by private Cloud Run (fully managed)
    and Cloud Functions.
    """

    url = os.environ.get("EDITOR_UPSTREAM_RENDER_URL")
    if not url:
        raise Exception("EDITOR_UPSTREAM_RENDER_URL missing")

    req = urllib.request.Request(url, data=data.encode())

    credentials, project = google.auth.default()
    auth_req = google.auth.transport.requests.Request()
    target_audience = url

    id_token = google.oauth2.id_token.fetch_id_token(auth_req, target_audience)
    req.add_header("Authorization", f"Bearer {id_token}")

    response = urllib.request.urlopen(req)
    return response.read()

Parse the markdown from JSON and send it to the Renderer service to be transformed into HTML.

@app.route("/render", methods=["POST"])
def render_handler():
    body = request.get_json()
    if not body:
        raise Exception("Invalid JSON")

    data = body["data"]
    parsed_markdown = render.new_request(data)
    return parsed_markdown

Go

RenderService creates authenticated requests to private services. It uses the Google Cloud metadata server in the Cloud Run environment to create an identity token and add it to the HTTP request as part of an Authorization header.

In other environments, RenderService requests an identity token from Google's servers by authenticating with Application Default Credentials.

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"net/http"
	"time"

	"golang.org/x/oauth2"
	"google.golang.org/api/idtoken"
)

// RenderService represents our upstream render service.
type RenderService struct {
	// URL is the render service address.
	URL string
	// tokenSource provides an identity token for requests to the Render Service.
	tokenSource oauth2.TokenSource
}

// NewRequest creates a new HTTP request to the Render service.
// If authentication is enabled, an Identity Token is created and added.
func (s *RenderService) NewRequest(method string) (*http.Request, error) {
	req, err := http.NewRequest(method, s.URL, nil)
	if err != nil {
		return nil, fmt.Errorf("http.NewRequest: %w", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// Create a TokenSource if none exists.
	if s.tokenSource == nil {
		s.tokenSource, err = idtoken.NewTokenSource(ctx, s.URL)
		if err != nil {
			return nil, fmt.Errorf("idtoken.NewTokenSource: %w", err)
		}
	}

	// Retrieve an identity token. Will reuse tokens until refresh needed.
	token, err := s.tokenSource.Token()
	if err != nil {
		return nil, fmt.Errorf("TokenSource.Token: %w", err)
	}
	token.SetAuthHeader(req)

	return req, nil
}

The request is sent to the Renderer service after adding the markdown text to be transformed into HTML. Response errors are handled to differentiate communication problems from rendering functionality.


var renderClient = &http.Client{Timeout: 30 * time.Second}

// Render converts the Markdown plaintext to HTML.
func (s *RenderService) Render(in []byte) ([]byte, error) {
	req, err := s.NewRequest(http.MethodPost)
	if err != nil {
		return nil, fmt.Errorf("RenderService.NewRequest: %w", err)
	}
	req.Body = ioutil.NopCloser(bytes.NewReader(in))
	defer req.Body.Close()

	resp, err := renderClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("http.Client.Do: %w", err)
	}

	out, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("ioutil.ReadAll: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return out, fmt.Errorf("http.Client.Do: %s (%d): request not OK", http.StatusText(resp.StatusCode), resp.StatusCode)
	}

	return out, nil
}

Java

makeAuthenticatedRequest creates authenticated requests to private services. It uses the Google Cloud metadata server in the Cloud Run environment to create an identity token and add it to the HTTP request as part of an Authorization header.

In other environments, makeAuthenticatedRequest requests an identity token from Google's servers by authenticating with Application Default Credentials.

// makeAuthenticatedRequest creates a new HTTP request authenticated by a JSON Web Tokens (JWT)
// retrievd from Application Default Credentials.
public String makeAuthenticatedRequest(String url, String markdown) {
  String html = "";
  try {
    // Retrieve Application Default Credentials
    GoogleCredentials credentials = GoogleCredentials.getApplicationDefault();
    IdTokenCredentials tokenCredentials =
        IdTokenCredentials.newBuilder()
            .setIdTokenProvider((IdTokenProvider) credentials)
            .setTargetAudience(url)
            .build();

    // Create an ID token
    String token = tokenCredentials.refreshAccessToken().getTokenValue();
    // Instantiate HTTP request
    MediaType contentType = MediaType.get("text/plain; charset=utf-8");
    okhttp3.RequestBody body = okhttp3.RequestBody.create(markdown, contentType);
    Request request =
        new Request.Builder()
            .url(url)
            .addHeader("Authorization", "Bearer " + token)
            .post(body)
            .build();

    Response response = ok.newCall(request).execute();
    html = response.body().string();
  } catch (IOException e) {
    logger.error("Unable to get rendered data", e);
  }
  return html;
}

Parse the markdown from JSON and send it to the Renderer service to be transformed into HTML.

// '/render' expects a JSON body payload with a 'data' property holding plain text
// for rendering.
@PostMapping(value = "/render", consumes = "application/json")
public String render(@RequestBody Data data) {
  String markdown = data.getData();

  String url = System.getenv("EDITOR_UPSTREAM_RENDER_URL");
  if (url == null) {
    String msg =
        "No configuration for upstream render service: "
            + "add EDITOR_UPSTREAM_RENDER_URL environment variable";
    logger.error(msg);
    throw new IllegalStateException(msg);
  }

  String html = makeAuthenticatedRequest(url, markdown);
  return html;
}

Shipping the public editor service

To build and deploy your code:

  1. Change to the editor directory:

    cd ../editor
  2. Run the following command to build your container and publish on Container Registry.

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    Where PROJECT_ID is your GCP project ID, and editor is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    Where PROJECT_ID is your GCP project ID, and editor is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Go

    gcloud builds submit --tag gcr.io/PROJECT_ID/editor

    Where PROJECT_ID is your GCP project ID, and editor is the name you want to give your service.

    Upon success, you will see a SUCCESS message containing the ID, creation time, and image name. The image is stored in Container Registry and can be re-used if desired.

    Java

    This sample uses Jib to build Docker images using common Java tools. Jib optimizes container builds without the need for a Dockerfile or having Docker installed. Learn more about building Java containers with Jib.

    mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/editor

    Where PROJECT_ID is your GCP project ID, and editor is the name you want to give your service.

    Upon success, you will see a BUILD SUCCESS message. The image is stored in Container Registry and can be re-used if desired.

  3. Deploy as a private service with special access to the rendering service.

    1. Create a service account to serve as the "compute identity" of the render service. By default this has no privileges other than project membership.

       gcloud iam service-accounts create editor-identity
      

      The Editor service does not need to interact with anything else in Google Cloud other than the Markdown rendering service.

    2. Grant access to the editor-identity compute identity to invoke the Markdown rendering service. Any service which uses this as a compute identity will have this privilege.

      gcloud run services add-iam-policy-binding renderer \
        --member serviceAccount:editor-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/run.invoker

      Because this is given the invoker role in the context of the render service, the render service is the only private Cloud Run service the editor can invoke.

    3. Deploy with the editor-identity service account and allow public, unauthenticated access.

      gcloud run deploy editor --image gcr.io/PROJECT_ID/editor \
        --service-account editor-identity \
        --set-env-vars EDITOR_UPSTREAM_RENDER_URL=RENDERER_SERVICE_URL \
        --allow-unauthenticated

      Replace,

      • PROJECT_ID with your project ID
      • RENDERER_SERVICE_URL with the URL provided after deploying the Markdown rendering service.

Understanding the HTTPS traffic

There are three HTTP requests involved in rendering markdown using these services.

Diagram showing the request flow from the user to the editor, editor to get a token from Metadata server, editor to make request to render service, render service to return HTML to editor.
The frontend service with the editor-identity invokes the render service. Both editor-identity and renderer-identity have limited permissions so any security exploit or code injection has limited access to other Google Cloud resources.

Trying it out

To try out the complete two-service application:

  1. Navigate your browser to the URL provided by the deployment step above.

  2. Try editing the Markdown text on the left and click the button to see it preview on the right.

    It should look like this:

    Screenshot of the Markdown Editor User Interface

If you choose to continue developing these services, remember that they have restricted Identity and Access Management (IAM) access to the rest of Google Cloud and will need to be given additional IAM roles to access many other services.

Cleaning up

If you created a new project for this tutorial, delete the project. If you used an existing project and wish to keep it without the changes added in this tutorial, delete resources created for the tutorial.

Deleting the project

The easiest way to eliminate billing is to delete the project that you created for the tutorial.

To delete the project:

  1. In the Cloud Console, go to the Manage resources page.

    Go to the Manage resources page

  2. In the project list, select the project that you want to delete and then click Delete .
  3. In the dialog, type the project ID and then click Shut down to delete the project.

Deleting tutorial resources

  1. Delete the Cloud Run service you deployed in this tutorial:

    gcloud run services delete SERVICE

    Where SERVICE is your chosen service name.

    You can also delete Cloud Run services from the Google Cloud Console.

  2. Remove the gcloud default configurations you added during tutorial setup.

     gcloud config unset run/region
    
  3. Remove the project configuration:

     gcloud config unset project
    
  4. Delete other Google Cloud resources created in this tutorial:

What's next