Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions terraform/environments/gcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,18 @@ The GCP deployment creates:

## Security: Cloud Run ingress

Two variables control how external callers reach the Cloud Run service:
Two settings control how external callers reach the Cloud Run service:

| Variable | Default | What it does |
| Setting | Source | What it does |
| --- | --- | --- |
| `cloud_run_allow_unauthenticated` | `false` | IAM gate. `false` = only callers with `roles/run.invoker` can hit the URL. |
| `cloud_run_ingress` | `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` | Network gate. Restricts the `*.run.app` URL to VPC + LB traffic only. |
| `allow_unauthenticated` | Derived from `enable_cdn` (in `compute.tf` as `local.cloud_run_allow_unauthenticated = !var.enable_cdn`) | IAM gate. `false` = only callers with `roles/run.invoker` can hit the URL. Flips with the LB so the IAM door stays closed exactly when the LB SA can sign upstream calls. |
| `cloud_run_ingress` | Operator-overridable variable, default `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` | Network gate. Restricts the `*.run.app` URL to VPC + LB traffic only. |

The defaults are **defence-in-depth**: even if a bad IAM binding ever grants `roles/run.invoker` to `allUsers`, the network gate keeps direct internet callers out of the `*.run.app` URL — only requests that come through the external HTTPS load balancer (and therefore Cloud Armor's WAF) can reach the service.

### When to override

`cloud_run_ingress` MUST be overridden to `INGRESS_TRAFFIC_ALL` whenever the supporting LB stack is not provisioned (`enable_cdn = false`), or the service becomes unreachable. All shipped tfvars (`dev.tfvars.example`, `github-dev.tfvars`, `github-staging.tfvars`, `github-prod.tfvars`) currently set `enable_cdn = false` and override `cloud_run_ingress` accordingly. When an environment flips `enable_cdn = true` (and provisions the LB + Cloud Armor + DNS), drop the `cloud_run_ingress` override so the service falls back to the secure default.
`cloud_run_ingress` MUST be overridden to `INGRESS_TRAFFIC_ALL` whenever the supporting LB stack is not provisioned (`enable_cdn = false`), or the service becomes unreachable. All shipped tfvars (`dev.tfvars.example`, `github-dev.tfvars`, `github-staging.tfvars`, `github-prod.tfvars`) currently set `enable_cdn = false` and override `cloud_run_ingress` accordingly. When an environment flips `enable_cdn = true` (and provisions the LB + Cloud Armor + DNS), drop the `cloud_run_ingress` override so the service falls back to the secure default — `allow_unauthenticated` automatically flips to `false` in the same step because both are derived from `enable_cdn`.

### Verify

Expand Down
36 changes: 33 additions & 3 deletions terraform/environments/gcp/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@
# Compute Platform: Cloud Run (Serverless)
# ==============================================

# Cloud Run allow_unauthenticated is fully derived from enable_cdn. There is no
# operator-facing variable for it: the two valid combinations are
#
# enable_cdn = false -> allow_unauthenticated = true
# The SPA / clients hit the *.run.app URL directly. Browsers cannot present
# a Google-signed identity token, so the IAM gate must accept allUsers; auth
# is enforced at the application layer (login session, CSRF, OIDC for
# scheduled tasks).
#
# enable_cdn = true -> allow_unauthenticated = false
# The external HTTPS LB (with Cloud Armor) fronts the service. The LB
# attaches a Google-signed identity to upstream calls, so Cloud Run can
# enforce roles/run.invoker on the LB's service account only and lock
# allUsers out at the IAM layer (closes #384).
#
# Tying the two flags together prevents two specific mis-configurations:
# (a) enable_cdn = true with allow_unauthenticated = true -> public *.run.app
# URL behind a pointless LB (security goal of #384 defeated; bypassing
# Cloud Armor's WAF is a single curl away).
# (b) enable_cdn = false with allow_unauthenticated = false -> direct browser
# hits to *.run.app return 403 (no way to present a signed identity), the
# service is unreachable.
#
# This mirrors the AWS-side pattern in #574 (Lambda Function URL auth_type
# derived from enable_cdn). The AWS analog is the CloudFront OAC's SigV4 signing
# of upstream calls; the GCP analog is the LB SA's identity token.
locals {
cloud_run_allow_unauthenticated = !var.enable_cdn
}

module "compute_cloud_run" {
source = "../../modules/compute/gcp/cloud-run"
count = var.compute_platform == "cloud-run" ? 1 : 0
Expand All @@ -24,7 +54,7 @@ module "compute_cloud_run" {
request_timeout = var.cloud_run_request_timeout

# Access
allow_unauthenticated = var.cloud_run_allow_unauthenticated
allow_unauthenticated = local.cloud_run_allow_unauthenticated
ingress = var.cloud_run_ingress

# Database connection
Expand Down Expand Up @@ -54,8 +84,8 @@ module "compute_cloud_run" {
# ID token with the scheduler SA, and the CUDly app validates that
# token at /api/scheduled/* via internal/server/scheduledauth
# (signature, issuer, audience, sub-pin). Cloud Run's IAM gate
# (roles/run.invoker, gated by cloud_run_allow_unauthenticated —
# tracked separately in #78) acts as defence in depth on top.
# (roles/run.invoker, derived from enable_cdn via the local above
# — see #384) acts as defence in depth on top.
# Azure stays on bearer + Key Vault because Logic Apps' HTTP
# Connector does not emit Entra OIDC tokens.
enable_scheduled_tasks = var.enable_scheduled_tasks
Expand Down
6 changes: 4 additions & 2 deletions terraform/environments/gcp/dev.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ cloud_run_cpu = "1"
cloud_run_memory = "512Mi"
cloud_run_min_instances = 0
cloud_run_max_instances = 10
cloud_run_request_timeout = 300
cloud_run_allow_unauthenticated = true
cloud_run_request_timeout = 300
# Dev runs without the external HTTPS LB (`enable_cdn = false` below), so
# the *.run.app URL must accept direct traffic — override the secure default.
# (allow_unauthenticated is no longer an operator-facing tfvar — it is derived
# from enable_cdn in compute.tf; when enable_cdn = false it stays true so
# *.run.app browser hits go through.)
cloud_run_ingress = "INGRESS_TRAFFIC_ALL"

# GKE settings (when compute_platform = "gke")
Expand Down
21 changes: 13 additions & 8 deletions terraform/environments/gcp/github-dev.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ compute_platform = "cloud-run"
enable_docker_build = true # Build and push image via terraform apply on the runner

# Cloud Run Configuration
cloud_run_cpu = "1"
cloud_run_memory = "512Mi"
cloud_run_min_instances = 0
cloud_run_max_instances = 10
cloud_run_request_timeout = 300
cloud_run_allow_unauthenticated = true
# github-dev runs without the external HTTPS LB (`enable_cdn = false`), so
# the *.run.app URL must accept direct traffic — override the secure default.
cloud_run_cpu = "1"
cloud_run_memory = "512Mi"
cloud_run_min_instances = 0
cloud_run_max_instances = 10
cloud_run_request_timeout = 300
# github-dev: enable_cdn = false means no external HTTPS LB is provisioned
# yet, so direct *.run.app traffic must still be accepted — override the
# secure ingress default until the LB stack (enable_cdn = true + DNS + cert)
# lands. Once enable_cdn flips to true, remove this line so
# INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER takes effect. See issues #78 + #384.
# (allow_unauthenticated is no longer an operator-facing tfvar — it is derived
# from enable_cdn in compute.tf so the IAM gate and ingress door flip in lock-
# step with the LB stack landing.)
cloud_run_ingress = "INGRESS_TRAFFIC_ALL"

# ==============================================
Expand Down
24 changes: 13 additions & 11 deletions terraform/environments/gcp/github-prod.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@ compute_platform = "cloud-run"
enable_docker_build = true # Build image via Terraform build module (no separate CI build step)

# Cloud Run Configuration
cloud_run_cpu = "2"
cloud_run_memory = "2Gi"
cloud_run_min_instances = 2
cloud_run_max_instances = 50
cloud_run_request_timeout = 300
cloud_run_allow_unauthenticated = true
# Prod still has `enable_cdn = false` — the LB stack lands separately.
# Until then, override the secure default so the *.run.app URL stays reachable.
# When `enable_cdn` flips to `true`, drop this override (or set
# `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` explicitly) to lock direct access
# out and force all traffic through Cloud Armor's WAF.
cloud_run_cpu = "2"
cloud_run_memory = "2Gi"
cloud_run_min_instances = 2
cloud_run_max_instances = 50
cloud_run_request_timeout = 300
# Prod: enable_cdn = false — LB + Cloud Armor stack not yet provisioned.
# Override the secure ingress default to keep the *.run.app URL reachable
# until DNS + cert + LB + Cloud Armor land. When enable_cdn flips to true,
# drop this line so all traffic routes through Cloud Armor's WAF.
# See issues #78 + #384.
# (allow_unauthenticated is no longer an operator-facing tfvar — it is derived
# from enable_cdn in compute.tf so the IAM gate and ingress door flip in lock-
# step with the LB stack landing.)
cloud_run_ingress = "INGRESS_TRAFFIC_ALL"

# ==============================================
Expand Down
23 changes: 13 additions & 10 deletions terraform/environments/gcp/github-staging.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ compute_platform = "cloud-run"
enable_docker_build = true # Build image via Terraform build module (no separate CI build step)

# Cloud Run Configuration
cloud_run_cpu = "1"
cloud_run_memory = "1Gi"
cloud_run_min_instances = 1
cloud_run_max_instances = 10
cloud_run_request_timeout = 300
cloud_run_allow_unauthenticated = true
# Staging keeps `enable_cdn = false` for now — the LB stack lands separately.
# Until then, override the secure default so the *.run.app URL stays reachable.
# When `enable_cdn` flips to `true`, drop this override (or set
# `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` explicitly) to lock direct access out.
cloud_run_cpu = "1"
cloud_run_memory = "1Gi"
cloud_run_min_instances = 1
cloud_run_max_instances = 10
cloud_run_request_timeout = 300
# Staging: enable_cdn = false — LB stack not yet provisioned.
# Override the secure ingress default so the *.run.app URL stays reachable
# until DNS + cert + LB are in place. When enable_cdn flips to true, drop
# this line (INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER takes effect from the
# variable default). See issues #78 + #384.
# (allow_unauthenticated is no longer an operator-facing tfvar — it is derived
# from enable_cdn in compute.tf so the IAM gate and ingress door flip in lock-
# step with the LB stack landing.)
cloud_run_ingress = "INGRESS_TRAFFIC_ALL"

# ==============================================
Expand Down
41 changes: 15 additions & 26 deletions terraform/environments/gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -226,28 +226,16 @@ variable "cloud_run_request_timeout" {
default = 300
}

variable "cloud_run_allow_unauthenticated" {
description = <<-EOT
Whether to expose the Cloud Run service publicly without IAM-level
authentication. The default is `false` (least-privilege): only callers
with the `roles/run.invoker` IAM binding can hit the URL. Application-
layer auth (sessions, RBAC, OIDC federation) still runs on top.

Set to `true` to allow direct browser access on the *.run.app URL —
useful for dev/preview environments where the only consumer is the
bundled frontend hitting the Cloud Run URL directly without an HTTPS
load balancer in front.

For production, prefer `false` plus the external HTTPS load balancer
with Cloud Armor in front (see `cloud_run_ingress` and `enable_cdn`).
The two defences are complementary: `allow_unauthenticated = false`
closes the IAM door, `cloud_run_ingress = "..._INTERNAL_LOAD_BALANCER"`
closes the network door so a misconfigured `roles/run.invoker` binding
on `allUsers` can't blow it open.
EOT
type = bool
default = false
}
# Cloud Run allow_unauthenticated is derived from var.enable_cdn (see
# local.cloud_run_allow_unauthenticated in compute.tf). When enable_cdn = true
# the *.run.app URL is fronted by the external HTTPS LB + Cloud Armor, which
# attaches a Google-signed identity to upstream calls, so the IAM gate locks
# allUsers out (roles/run.invoker restricted to the LB SA). When enable_cdn =
# false the SPA / clients hit *.run.app directly and the browser cannot present
# a signed identity, so allow_unauthenticated stays true and the application
# layer (login session, CSRF, OIDC for scheduled tasks) is the gate. The
# `cloud_run_ingress` knob is the complementary network-layer defence — see
# `cloud_run_ingress` below and #78 for its lifecycle.

variable "cloud_run_ingress" {
description = <<-EOT
Expand All @@ -268,10 +256,11 @@ variable "cloud_run_ingress" {
the service.

The default is `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` (defence-in-
depth) — pair it with `allow_unauthenticated = false` so neither the
network nor the IAM door is open by accident. Environments that
don't yet provision the LB (`enable_cdn = false`) MUST override to
`INGRESS_TRAFFIC_ALL` or the service becomes unreachable.
depth) — pairs with `allow_unauthenticated = false` (also derived from
`enable_cdn`) so neither the network nor the IAM door is open by
accident. Environments that don't yet provision the LB (`enable_cdn
= false`) MUST override to `INGRESS_TRAFFIC_ALL` or the service
becomes unreachable.
EOT
type = string
default = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
Expand Down
6 changes: 3 additions & 3 deletions terraform/modules/compute/gcp/cloud-run/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,9 @@ resource "google_cloud_scheduler_job" "recommendations" {

# Auth: oidc_token below is signed by the scheduler's service
# account at invocation time. Two complementary defences:
# 1. Cloud Run's IAM gate via roles/run.invoker (when
# cloud_run_allow_unauthenticated = false; tracked separately
# in #78).
# 1. Cloud Run's IAM gate via roles/run.invoker (active when
# allow_unauthenticated = false; in the GCP env layer this
# is derived from enable_cdn — see #384).
# 2. App-level OIDC validation on /api/scheduled/* — the Go
# validator (internal/server/scheduledauth) checks the JWT
# signature, issuer, audience, and pins the subject to this
Expand Down
Loading