Skip to content

Commit b59eb53

Browse files
feature: cloud function security improvement with shared secret (looker-open-source#31)
* terraform and backend changes to support the auth token fix auth and cors use the X-Signature header instaed of Authorization add type definitions for crypto-js ignore all dist folders * Add a test script for the local script * improve readme for cloud function * Update changelog * docs: minor typos * docs: updating extension source for new cloud function deployment env var --------- Co-authored-by: Luka Fontanilla <[email protected]>
1 parent aa75432 commit b59eb53

File tree

14 files changed

+212
-68
lines changed

14 files changed

+212
-68
lines changed

Diff for: .gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ terraform.tfstate*
88
*.tfstate
99
.venv
1010
node_modules
11-
dist/
11+
12+
.vertex_cf_auth_token
13+
dist

Diff for: CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v2.1
4+
5+
### Added
6+
- Shared secret for the cloud function and explore assistant extension
7+
- Terraform code for managing the token in the GCP Secrets Manager
8+
39
## v2.0
410

511
There are many breaking changes in this version.

Diff for: explore-assistant-backend/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,21 @@ terraform init
2424

2525
### Cloud Function Backend
2626

27+
First create a file that will contain the LOOKER_AUTH_TOKEN and place it at the root. This will be used my the cloud function locally, as well as the extension framework app. The value of this token will uploaded to the GCP project as secret to be used by the Cloud Function.
28+
29+
```bash
30+
openssl rand -base64 32 > .vertex_cf_auth_token
31+
32+
```
33+
2734
To deploy the Cloud Function backend:
2835

2936
```bash
3037
export TF_VAR_project_id=XXX
3138
export TF_VAR_use_bigquery_backend=0
3239
export TF_VAR_use_cloud_function_backend=1
40+
export TF_VAR_looker_auth_token=$(cat ../../.vertex_cf_auth_token)
41+
terraform init
3342
terraform plan
3443
terraform apply
3544
```

Diff for: explore-assistant-backend/terraform/cloud_function/main.tf

+59-26
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11

22
variable "cloud_run_service_name" {
3-
type = string
3+
type = string
44
}
55

66
variable "deployment_region" {
7-
type = string
7+
type = string
88
}
99

1010
variable "project_id" {
11-
type = string
11+
type = string
1212
}
1313

1414
resource "google_service_account" "explore-assistant-sa" {
@@ -36,12 +36,37 @@ resource "google_project_iam_member" "iam_service_account_act_as" {
3636
}
3737

3838
# IAM permission as Editor
39-
resource "google_project_iam_member" "iam_looker_service_usage" {
39+
resource "google_project_iam_member" "iam_looker_service_usage" {
4040
project = var.project_id
4141
role = "roles/serviceusage.serviceUsageConsumer"
4242
member = format("serviceAccount:%s", google_service_account.explore-assistant-sa.email)
4343
}
4444

45+
resource "google_secret_manager_secret" "vertex_cf_auth_token" {
46+
project = var.project_id
47+
secret_id = "VERTEX_CF_AUTH_TOKEN"
48+
replication {
49+
user_managed {
50+
replicas {
51+
location = var.deployment_region
52+
}
53+
}
54+
}
55+
}
56+
57+
resource "google_secret_manager_secret_version" "vertex_cf_auth_token_version" {
58+
secret = google_secret_manager_secret.vertex_cf_auth_token.name
59+
secret_data = file("${path.module}/../../../.vertex_cf_auth_token")
60+
}
61+
62+
resource "google_secret_manager_secret_iam_binding" "vertex_cf_auth_token_accessor" {
63+
secret_id = google_secret_manager_secret.vertex_cf_auth_token.secret_id
64+
role = "roles/secretmanager.secretAccessor"
65+
members = [
66+
"serviceAccount:${google_service_account.explore-assistant-sa.email}",
67+
]
68+
}
69+
4570
resource "random_id" "default" {
4671
byte_length = 8
4772
}
@@ -65,11 +90,11 @@ resource "google_storage_bucket_object" "object" {
6590
source = data.archive_file.default.output_path # Add path to the zipped function source code
6691
}
6792

68-
resource google_artifact_registry_repository "default" {
93+
resource "google_artifact_registry_repository" "default" {
6994
repository_id = "explore-assistant-repo"
7095
location = var.deployment_region
7196
project = var.project_id
72-
format = "DOCKER"
97+
format = "DOCKER"
7398
}
7499

75100
resource "google_cloudfunctions2_function" "default" {
@@ -78,8 +103,8 @@ resource "google_cloudfunctions2_function" "default" {
78103
description = "An endpoint for generating Looker queries from natural language using Generative UI"
79104

80105
build_config {
81-
runtime = "python310"
82-
entry_point = "cloud_function_entrypoint" # Set the entry point
106+
runtime = "python310"
107+
entry_point = "cloud_function_entrypoint" # Set the entry point
83108
docker_repository = google_artifact_registry_repository.default.id
84109
source {
85110
storage_source {
@@ -90,34 +115,42 @@ resource "google_cloudfunctions2_function" "default" {
90115

91116
environment_variables = {
92117
FUNCTIONS_FRAMEWORK = 1
93-
SOURCE_HASH = data.archive_file.default.output_sha
118+
SOURCE_HASH = data.archive_file.default.output_sha
94119
}
95120
}
96121

97122
service_config {
98-
max_instance_count = 10
99-
min_instance_count = 1
100-
available_memory = "4Gi"
101-
timeout_seconds = 60
102-
available_cpu = "4"
123+
max_instance_count = 10
124+
min_instance_count = 1
125+
available_memory = "4Gi"
126+
timeout_seconds = 60
127+
available_cpu = "4"
103128
max_instance_request_concurrency = 20
104129
environment_variables = {
105-
REGION = var.deployment_region
106-
PROJECT = var.project_id
130+
REGION = var.deployment_region
131+
PROJECT = var.project_id
107132
}
133+
134+
secret_environment_variables {
135+
key = "VERTEX_CF_AUTH_TOKEN"
136+
project_id = var.project_id
137+
secret = google_secret_manager_secret.vertex_cf_auth_token.secret_id
138+
version = "latest"
139+
}
140+
108141
all_traffic_on_latest_revision = true
109-
service_account_email = google_service_account.explore-assistant-sa.email
142+
service_account_email = google_service_account.explore-assistant-sa.email
110143
}
111144
}
112145

113146
### IAM permissions for Cloud Functions Gen2 (requires run invoker as well) for public access
114147

115148
resource "google_cloudfunctions2_function_iam_member" "default" {
116-
location = google_cloudfunctions2_function.default.location
117-
project = google_cloudfunctions2_function.default.project
118-
cloud_function = google_cloudfunctions2_function.default.name
119-
role = "roles/cloudfunctions.invoker"
120-
member = "allUsers"
149+
location = google_cloudfunctions2_function.default.location
150+
project = google_cloudfunctions2_function.default.project
151+
cloud_function = google_cloudfunctions2_function.default.name
152+
role = "roles/cloudfunctions.invoker"
153+
member = "allUsers"
121154
}
122155

123156
data "google_iam_policy" "noauth" {
@@ -130,9 +163,9 @@ data "google_iam_policy" "noauth" {
130163
}
131164

132165
resource "google_cloud_run_service_iam_policy" "noauth" {
133-
location = google_cloudfunctions2_function.default.location
134-
project = google_cloudfunctions2_function.default.project
135-
service = google_cloudfunctions2_function.default.name
166+
location = google_cloudfunctions2_function.default.location
167+
project = google_cloudfunctions2_function.default.project
168+
service = google_cloudfunctions2_function.default.name
136169

137170
policy_data = data.google_iam_policy.noauth.policy_data
138171
}
@@ -143,4 +176,4 @@ output "function_uri" {
143176

144177
output "data" {
145178
value = google_cloudfunctions2_function.default
146-
}
179+
}

Diff for: explore-assistant-backend/terraform/main.tf

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ module "project-services" {
2121
"storage-api.googleapis.com",
2222
"storage.googleapis.com",
2323
"aiplatform.googleapis.com",
24-
"compute.googleapis.com"
24+
"compute.googleapis.com",
25+
"secretmanager.googleapis.com",
2526
]
2627
}
2728

Diff for: explore-assistant-cloud-function/README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ The cloud function integrates with Vertex AI and utilizes the `GenerativeModel`
2222

2323
8. **Execution Environment**: When executed, the script checks if it's running in a Google Cloud Function environment and acts accordingly; otherwise, it starts a Flask web server for local development or testing.
2424

25+
9. **Endpoint Security**: We are using a simple shared secret approach to securing the endpoint. The request body is checked against the supplied signature in the X-Signature header. We aren't yet guarding against replay attacks with nonces.
26+
2527
## Local Development
2628

2729
To set up and run the function locally, follow these steps:
@@ -43,13 +45,13 @@ To set up and run the function locally, follow these steps:
4345
3. Run the function locally by executing the main script:
4446

4547
```bash
46-
PROJECT=XXX REGION=us-central1 python main.py
48+
PROJECT=XXXX LOCATION=us-central-1 VERTEX_CF_AUTH_TOKEN=$(cat ../.vertex_cf_auth_token) python main.py
4749
```
4850

4951
4. Test calling the endpoint locally with a custom query and parameter declaration
5052

5153
```bash
52-
curl -X POST -H "Content-Type: application/json" -d '{"contents":"how are you doing?", "parameters":{"max_output_tokens": 1000}}' http://localhost:8000
54+
python test.py
5355
```
5456

5557
This setup allows developers to test and modify the function in a local environment before deploying it to a cloud function service.

Diff for: explore-assistant-cloud-function/main.py

+38-17
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
# SOFTWARE.
2323

2424
import os
25-
import json
26-
from flask import Flask, request
25+
import hmac
26+
from flask import Flask, request, Response
2727
from flask_cors import CORS
2828
import functions_framework
2929
import vertexai
@@ -36,8 +36,30 @@
3636
# Initialize the Vertex AI
3737
project = os.environ.get("PROJECT")
3838
location = os.environ.get("REGION")
39+
vertex_cf_auth_token = os.environ.get("VERTEX_CF_AUTH_TOKEN")
3940
vertexai.init(project=project, location=location)
4041

42+
def get_response_headers(request):
43+
headers = {
44+
"Access-Control-Allow-Origin": "*",
45+
"Access-Control-Allow-Methods": "POST, OPTIONS",
46+
"Access-Control-Allow-Headers": "Content-Type, X-Signature"
47+
}
48+
return headers
49+
50+
51+
def has_valid_signature(request):
52+
signature = request.headers.get("X-Signature")
53+
if signature is None:
54+
return False
55+
56+
# Validate the signature
57+
secret = vertex_cf_auth_token.encode("utf-8")
58+
request_data = request.get_data()
59+
hmac_obj = hmac.new(secret, request_data, "sha256")
60+
expected_signature = hmac_obj.hexdigest()
61+
62+
return hmac.compare_digest(signature, expected_signature)
4163

4264
def generate_looker_query(contents, parameters=None, model_name="gemini-1.0-pro-001"):
4365

@@ -91,17 +113,21 @@ def create_flask_app():
91113
@app.route("/", methods=["POST", "OPTIONS"])
92114
def base():
93115
if request.method == "OPTIONS":
94-
return handle_options_request()
116+
return handle_options_request(request)
95117

96118
incoming_request = request.get_json()
119+
print(incoming_request)
97120
contents = incoming_request.get("contents")
98121
parameters = incoming_request.get("parameters")
99122
if contents is None:
100123
return "Missing 'contents' parameter", 400
101-
124+
125+
if not has_valid_signature(request):
126+
return "Invalid signature", 403
127+
102128
response_text = generate_looker_query(contents, parameters)
103-
104-
return response_text, 200, response_headers()
129+
130+
return response_text, 200, get_response_headers(request)
105131

106132
return app
107133

@@ -110,31 +136,26 @@ def base():
110136
@functions_framework.http
111137
def cloud_function_entrypoint(request):
112138
if request.method == "OPTIONS":
113-
return handle_options_request()
139+
return handle_options_request(request)
114140

115141
incoming_request = request.get_json()
116142
contents = incoming_request.get("contents")
117143
parameters = incoming_request.get("parameters")
118144
if contents is None:
119145
return "Missing 'contents' parameter", 400
120-
146+
121147
response_text = generate_looker_query(contents, parameters)
122148

123-
return response_text, 200, response_headers()
149+
return response_text, 200, get_response_headers(request)
124150

125151
def response_headers():
126152
return {
127153
"Access-Control-Allow-Origin": "*"
128154
}
129155

130-
def handle_options_request():
131-
headers = {
132-
"Access-Control-Allow-Origin": "*",
133-
"Access-Control-Allow-Methods": "POST, OPTIONS",
134-
"Access-Control-Allow-Headers": "Content-Type",
135-
"Access-Control-Max-Age": "3600"
136-
}
137-
return "", 204, headers
156+
def handle_options_request(request):
157+
return "", 204, get_response_headers(request)
158+
138159

139160
# Determine the running environment and execute accordingly
140161
if __name__ == "__main__":

Diff for: explore-assistant-cloud-function/test.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import hmac
2+
import hashlib
3+
import requests
4+
import json
5+
6+
def generate_hmac_signature(secret_key, data):
7+
"""
8+
Generate HMAC-SHA256 signature for the given data using the secret key.
9+
"""
10+
hmac_obj = hmac.new(secret_key.encode(), json.dumps(data).encode(), hashlib.sha256)
11+
return hmac_obj.hexdigest()
12+
13+
def send_request(url, data, signature):
14+
"""
15+
Send a POST request to the given URL with the provided data and HMAC signature.
16+
"""
17+
headers = {
18+
'Content-Type': 'application/json',
19+
'X-Signature': signature
20+
}
21+
response = requests.post(url, headers=headers, json=data)
22+
return response.text
23+
24+
def main():
25+
# URL of the endpoint
26+
url = 'http://localhost:8000'
27+
28+
# Request payload
29+
data = {"contents":"how are you doing?", "parameters":{"max_output_tokens": 1000}}
30+
31+
# Read the secret key from a file
32+
with open('../.vertex_cf_auth_token', 'r') as file:
33+
secret_key = file.read().strip() # Remove any potential newline characters
34+
35+
# Generate HMAC signature
36+
signature = generate_hmac_signature(secret_key, data)
37+
38+
# Send the request
39+
response = send_request(url, data, signature)
40+
print("Response from server:", response)
41+
42+
if __name__ == "__main__":
43+
main()

Diff for: explore-assistant-extension/.env_example

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
VERTEX_AI_ENDPOINT=<This is your Deployed Cloud Function Endpoint>
21
LOOKER_MODEL=<This is your Looker model name>
32
LOOKER_EXPLORE=<This is your Looker explore name>
43

4+
VERTEX_AI_ENDPOINT=<This is your Deployed Cloud Function Endpoint>
5+
VERTEX_CF_AUTH_TOKEN=<This is the token used to communicate with the cloud function>
6+
57
VERTEX_BIGQUERY_LOOKER_CONNECTION_NAME=<This is the connection name that has vertex ai external connector>
68
VERTEX_BIGQUERY_MODEL_ID=<This is the model id that you want to use for prediction>

0 commit comments

Comments
 (0)