Access to Google Workspace documents from Cloud Build

Modern code development is based on automation, and especially CI/CD pipelines (Continuous Integration Continuous Deployment/Delivery). On Google Cloud, Cloud Build is the serverless solution to achieve CI/CD and offers many features and highly customizable pipelines.

During the code packaging, you might need to rely on other data than only your code, for configuration, enrichment, customization, versioning,… That new source of data can be in a database but also on a Google Workspace document.

Accessing a Google Workspace document

Before trying to access the document, let’s start by writing a small python script main.py that read a Google Sheet document

import google.auth
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

# The ID and range of a sample spreadsheet.
SAMPLE_SPREADSHEET_ID = 'YOUR_SHEET_ID'
SAMPLE_RANGE_NAME = 'DATA!A2:E' #Example


def main():
"""Shows basic usage of the Sheets API.
Prints values from a sample spreadsheet.
"""

creds, _ = google.auth.default()
try:
service = build('sheets', 'v4', credentials=creds)
# Call the Sheets API
sheet = service.spreadsheets()
result = sheet.values().get(spreadsheetId=SAMPLE_SPREADSHEET_ID,
range=SAMPLE_RANGE_NAME).execute()
values = result.get('values', [])

if not values:
print('No data found.')
return

print('Name, Major:')
for row in values:
# Print columns A and E
print('%s, %s' % (row[0], row[4]))
except HttpError as err:
print(err)


if __name__ == '__main__':
main()

Install the dependencies (file requirements.txt)

google-api-python-client
google-auth
# Install dependencies with: pip3 install -r requirements.txt

You can perform a first try.

python3 main.py

Fail… Insufficient scopes. There is an issue with the default authentication.

When you authenticate yourself on your environment with the command gcloud auth application-default login, you use the default scopes, which do not include the Google Sheet scope.
Let’s fix that

To scope your personal account you just have to explicitly define the scopes and add thoses that you want in addition of the Google Cloud Platform ones.

gcloud auth application-default login \
--scopes='https://www.googleapis.com/auth/spreadsheets.readonly',\
https://www.googleapis.com/auth/cloud-platform'

And try your code again. This time it works!!

The naïve first try

To run the Python script in Cloud Build, you have to write a cloudbuild.yaml file that describe the operation. We will use a single step with a Python image.

steps:
- name: 'python:slim-buster'
script: |
pip3 install -r requirements.txt
python3 main.py

And run the build;

gcloud builds submit

Oh no, Insufficient scopes!! But this time, you can’t scope the current account, because it is provided by the metadata server.

Let’s update the code to enforce that scope programmatically
Update only the creds variable definition.

SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
creds, _ = google.auth.default(scopes=SCOPES)

And try again. Not better…

The custom service account try

With some services, like Compute Engine, you can’t change the scope of the default service account at runtime.

Or, and it’s the most interesting and my most ambitious option

So, let’s use a customer-managed service account with Cloud Build. You have to create a service account, and grant the correct permissions on it.

#Create the service account
gcloud iam service-accounts create custom-sa
#Grant the permission
gcloud projects add-iam-policy-binding \
--member="serviceAccount:custom-sa@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/storage.objectViewer" PROJECT_ID
gcloud projects add-iam-policy-binding \
--member="serviceAccount:custom-sa@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/logging.logWriter" PROJECT_ID

Replace the PROJECT_ID with your own project ID

And update your cloudbuild.yaml file like that

steps:
- name: 'python:slim-buster'
script: |
pip3 install -r requirements.txt
python3 main.py
serviceAccount: 'projects/PROJECT_ID/serviceAccounts/custom-sa@PROJECT_ID.iam.gserviceaccount.com'
options:
logging: CLOUD_LOGGING_ONLY

Finally, have a final try… And fail again Insufficient scopes. Why??

The impersonation solution

So, the metadata server can’t generate a token with a different scope. The issue is similar as this one we had with the user account at the beginning. Impossible to change the token scope at runtime.
The solution was to regenerate a token with the correct scopes, from the beginning.

Therefore, the next option is to do the same thing and to ask for a new token, on the service account, with the correct scopes at token creation time.

For that, we can use a feature named impersonation. The principle is to generate a token (access token or identity token) on behalf another account.
And, by the way, inherit of all its permissions

In the code, you have to change things:

  • You must know the service account email to impersonate (for production purpose, use environment variable to provide it)
  • You have to create the impersonated credential and to use it
import google.auth.impersonated_credentials

from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

# The ID and range of a sample spreadsheet.
SAMPLE_SPREADSHEET_ID = 'YOUR_SHEET_ID'
SAMPLE_RANGE_NAME = 'DATA!A2:E'


def main():
"""Shows basic usage of the Sheets API.
Prints values from a sample spreadsheet.
"""
SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
creds, _ = google.auth.default(scopes="https://www.googleapis.com/auth/cloud-platform")
icreds = google.auth.impersonated_credentials.Credentials(
source_credentials=creds,
target_principal=custom-sa@PROJECT_ID.iam.gserviceaccount.com,
target_scopes=SCOPES,
)


try:
service = build('sheets', 'v4', credentials=icreds)

# Call the Sheets API
sheet = service.spreadsheets()
result = sheet.values().get(spreadsheetId=SAMPLE_SPREADSHEET_ID,
range=SAMPLE_RANGE_NAME).execute()
values = result.get('values', [])

if not values:
print('No data found.')
return

print('Name, Major:')
for row in values:
# Print columns A and E, which correspond to indices 0 and 4.
print('%s, %s' % (row[0], row[4]))
except HttpError as err:
print(err)


if __name__ == '__main__':
main()

To allow the Cloud Build runtime service account impersonating the target service account, the runtime service account must be allowed to create a token on the target service account.

So, you have to grant the roles Service Account Token Creator on the runtime service account like that

gcloud projects add-iam-policy-binding \
--member="serviceAccount:custom-sa@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator" PROJECT_ID

And have a try… Fail again!!

But the good news is that it’s not the same error! It’s an error from Google Sheet because the target service account hasn’t the permission to access to it!

Wonderful!! Go to your sheet document, click on the SHARE button and add the target service account email as VIEWER of the sheet document.

Have a new try and this time it works!!

Note: the customer-managed service account is not longer required. You can switch back to the Cloud Build default service account; Don’t forget to grant the correct role on it to let it impersonate the service accounts.

Note 2: you can’t impersonate the Cloud Build default service account itself. You can only impersonate a customer-managed service account (a service account that you create and which is attached to your project)

Inconsistent security solutions

Security is paramount and Google Cloud offers really strong and versatile security options.

However, those options are inconsistent from a service to another one and requires expertise, failure and hack to success in an elegant way.
Because of those difficulties, many users don’t spend time and go quickly to the fastest and the ugliest solution: They use a service account key file (which is the anti-pattern of security)!

I hope that article helps you to successfully and elegantly manage your security issues and stay safe in any circumstances!

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
guillaume blaquiere

GDE cloud platform, Group Data Architect @Carrefour, speaker, writer and polyglot developer, Google Cloud platform 3x certified, serverless addict and Go fan.