Skip to content

Commit 245c14c

Browse files
authored
Merge pull request #32 from ChingEnLin/feat/infra-security-hardening
feat: harden Cloud Run security with Secret Manager, VPC connector, and private backend
2 parents a9a4e0f + 2ca3e11 commit 245c14c

16 files changed

Lines changed: 610 additions & 76 deletions
Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# This workflow builds and pushes Docker containers to Google Artifact Registry
2-
# and deploys both backend and frontend on Cloud Run when a commit is pushed to the "production"
3-
# branch.
1+
# Build and deploy QueryPal to Cloud Run.
2+
# Runs on pushes to the production branch.
3+
#
4+
# Infrastructure changes (VPC connector, Secret Manager, IAM) are managed by
5+
# Terraform in the terraform/ directory and must be applied before first deploy.
46

57
name: 'Build and Deploy QueryPal to Cloud Run'
68

@@ -16,6 +18,11 @@ env:
1618
BACKEND_SERVICE: 'querypal-backend'
1719
FRONTEND_SERVICE: 'querypal-frontend'
1820
WORKLOAD_IDENTITY_PROVIDER: 'projects/874216619692/locations/global/workloadIdentityPools/github/providers/querypal'
21+
# Short name of the Cloud Run SA and VPC connector created by Terraform.
22+
# The full SA email is constructed inline in flags using ${{ env.PROJECT_ID }}
23+
# because GitHub Actions does not interpolate env vars inside the env: block.
24+
CLOUD_RUN_SA_NAME: 'querypal-cloudrun-sa'
25+
VPC_CONNECTOR: 'querypal-vpc-connector'
1926

2027
jobs:
2128
deploy:
@@ -29,80 +36,96 @@ jobs:
2936
- name: 'Checkout'
3037
uses: 'actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332' # actions/checkout@v4
3138

32-
# Configure Workload Identity Federation and generate an access token.
3339
- id: 'auth'
3440
name: 'Authenticate to Google Cloud'
3541
uses: 'google-github-actions/auth@f112390a2df9932162083945e46d439060d66ec2' # google-github-actions/auth@v2
3642
with:
3743
workload_identity_provider: '${{ env.WORKLOAD_IDENTITY_PROVIDER }}'
38-
service_account: 'github-actions@gen-lang-client-0698668474.iam.gserviceaccount.com'
44+
service_account: 'github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com'
3945

40-
# Set up Cloud SDK
4146
- name: 'Set up Cloud SDK'
4247
uses: 'google-github-actions/setup-gcloud@98ddc00a17442e89a24bbf282954a3b65ce6d200' # google-github-actions/setup-gcloud@v2
4348

44-
# Configure Docker to use gcloud as a credential helper
4549
- name: 'Configure Docker for GCR'
46-
run: |-
47-
gcloud auth configure-docker --quiet
50+
run: gcloud auth configure-docker --quiet
51+
52+
# ── Backend ──────────────────────────────────────────────────────────────
4853

49-
# Build and Push Backend Container
5054
- name: 'Build and Push Backend Container'
5155
run: |-
5256
cd backend
5357
DOCKER_TAG="gcr.io/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}:${{ github.sha }}"
5458
docker build --tag "${DOCKER_TAG}" --platform linux/amd64 .
5559
docker push "${DOCKER_TAG}"
5660
57-
# Deploy Backend to Cloud Run
5861
- id: 'deploy-backend'
5962
name: 'Deploy Backend to Cloud Run'
6063
uses: 'google-github-actions/deploy-cloudrun@33553064113a37d688aa6937bacbdc481580be17' # google-github-actions/deploy-cloudrun@v2
6164
with:
6265
service: '${{ env.BACKEND_SERVICE }}'
6366
region: '${{ env.REGION }}'
6467
image: 'gcr.io/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}:${{ github.sha }}'
68+
# Non-secret runtime configuration only.
6569
env_vars: |
6670
ENVIRONMENT=production
67-
AZURE_TENANT_ID=${{ secrets.AZURE_TENANT_ID }}
68-
AZURE_CLIENT_ID=${{ secrets.AZURE_CLIENT_ID }}
69-
AZURE_CLIENT_SECRET=${{ secrets.AZURE_CLIENT_SECRET }}
7071
ARM_SCOPE=https://management.azure.com/.default
71-
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
72-
DB_USER=${{ secrets.DB_USER }}
73-
DB_PASS=${{ secrets.DB_PASS }}
7472
DB_NAME=querypal
75-
DB_UNIX_SOCKET=/cloudsql/gen-lang-client-0698668474:europe-west1:querypal-db
73+
DB_UNIX_SOCKET=/cloudsql/${{ env.PROJECT_ID }}:${{ env.REGION }}:querypal-db
74+
# Sensitive values are read directly from Secret Manager at runtime.
75+
# Secret must exist before first deploy (created by terraform/secrets.tf).
76+
secrets: |
77+
AZURE_TENANT_ID=querypal-azure-tenant-id:latest
78+
AZURE_CLIENT_ID=querypal-azure-client-id:latest
79+
AZURE_CLIENT_SECRET=querypal-azure-client-secret:latest
80+
GEMINI_API_KEY=querypal-gemini-api-key:latest
81+
DB_USER=querypal-db-user:latest
82+
DB_PASS=querypal-db-pass:latest
7683
flags: |
7784
--port=8000
78-
--add-cloudsql-instances=gen-lang-client-0698668474:europe-west1:querypal-db
85+
--service-account=${{ env.CLOUD_RUN_SA_NAME }}@${{ env.PROJECT_ID }}.iam.gserviceaccount.com
86+
--add-cloudsql-instances=${{ env.PROJECT_ID }}:${{ env.REGION }}:querypal-db
87+
--vpc-connector=${{ env.VPC_CONNECTOR }}
88+
--vpc-egress=private-ranges-only
89+
--ingress=internal
7990
--allow-unauthenticated
8091
81-
# Build and Push Frontend Container
92+
# ── Frontend ─────────────────────────────────────────────────────────────
93+
8294
- name: 'Build and Push Frontend Container'
8395
run: |-
8496
cd frontend
8597
DOCKER_TAG="gcr.io/${{ env.PROJECT_ID }}/${{ env.FRONTEND_SERVICE }}:${{ github.sha }}"
98+
# VITE_API_BASE_URL=/api tells the React app to send all API calls to
99+
# the /api/* path on its own origin instead of a full backend URL.
100+
# Nginx then proxies those requests to the internal backend service.
86101
docker build --tag "${DOCKER_TAG}" --platform linux/amd64 \
87-
--build-arg VITE_API_BASE_URL=${{ steps.deploy-backend.outputs.url }} \
102+
--build-arg VITE_API_BASE_URL=/api \
88103
--build-arg VITE_AZURE_REDIRECT_URI=https://querypal.virtonomy.io \
89104
.
90105
docker push "${DOCKER_TAG}"
91106
92-
# Deploy Frontend to Cloud Run
93107
- id: 'deploy-frontend'
94108
name: 'Deploy Frontend to Cloud Run'
95109
uses: 'google-github-actions/deploy-cloudrun@33553064113a37d688aa6937bacbdc481580be17' # google-github-actions/deploy-cloudrun@v2
96110
with:
97111
service: '${{ env.FRONTEND_SERVICE }}'
98112
region: '${{ env.REGION }}'
99113
image: 'gcr.io/${{ env.PROJECT_ID }}/${{ env.FRONTEND_SERVICE }}:${{ github.sha }}'
114+
# BACKEND_URL is the internal Cloud Run URL; nginx uses it at runtime to
115+
# proxy /api/* requests to the backend (which is not publicly reachable).
116+
env_vars: |
117+
BACKEND_URL=${{ steps.deploy-backend.outputs.url }}
100118
flags: |
101119
--port=4000
120+
--service-account=${{ env.CLOUD_RUN_SA_NAME }}@${{ env.PROJECT_ID }}.iam.gserviceaccount.com
121+
--vpc-connector=${{ env.VPC_CONNECTOR }}
122+
--vpc-egress=all-traffic
123+
--ingress=all
102124
--allow-unauthenticated
103125
104-
# Show output URLs
126+
# ── Summary ───────────────────────────────────────────────────────────────
127+
105128
- name: 'Show deployment URLs'
106129
run: |-
107-
echo "Backend URL: ${{ steps.deploy-backend.outputs.url }}"
108130
echo "Frontend URL: ${{ steps.deploy-frontend.outputs.url }}"
131+
echo "Backend URL: ${{ steps.deploy-backend.outputs.url }} (internal only)"

README.md

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Azure Cosmos DB's portal interface can be limiting for real-world data explorati
7878
| **Backend API** | FastAPI (Python 3.12), Uvicorn, Pydantic V2 |
7979
| **Database** | Azure Cosmos DB (MongoDB API), PostgreSQL (User Data) |
8080
| **Cloud Platform** | Google Cloud Run, Azure Resource Manager (ARM) |
81+
| **Infrastructure** | Terraform, GCP Secret Manager, Serverless VPC Access, Cloud SQL |
8182
| **DevOps & CI/CD** | GitHub Actions, Docker, Google Container Registry |
8283
| **Testing** | Vitest, React Testing Library, Pytest, Coverage.py |
8384
| **Code Quality** | ESLint, Black, Flake8, MyPy, TypeScript Strict Mode |
@@ -286,50 +287,127 @@ npm run test:ui
286287

287288
---
288289

289-
## ☁️ Cloud Deployment
290+
## ☁️ Infrastructure & Deployment
290291

291-
### Google Cloud Run (Production)
292+
### Production Architecture
292293

293-
QueryPal is designed for Google Cloud Run with automatic CI/CD:
294+
QueryPal runs on Google Cloud Run with a private backend topology. The frontend nginx container is the only public entry point — the backend service is network-isolated and unreachable from the internet.
294295

295-
#### Automatic Deployment
296-
1. **Push to Production**: Commits to `production` branch trigger automatic deployment
297-
2. **GitHub Actions**: Builds and deploys both frontend and backend containers
298-
3. **Environment Variables**: Securely managed through GitHub Secrets
296+
```mermaid
297+
graph TB
298+
Browser(["👤 Browser"])
299+
300+
subgraph gcp["☁️ Google Cloud Platform — europe-west1"]
301+
subgraph cloudrun["Cloud Run"]
302+
direction TB
303+
Frontend["<b>querypal-frontend</b><br/>──────────────<br/>ingress: public<br/>nginx · serves SPA<br/>proxies /api/* → backend"]
304+
Backend["<b>querypal-backend</b><br/>──────────────<br/>ingress: internal only<br/>FastAPI · Uvicorn<br/>❌ not reachable from internet"]
305+
end
306+
307+
subgraph vpc["VPC Network"]
308+
Connector["Serverless VPC<br/>Access Connector<br/><i>10.8.0.0/28</i>"]
309+
end
310+
311+
SM[("🔑 Secret Manager<br/>6 secrets")]
312+
SQL[("🗄️ Cloud SQL<br/>PostgreSQL")]
313+
GCR["📦 Container Registry"]
314+
SA["🪪 Cloud Run SA<br/><i>least-privilege</i>"]
315+
end
316+
317+
subgraph azure["☁️ Microsoft Azure"]
318+
Entra["🔐 Entra ID<br/><i>MSAL · OBO flow</i>"]
319+
Cosmos[("🌍 Cosmos DB<br/>MongoDB API")]
320+
end
321+
322+
Gemini["🤖 Google Gemini Pro"]
323+
324+
Browser -- "HTTPS" --> Frontend
325+
Frontend -. "vpc-egress: all-traffic" .-> Connector
326+
Connector -- "internal ingress\n✅ VPC source allowed" --> Backend
327+
Backend -- "Cloud SQL Proxy\nunix socket" --> SQL
328+
Backend -- "HTTPS" --> Entra
329+
Backend -- "HTTPS" --> Cosmos
330+
Backend -- "HTTPS" --> Gemini
331+
SM -- "mounted at startup\nvia --set-secrets" --> Backend
332+
SA -. "identity" .-> Frontend
333+
SA -. "identity" .-> Backend
334+
GCR -- "image" --> Frontend
335+
GCR -- "image" --> Backend
336+
```
337+
338+
### Network Security Model
339+
340+
| | Frontend | Backend |
341+
|---|---|---|
342+
| **Cloud Run ingress** | `all` (public) | `internal` (VPC only) |
343+
| **VPC egress** | `all-traffic` (proxy to backend) | `private-ranges-only` |
344+
| **Internet accessible** | ✅ Yes | ❌ No — 403 from GFE |
345+
| **Who can call it** | Anyone | Frontend nginx via VPC connector |
346+
347+
All API calls from the browser go to `/api/*` on the frontend's own origin. Nginx strips the `/api` prefix and proxies the request to the backend's internal Cloud Run URL through the VPC connector. The backend URL is never exposed to the browser.
348+
349+
### Secret Management
350+
351+
All sensitive configuration is stored in **GCP Secret Manager** and mounted into the backend container at startup via Cloud Run's native `--set-secrets` integration. Secrets are never passed as plain environment variables and never appear in deployment logs or `gcloud run describe` output.
352+
353+
| Secret | Description |
354+
|---|---|
355+
| `querypal-azure-tenant-id` | Microsoft Entra ID tenant |
356+
| `querypal-azure-client-id` | Backend app registration client ID |
357+
| `querypal-azure-client-secret` | Backend app registration client secret |
358+
| `querypal-gemini-api-key` | Google Gemini API key |
359+
| `querypal-db-user` | Cloud SQL PostgreSQL username |
360+
| `querypal-db-pass` | Cloud SQL PostgreSQL password |
361+
362+
### Infrastructure as Code
363+
364+
Cloud infrastructure is managed by **Terraform** in the `terraform/` directory. The CI pipeline owns image builds and Cloud Run deployments; Terraform owns everything underneath.
365+
366+
| Resource | Managed by |
367+
|---|---|
368+
| VPC connector | Terraform |
369+
| Secret Manager secrets | Terraform |
370+
| Cloud Run service account + IAM | Terraform |
371+
| Cloud SQL instance & database | Terraform (import existing) |
372+
| Cloud Run services | CI pipeline (GitHub Actions) |
373+
| Docker images | CI pipeline (GitHub Actions) |
299374

300-
#### Manual Deployment
301375
```bash
302-
# Authenticate with Google Cloud
303-
gcloud auth login
304-
gcloud config set project YOUR_PROJECT_ID
376+
cd terraform
377+
cp terraform.tfvars.example terraform.tfvars
378+
terraform init
379+
./import.sh # import existing Cloud SQL — no data migration needed
380+
terraform apply
381+
```
305382

306-
# Deploy backend
307-
cd backend
308-
docker build -t gcr.io/YOUR_PROJECT_ID/querypal-backend .
309-
docker push gcr.io/YOUR_PROJECT_ID/querypal-backend
310-
gcloud run deploy querypal-backend \
311-
--image gcr.io/YOUR_PROJECT_ID/querypal-backend \
312-
--region europe-west1 \
313-
--port 8000 \
314-
--add-cloudsql-instances YOUR_CLOUDSQL_INSTANCE \
315-
--set-env-vars AZURE_TENANT_ID=xxx,GEMINI_API_KEY=xxx \
316-
--allow-unauthenticated
317-
318-
# Deploy frontend
319-
cd ../frontend
320-
docker build -t gcr.io/YOUR_PROJECT_ID/querypal-frontend \
321-
--build-arg VITE_API_BASE_URL=https://your-backend-url \
322-
--build-arg VITE_AZURE_REDIRECT_URI=https://your-frontend-url .
323-
docker push gcr.io/YOUR_PROJECT_ID/querypal-frontend
324-
gcloud run deploy querypal-frontend \
325-
--image gcr.io/YOUR_PROJECT_ID/querypal-frontend \
326-
--region europe-west1 \
327-
--port 4000 \
328-
--allow-unauthenticated
383+
> See the PR migration guide for the full step-by-step checklist, including how to populate Secret Manager values and what to verify before the first production deploy.
384+
385+
### CI/CD Pipeline
386+
387+
Pushes to the `production` branch trigger the deploy workflow (`.github/workflows/google-cloudrun-docker.yml`).
388+
389+
```mermaid
390+
flowchart LR
391+
Push(["push to\nproduction"]) --> Auth
392+
393+
subgraph gha["GitHub Actions"]
394+
Auth["Authenticate\nWorkload Identity\nFederation"]
395+
Auth --> BuildBE["Build &amp; push\nbackend image"]
396+
Auth --> BuildFE["Build &amp; push\nfrontend image"]
397+
BuildBE --> DeployBE["Deploy backend\n--ingress=internal\n--set-secrets\n--vpc-connector"]
398+
BuildFE --> DeployFE
399+
DeployBE -- "backend URL" --> DeployFE["Deploy frontend\nBACKEND_URL=internal URL\n--vpc-connector"]
400+
end
401+
402+
DeployBE --> SM
403+
DeployFE --> Done(["✅ Live"])
404+
405+
subgraph gcp["GCP"]
406+
SM[("Secret Manager\nfetch at startup")]
407+
end
329408
```
330409

331-
### Azure Web App (Alternative)
332-
QueryPal also supports deployment to Azure Web Apps using the included publish profiles.
410+
Workload Identity Federation is used for keyless authentication — no long-lived service account keys are stored in GitHub. The dedicated Cloud Run service account (`querypal-cloudrun-sa`) holds only the permissions it needs: `secretmanager.secretAccessor`, `cloudsql.client`, and `vpcaccess.user`.
333411

334412
---
335413

frontend/Dockerfile

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@ COPY --from=build /app/dist .
2121
EXPOSE 4000
2222
RUN rm -rf /etc/nginx/conf.d/default.conf
2323
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
24-
25-
# Create script to substitute environment variables in nginx config
26-
RUN echo '#!/bin/sh' > /docker-entrypoint.sh && \
27-
echo '# Set PORT default if not provided' >> /docker-entrypoint.sh && \
28-
echo 'export PORT=${PORT:-4000}' >> /docker-entrypoint.sh && \
29-
echo 'envsubst "\$PORT" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf' >> /docker-entrypoint.sh && \
30-
echo 'exec nginx -g "daemon off;"' >> /docker-entrypoint.sh && \
31-
chmod +x /docker-entrypoint.sh
24+
COPY docker-entrypoint.sh /docker-entrypoint.sh
25+
RUN chmod +x /docker-entrypoint.sh
3226

3327
CMD ["/docker-entrypoint.sh"]

frontend/docker-entrypoint.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/sh
2+
set -e
3+
4+
export PORT=${PORT:-4000}
5+
# BACKEND_URL is the internal Cloud Run URL of the backend service.
6+
# In production this is set as a Cloud Run environment variable.
7+
# Locally, point directly at the backend container.
8+
export BACKEND_URL=${BACKEND_URL:-http://localhost:8000}
9+
10+
# Substitute only $PORT and $BACKEND_URL; leave nginx's own $variables untouched.
11+
envsubst '$PORT $BACKEND_URL' \
12+
< /etc/nginx/conf.d/default.conf.template \
13+
> /etc/nginx/conf.d/default.conf
14+
15+
exec nginx -g "daemon off;"

frontend/nginx.conf

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ server {
22
listen $PORT;
33
server_name localhost;
44

5+
# Serve the React SPA static files.
56
location / {
67
root /usr/share/nginx/html;
78
index index.html index.htm;
89
try_files $uri $uri/ /index.html;
910
}
1011

11-
# Optionally, proxy API requests to backend if needed
12-
# location /api/ {
13-
# proxy_pass http://backend:8000;
14-
# proxy_set_header Host $host;
15-
# proxy_set_header X-Real-IP $remote_addr;
16-
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
17-
# proxy_set_header X-Forwarded-Proto $scheme;
18-
# }
12+
# Proxy all /api/ requests to the internal backend Cloud Run service.
13+
# The trailing slash on proxy_pass strips the /api prefix before forwarding,
14+
# so /api/query/execute becomes /query/execute on the backend.
15+
# BACKEND_URL is injected at container startup via docker-entrypoint.sh.
16+
location /api/ {
17+
proxy_pass $BACKEND_URL/;
18+
proxy_http_version 1.1;
19+
proxy_set_header Host $proxy_host;
20+
proxy_set_header X-Real-IP $remote_addr;
21+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
22+
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
23+
proxy_read_timeout 300s;
24+
proxy_connect_timeout 10s;
25+
proxy_send_timeout 300s;
26+
}
1927
}

0 commit comments

Comments
 (0)