Securely Deploying to Cloud Run

1. Overview

You will modify the default steps for deploying a service to Cloud Run to improve security, and then see how to access the deployed app in a secure way. The app is a "partner registration service" of the Cymbal Eats application, which is used by companies that work with Cymbal Eats to process food orders.

What you will learn

By making a few small changes to the minimal default steps for deploying an app to Cloud Run, you can significantly increase its security. You will take an existing app and deployment instructions and change the deployment steps to improve the security of the deployed app.

You will then see how to authorize access to the app and make authorized requests..

This is not an exhaustive look at application deployment security, but instead a look at changes you can make to all your future app deployments that will improve their security with very little effort.

2. Setup and Requirements

Self-paced environment setup

  1. Sign-in to the Google Cloud Console and create a new project or reuse an existing one. If you don't already have a Gmail or Google Workspace account, you must create one.

b35bf95b8bf3d5d8.png

a99b7ace416376c4.png

bd84a6d3004737c5.png

  • The Project name is the display name for this project's participants. It is a character string not used by Google APIs. You can update it at any time.
  • The Project ID must be unique across all Google Cloud projects and is immutable (cannot be changed after it has been set). The Cloud Console auto-generates a unique string; usually you don't care what it is. In most codelabs, you'll need to reference the Project ID (it is typically identified as PROJECT_ID). If you don't like the generated ID, you may generate another random one. Alternatively, you can try your own and see if it's available. It cannot be changed after this step and will remain for the duration of the project.
  • For your information, there is a third value, a Project Number which some APIs use. Learn more about all three of these values in the documentation.
  1. Next, you'll need to enable billing in the Cloud Console to use Cloud resources/APIs. Running through this codelab shouldn't cost much, if anything at all. To shut down resources so you don't incur billing beyond this tutorial, you can delete the resources you created or delete the whole project. New users of Google Cloud are eligible for the $300 USD Free Trial program.

Activate Cloud Shell

  1. From the Cloud Console, click Activate Cloud Shell 853e55310c205094.png.

55efc1aaa7a4d3ad.png

If you've never started Cloud Shell before, you're presented with an intermediate screen (below the fold) describing what it is. If that's the case, click Continue (and you won't ever see it again). Here's what that one-time screen looks like:

9c92662c6a846a5c.png

It should only take a few moments to provision and connect to Cloud Shell.

9f0e51b578fecce5.png

This virtual machine is loaded with all the development tools you need. It offers a persistent 5GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. Much, if not all, of your work in this codelab can be done with simply a browser or your Chromebook.

Once connected to Cloud Shell, you should see that you are already authenticated and that the project is already set to your project ID.

  1. Run the following command in Cloud Shell to confirm that you are authenticated:
gcloud auth list

Command output

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Run the following command in Cloud Shell to confirm that the gcloud command knows about your project:
gcloud config list project

Command output

[core]
project = <PROJECT_ID>

If it is not, you can set it with this command:

gcloud config set project <PROJECT_ID>

Command output

Updated property [core/project].

Environment Setup

You will be running commands in the Cloud Shell command line for this lab. You can usually copy the commands and paste them as is, though in some cases you will need to change placeholder values to correct ones.

  1. Set an environment variable to the Project ID for use in later commands:
export PROJECT_ID=$(gcloud config get-value project)
export REGION=us-central
export SERVICE_NAME=partner-registration-service
  1. Enable the Cloud Run service API that will run your app, the Firestore API that will provide NoSQL data storage, the Cloud Build API that will be used by the deployment command, and the Artifact Registry that will be used to hold the application container when built:
gcloud services enable \
  run.googleapis.com \
  firestore.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com
  1. Initialize the Firestore database in Native mode. That command uses the App Engine API, so it must be enabled first.

The command must specify a region for App Engine, which we will not be using but must create for historical reasons, and a region for the database. We will use us-central for App Engine, and nam5 for the database. nam5 is the United State multi-region location. Multi-region locations maximize the availability and durability of the database.

gcloud services enable appengine.googleapis.com

gcloud app create --region=${REGION}
gcloud firestore databases create --region=nam5
  1. Clone the sample app repository and navigate to the directory
git clone https://github.com/momander/cymbal-eats.git

cd cymbal-eats/partner-registration-service

3. Review the README

Open the editor and look at the files comprising the app. View README.md, which describes the steps needed to deploy this app. Some of these steps may involve implicit or explicit security decisions to consider. You will be changing a few of these choices to improve the security of your deployed app, as described here:

Step 3 - Run npm install

It's important to know the provenance and integrity of any third party software used in an app. Managing Software Supply Chain Security is relevant to building any software, not just apps deployed to Cloud Run. This lab focused on deployment, so does not address this area, but you might want to research the topic separately..

Steps 4 and 5 - Edit and run deploy.sh

These steps deploy the app to Cloud Run leaving most options at their defaults. You will modify this step to make the deployment more secure in two key ways:

  1. Do not allow unauthenticated access. It can be convenient to allow that for trying things out during exploration, but this is a web service for use by commercial partners, and should always authenticate its users.
  2. Specify that the application must use a dedicated service account tailored with only the necessary privileges, instead of a default one that will likely have more API and resource access than needed. This is known as the principle of least privilege, and is a fundamental concept of application security.

Steps 6 through 11 - Make sample web requests to verify correct behavior

Since the application deployment now requires authentication, these requests must now include proof of the requester's identity. Instead of altering these files, you will make requests directly from the command line.

4. Securely deploy the service

There were two changes identified as needed in the deploy.sh script: not allowing unauthenticated access, and using a dedicated service account with minimal privileges.

You will create a new service account first, then edit the deploy.sh script to reference that service account and to disallow unauthenticated access, then deploy the service by running the modified script before we can run the modified deploy.sh script..

Create a service account and give it needed access to Firestore/Datastore

gcloud iam service-accounts create partner-sa

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:partner-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role=roles/datastore.user

Edit deploy.sh

Modify the deploy.sh file to disallow unauthenticated access(–no-allow-unauthenticated), and to specify the new service account(–service-account) for the deployed app. Correct the GOOGLE_PROJECT_ID to be your own project's ID.

You will be deleting the first two lines, and changing three other lines as shown below.

gcloud run deploy $SERVICE_NAME \
  --source . \
  --platform managed \
  --region ${REGION} \
  --no-allow-unauthenticated \
  --project=$PROJECT_ID \
  --service-account=partner-sa@${PROJECT_ID}.iam.gserviceaccount.com

Deploy the service

From the command line, run the deploy.sh script:

./deploy.sh

When deployment is complete, the last line of the command output will display the new app's Service URL. Save the URL in an environment variable:

export SERVICE_URL=<URL from last line of command output>

Now try to fetch an order from the app using the curl tool:

curl -i -X GET $SERVICE_URL/partners

The -i flag for the curl command tells it to include response headers in the output. The first line of the output should be:

HTTP/2 403

The app was deployed with the option to disallow unauthenticated requests. This curl command contains no authentication information, so it is refused by Cloud Run. The actual deployed application does not even run or receive any data from this request.

5. Make Authenticated Requests

The deployed app is invoked by making web requests, which now must be authenticated for Cloud Run to allow them. Web requests are authenticated by including an Authorization header of the form:

Authorization: Bearer identity-token

The identity-token is a short term, cryptographically signed, encoded string issued by a trusted authentication provider. In this case, an unexpired, valid, Google-issued identity token is required.

Make a request as your user account

The Google Cloud CLI tool can provide a token for the default authenticated user. Run this command to get an identity token for your own account and save it in the ID_TOKEN environment variable:

export ID_TOKEN=$(gcloud auth print-identity-token)

By default, Google issued identity tokens are valid for one hour. Run the following curl command to make the request that was rejected before because it was not authorized. This command will include the necessary header:

curl -i -X GET $SERVICE_URL/partners \
  -H "Authorization: Bearer $ID_TOKEN"

The command output should start with HTTP/2 200, indicating that the request is acceptable and is being honored. (If you wait an hour and try this request again, it will fail because the token will have expired.) The body of the response is at the end of the output, after a blank line:

{"status":"success","data":[]}

There are no partners yet.

Register partners using the sample JSON data in the directory with two curl commands:

curl -X POST \
  -H "Authorization: Bearer $ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "@example-partner.json" \
  $SERVICE_URL/partner

and

curl -X POST \
  -H "Authorization: Bearer $ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "@example-partner2.json" \
  $SERVICE_URL/partner

Repeat the earlier GET request to see all the registered partners now:

curl -i -X GET $SERVICE_URL/partners \
  -H "Authorization: Bearer $ID_TOKEN"

You should see JSON data with far more content, giving information about the two registered partners.

Make a request as an unauthorized account

The authenticated request made in the last step succeeded not only because it was authenticated, but also because the authenticated user (your account) was authorized. That is, the account had permission to invoke the app. Not all authenticated accounts will be authorized to do that.

The default account used in the previous request was authorized because it is the account that created the project containing the app, and by default that gave it permission to invoke any Cloud Run applications in the account. That permission can be revoked if needed, which would be desirable in a production application. Rather than doing that now, you will create a new service account with no privileges or roles assigned to it and use that to try to access the deployed app.

  1. Create a service account called tester.
gcloud iam service-accounts create tester
  1. You will get an identity token for this new account in much the same way you got one for your default account earlier. However, this requires your default account to have permission to impersonate service accounts. Grant your account this permission.
export USER_EMAIL=$(gcloud config list account --format "value(core.account)")

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="user:$USER_EMAIL" \
  --role=roles/iam.serviceAccountTokenCreator
  1. Now run the following command to save an identity token for this new account in the TEST_IDENTITY environment variable. If the command shows an error message, wait a minute or two and try again.
export TEST_TOKEN=$( \
  gcloud auth print-identity-token \
    --impersonate-service-account \
    "tester@$PROJECT_ID.iam.gserviceaccount.com" \
)
  1. Make the authenticated web request as before, but using this identity token:
curl -i -X GET $SERVICE_URL/partners \
  -H "Authorization: Bearer $TEST_TOKEN"

The command output will again begin with HTTP/2 403 because the request, though authenticated, is not authorized. The new service account does not have permission to invoke this app.

Authorizing an account

A user or service account must have the Cloud Run Invoker role on a Cloud Run service in order to make requests to it. Give the tester service account that role with the command:

gcloud run services add-iam-policy-binding ${SERVICE_NAME} \
  --member="serviceAccount:tester@$PROJECT_ID.iam.gserviceaccount.com" \
  --role=roles/run.invoker \
  --region=${REGION}

After waiting a minute or two for the new role to be updated, repeat the authenticated request. Save a new TEST_TOKEN if it has been an hour or more since it was first saved.

curl -i -X GET $SERVICE_URL/partners \
  -H "Authorization: Bearer $TEST_TOKEN"

The command output now begins with HTTP/1.1 200 OK and the last line contains the JSON response. This request was accepted by Cloud Run and processed by the app.

6. Authenticating programs versus authenticating users

The authenticated requests you have made up to now have used the curl command line tool. There are other tools and programming languages that could have been used instead. However, authenticated Cloud Run requests cannot be made using a web browser with plain web pages. If a user clicks on a link or clicks a button to submit a form in a web page, the browser will not add the Authorization header required by Cloud Run for authenticated requests.

Cloud Run's built-in authentication mechanism is intended for use by programs, not by end users.

Note:

Cloud Run can host user-facing web applications, but those kinds of applications must set Cloud Run to allow unauthenticated requests from users' web browsers. If the applications require user authentication the application must handle it instead of asking Cloud Run to do it. The application can do that in the same ways web applications outside of Cloud Run do. How that is done is outside the scope of this codelab.

You may have noticed that the responses to the example requests up until now have been JSON objects, not web pages. That's because this partner registration service is intended for programs to use, and JSON is a convenient form for them to consume. Next, you will write and run a program to consume and use this data.

Authenticated requests from a Python program

A program can make authenticated requests of a secured Cloud Run application via standard HTTP web requests, but including an Authorization header. The only new challenge for those programs is getting a valid, unexpired identity token to place in that header. That token will be validated by Cloud Run using Google Cloud Identity and Access Management (IAM), so the token must be issued and signed by an authority recognized by IAM. There are client libraries available in many languages programs can use to request such a token be issued. The client library this example will use is the Python google.auth one. There are several Python libraries for making web requests in general; this example uses the popular requests module.

The first step is to install the two client libraries:

pip install google-auth
pip install requests

Python code to request an identity token for the default user is:

credentials, _ = google.auth.default()
credentials.refresh(google.auth.transport.requests.Request())
identity_token = credentials.id_token

If you are using a command shell such as Cloud Shell or the standard terminal shell on your own computer, the default user is whichever one has authenticated inside that shell. In Cloud Shell that's generally the user logged into Google. In other cases, it is whatever user authenticated with gcloud auth login or other gcloud command. If the user has never logged in, there will be no default user and this code will fail.

For a program making requests of another program, you generally don't want to use a person's identity, but rather the requesting program's identity. That's what service accounts are for. You deployed the Cloud Run service with a dedicated service account that provides the identity it uses when making API requests, such as to Cloud Firestore. When a program runs on a Google Cloud platform the client libraries will automatically use the service account assigned to it as its default identity, so the same program code works in both situations.

Python code to make a request with an added Authorization header is:

auth_header = {"Authorization": "Bearer " + identity_token}
response = requests.get(url, headers=auth_header)

The following complete Python program will make an authenticated request to the Cloud Run service to retrieve all registered partners and then print their names and assigned IDs. Copy and run the command below to save this code to the file print_partners.py.

cat > ./print_partners.py << EOF
def print_partners():
    import google.auth
    import google.auth.transport.requests
    import requests

    credentials, _ = google.auth.default()
    credentials.refresh(google.auth.transport.requests.Request())
    identity_token = credentials.id_token

    auth_header = {"Authorization": "Bearer " + identity_token}
    response = requests.get("${SERVICE_URL}/partners", headers=auth_header)

    parsed_response = response.json()
    partners = parsed_response["data"]

    for partner in partners:
        print(f"{partner['partnerId']}: {partner['name']}")


print_partners()
EOF

You will run this program with a shell command. You will need to authenticate as the default user first, so that the program will be able to use those credentials. Run the gcloud auth command below:

gcloud auth application-default login

Follow the instructions to complete the login. Then run the program from the command line:

$ python print_partners.py

The output will look something like the following:

10102: Zippy food delivery
67292: Foodful

The program's request reached the Cloud Run service because it was authenticated with your identity, and you are the owner of this project and therefore authorized to run it by default. It would be more common for this program to run under a service account's identity. When run on most Google Cloud products, such as Cloud Run or App Engine, the default identity would be a service account and will be used instead of a personal account.

7. Congratulations!

Congratulations, you finished the codelab!

Clean up

To avoid incurring charges to your Google Cloud account for the resources used in this tutorial, either delete the project that contains the resources, or keep the project and delete the individual resources.

Deleting the project

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