Setting Up Authorization for HTTP Cloud Functions in GCP
Tags: googlecloud, python, cloudfunctions, authorization
Published: 2020-12-27 15:20:45

Cloud functions in GCP (Google Cloud Platform) are a lightweight, stateless, and serverless option for executing code that is triggered by an event. They are equivalent to lambdas in AWS (Amazon Web Services) or Azure Functions in Microsoft Azure. Events that trigger a cloud function include Pub/Sub messages, Cloud Storage events like creating or deleting a storage object, and HTTP requests.

As with any cloud service, you want to make sure that the people and resources interacting with your cloud function are authorized to do so. With Pub/Sub and Cloud Storage triggers the responsibility for restricting who has the ability to invoke your cloud function is deferred to the IAM (Identity and Access Management) associated with those resources, however with HTTP triggers you will have to manage permissions by setting up and assigning one or more Google account and/or service account with the authorization to invoke your cloud function.

By default, a new cloud function created in the GCP Console will require authentication. When deploying a new cloud function from the `gcloud` SDK, you will be asked whether or not you would like to allow unauthenticated invocations.

```
gcloud functions deploy my-new-function `
--entry-point handle_request`
--runtime python38 `
--trigger-http

Allow unauthenticated invocations of new function
[my-new-function]? (y/N)?
```

By allowing unauthenticated invocations, you are making your cloud function's endpoint available to the open internet. If allowing unauthenticated invocations is the desired behavior, you can bypass this question when deploying via the SDK by using the optional flag `--allow-unauthenticated` with the `gcloud functions deploy` command. Unauthenticated cloud function invocations should be an exception; for most use cases you will want some form of authentication and authorization.

Depending on who or what is invoking your cloud function the process for setting up authentication will vary, however there are two requirements common to all types of authentication:

1. The person or service authorized to invoke the cloud function must be assigned the `cloudfunctions.invoker` role or some other role with the `cloudfunctions.invoke` permission.
2. The person or service authorized to invoke the cloud function must send a token along with the HTTP request to prove that they are authorized to invoke the cloud function.

Describing the process for [all use cases](https://cloud.google.com/functions/docs/securing/authenticating) is beyond the scope of this article. Instead the focus will be on setting up authentication for one cloud function to invoke another cloud function and on setting up authentication locally so that you can test your secure cloud functions.

## Function-to-Function Invocation
Before setting up authentication you will need to have a function written in one of the [supported languages](https://cloud.google.com/functions/docs/writing) with a few lines of code covering the basics of a response to an HTTP request. All of the example code in this post has been written in Python however the principles will be the same for all supported languages. 

This first cloud function is the caller function and it is responsible for validating the initial HTTP request, applying some business logic to the contents of that request, and finally invoking the second cloud function with an HTTP request that is authenticated by a token.

```python
import os
import requests

from dotenv import load_dotenv


load_dotenv()
CALLED_CLOUD_FUNCTION_URL = os.getenv('CALLED_CLOUD_FUNCTION_URL')


def send_request_to_called_cloud_function(request):
    request_body = request.get_json(silent=True)

    if not is_valid_request(request_body):
        return 'bad request, missing required field "foo"', 400

    called_func_request = prepare_called_func_request(request_body)

    token = request_token()
    headers = create_request_headers(token)

    called_func_response = requests.post(CALLED_CLOUD_FUNCTION_URL, json=called_func_request, headers=headers)
    
    return called_func_response.text, called_func_response.status_code
```

To create the token, the caller cloud function makes an HTTP request to its metadata server.

```python
def request_token():
    metadata_server_url = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience='
    token_request_url = metadata_server_url + CALLED_CLOUD_FUNCTION_URL
    headers = {'Metadata-Flavor': 'Google'}

    token_response = requests.get(token_request_url, headers=headers)
    token = token_response.text

    return token


def create_request_headers(token):
    headers = {
        'content-type': 'application/json',
        'authorization': f'bearer {token}'
    }

    return headers
```

Whether it's a virtual machine, a cloud function, or an app engine app, all of the compute resources in GCP have a server where relevant metadata about the resource is stored. The request to the metadata server url in the `request_token` function is checking the caller cloud function's metadata and verifying that the service account attached to it is authorized to invoke the intended audience of the request, the called cloud function. If the service account for the caller was setup correctly, your function will receive a valid token to send to the second cloud function.

How do you create a service account that is authorized to invoke a cloud function? You can either [create a new service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts) using the Console or `gcloud` SDK and give it the `cloudfunctions.invoker` role, or you can manage your IAM resources using an IAC (Infrastructure as Code) solution like Terraform.

```terraform
resource "google_service_account" "caller_cloud_function_sa" {
    project      = "my-cloud-function-project"
    account_id   = "caller-cloud-function-sa"
    display_name = "Caller Cloud Function Service Account"
}

resource "google_cloud_functions_function" "caller_cloud_function" {
    project               = "my-cloud-function-project"
    name                  = "caller-cloud-function"
    entry_point           = "send_request_to_called_cloud_function"
    runtime               = "python38"
    service_account_email = google_service_account.caller_cloud_function_sa.email
    trigger_http          = true
}

resource "google_cloudfunctions_function_iam_member" "cloud_function_invoker" {
    project        = google_cloud_functions_function.caller_cloud_function.project
    region         = google_cloud_functions_function.caller_cloud_function.region
    cloud_function = google_cloud_functions_function.caller_cloud_function.name

    role   = "roles/cloudfunctions.invoker"
    member = "serviceAccount:${google_service_account.caller_cloud_function_sa.email}"
}
```

With the service account created and the caller cloud function setup to request a token from its metadata server, the only thing left to do is write the code for the called cloud function

```python
from flask import jsonify


def respond_to_caller_cloud_function_request(request):
    request_body = request.get_json(silent=True)

    if not is_valid_request(request_body):
        return 'bad request, missing required field "bar"', 400

    new_entity = save_request_body(request_body)

    return jsonify(new_entity), 201
```

and deploy both cloud functions from the Console, the `gcloud` SDK, or through some other means like Terraform.

```powershell
# using powershell and the gcloud sdk to deploy both cloud functions
gcloud functions deploy caller-cloud-function `
--entry-point send_request_to_called_cloud_function `
--runtime python38 `
--service-account caller-cloud-function-sa@my-cloud-function-project.iam.gserviceaccount.com `
--trigger-http `
--allow-unauthenticated

gcloud functions deploy called-cloud-function `
--entry-point respond_to_caller_cloud_function_request `
--runtime python38 `
--trigger-http
```

*Note that the caller function is currently setup to allow unauthenticated access. To make this example more secure, you will either want to change this function to use one of the other triggers or set up HTTP authentication for it as well.*

## Testing Your Secured Cloud Function

Now that the called cloud function is secure, testing it from your local machine will require you to send a token along with the request just like the caller cloud function does.

To get a token, first make sure you are logged into your Google account using the `gcloud` SDK, and that you have the authorization to invoke a cloud function. This requires the `cloudfunctions.invoker` role or any other role that includes the `cloudfunctions.invoke` permission. 

If you have been assigned one of the basic roles of `editor` or `owner` you will have the `cloudfunctions.invoke` permission already, otherwise you will need to check the list of roles/permissions assigned to your account and possibly request to have a role with the `cloudfunctions.invoke` permission added by someone on the project with the ability to grant IAM roles.

To log into a Google account using the `gcloud` SDK, use the command

```powershell
gcloud auth login
```

This will open up a tab in your browser where you will be prompted to log in using your account's email address, password, and any multi-factor authentication required by your account's security settings.

Once you are logged in, you can request a token with the command 

```powershell
gcloud auth print-identity-token
```

You can also do this programmatically using Python

```python
def request_identity_token():
    stream = os.popen('gcloud auth print-identity-token')
    token = stream.read()

    return token.strip()
```

and then send your HTTP request in the same way the caller function does.

```python
import requests
from dotenv import load_dotenv


load_dotenv()
CALLED_CLOUD_FUNCTION_URL = os.getenv('CLOUD_FUNCTION_URL')


def send_test_request():
    content = {'foo': 'bar'}
    token = request_identity_token()
    headers = { 'content-type': 'application/json', 'authorization': f'bearer {token}'

    response = requests.post(CALLED_CLOUD_FUNCTION_URL, json=content, headers = headers)
```

With that you are ready to start deploying HTTP cloud functions that require authentication and to test those functions from your local machine. If your use case requires authorization for end users or some other service or resource the particulars may vary but the general process of attaching the `cloudfunctions.invoker` role to a Google account or service account and having the user or resource create a token before calling the function will be the same.