diff --git a/terraform/environments/gcp/README.md b/terraform/environments/gcp/README.md index 99d7822c9..df53a662a 100644 --- a/terraform/environments/gcp/README.md +++ b/terraform/environments/gcp/README.md @@ -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 diff --git a/terraform/environments/gcp/compute.tf b/terraform/environments/gcp/compute.tf index 7026586fd..743144d3c 100644 --- a/terraform/environments/gcp/compute.tf +++ b/terraform/environments/gcp/compute.tf @@ -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 @@ -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 @@ -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 diff --git a/terraform/environments/gcp/dev.tfvars.example b/terraform/environments/gcp/dev.tfvars.example index 508a0b89c..b00476c5d 100644 --- a/terraform/environments/gcp/dev.tfvars.example +++ b/terraform/environments/gcp/dev.tfvars.example @@ -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") diff --git a/terraform/environments/gcp/github-dev.tfvars b/terraform/environments/gcp/github-dev.tfvars index 5c8564034..d5f198aa9 100644 --- a/terraform/environments/gcp/github-dev.tfvars +++ b/terraform/environments/gcp/github-dev.tfvars @@ -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" # ============================================== diff --git a/terraform/environments/gcp/github-prod.tfvars b/terraform/environments/gcp/github-prod.tfvars index 81351a9a0..df04cbec0 100644 --- a/terraform/environments/gcp/github-prod.tfvars +++ b/terraform/environments/gcp/github-prod.tfvars @@ -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" # ============================================== diff --git a/terraform/environments/gcp/github-staging.tfvars b/terraform/environments/gcp/github-staging.tfvars index 3c6252b85..4d07137e7 100644 --- a/terraform/environments/gcp/github-staging.tfvars +++ b/terraform/environments/gcp/github-staging.tfvars @@ -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" # ============================================== diff --git a/terraform/environments/gcp/variables.tf b/terraform/environments/gcp/variables.tf index a8021df23..32ce42316 100644 --- a/terraform/environments/gcp/variables.tf +++ b/terraform/environments/gcp/variables.tf @@ -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 @@ -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" diff --git a/terraform/modules/compute/gcp/cloud-run/main.tf b/terraform/modules/compute/gcp/cloud-run/main.tf index 686d1404e..7f03c1360 100644 --- a/terraform/modules/compute/gcp/cloud-run/main.tf +++ b/terraform/modules/compute/gcp/cloud-run/main.tf @@ -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