From 5a9b52d54acdb097a30f2ad13d11c898d1105532 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 20 May 2026 18:48:01 +0200 Subject: [PATCH 1/2] security(iac/gcp): set allow_unauthenticated=false; document ingress override (closes #384, #78) #384: cloud_run_allow_unauthenticated was incorrectly set to true in all three environment tfvars, overriding the variable default of false. Cloud Run's built-in IAM auth is a defence-in-depth layer independent of our application-level JWT auth; it was unintentionally disabled. Set it to false in dev, staging, and prod. #78: cloud_run_ingress is temporarily set to INGRESS_TRAFFIC_ALL in all environments because the external HTTPS LB + Cloud Armor stack (enabled by enable_cdn=true) has not yet been provisioned. The variable default is INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER, which would block *.run.app traffic until DNS + cert + LB are in place. Update the inline comments to cross-reference both issues and explain the removal condition. --- terraform/environments/gcp/github-dev.tfvars | 9 ++++++--- terraform/environments/gcp/github-prod.tfvars | 12 ++++++------ terraform/environments/gcp/github-staging.tfvars | 11 ++++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/terraform/environments/gcp/github-dev.tfvars b/terraform/environments/gcp/github-dev.tfvars index 5c8564034..0cea8da2a 100644 --- a/terraform/environments/gcp/github-dev.tfvars +++ b/terraform/environments/gcp/github-dev.tfvars @@ -23,9 +23,12 @@ 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_allow_unauthenticated = false +# 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. cloud_run_ingress = "INGRESS_TRAFFIC_ALL" # ============================================== diff --git a/terraform/environments/gcp/github-prod.tfvars b/terraform/environments/gcp/github-prod.tfvars index 81351a9a0..73e89e68e 100644 --- a/terraform/environments/gcp/github-prod.tfvars +++ b/terraform/environments/gcp/github-prod.tfvars @@ -23,12 +23,12 @@ 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_allow_unauthenticated = false +# 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. cloud_run_ingress = "INGRESS_TRAFFIC_ALL" # ============================================== diff --git a/terraform/environments/gcp/github-staging.tfvars b/terraform/environments/gcp/github-staging.tfvars index 3c6252b85..aace51d5d 100644 --- a/terraform/environments/gcp/github-staging.tfvars +++ b/terraform/environments/gcp/github-staging.tfvars @@ -23,11 +23,12 @@ 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_allow_unauthenticated = false +# 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. cloud_run_ingress = "INGRESS_TRAFFIC_ALL" # ============================================== From 54cf45b69168b209291d2887c9f5b5231d3a4e7a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 00:13:51 +0200 Subject: [PATCH 2/2] security(iac/gcp): derive cloud_run_allow_unauthenticated from enable_cdn (mirrors PR #574 AWS pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the unconditional `cloud_run_allow_unauthenticated = false` override with a derived `local.cloud_run_allow_unauthenticated = !var.enable_cdn` in `terraform/environments/gcp/compute.tf`. The IAM gate now flips in lock-step with the LB stack landing, matching the parallel pattern shipped in #574 for AWS Lambda Function URL auth: AWS (#574): local.lambda_function_url_auth_type = var.enable_cdn ? "AWS_IAM" : "NONE" GCP (this): local.cloud_run_allow_unauthenticated = !var.enable_cdn Why the conditional rather than always-false: * enable_cdn = false (today's dev/staging/prod): the SPA / clients hit the *.run.app URL directly. Browsers cannot present a Google-signed identity token, so the IAM gate must accept allUsers (allow_unauthenticated = true); auth is enforced at the application layer (login session, CSRF, OIDC for scheduled tasks). The previous unconditional `= false` would have made the *.run.app URL return 403 to direct browser hits — exactly the GCP analog of PR #574's mis-configuration (b). * enable_cdn = true (post-LB-cutover): the external HTTPS LB (with Cloud Armor) fronts the service and attaches a Google-signed identity to upstream calls, so Cloud Run can enforce `roles/run.invoker` on the LB SA only and lock allUsers out at the IAM layer (closes #384). The pairing also blocks mis-configuration (a) — `enable_cdn = true` with allow_unauthenticated = true would put a public *.run.app URL behind a pointless LB. The AWS analog of CloudFront's OAC + SigV4 signing is the GCP LB SA's identity token. The two cloud sides now share the same "auth gate is derived from the fronting-LB toggle" shape. Changes: * terraform/environments/gcp/compute.tf: add `locals { cloud_run_allow_ unauthenticated = !var.enable_cdn }` with a comment block mirroring #574's documentation of the two valid combinations + the two mis-configurations it prevents. Wire `module.compute_cloud_run.allow_unauthenticated` to the local. Update the scheduled-tasks comment block (#159) to point at the new derivation. * terraform/environments/gcp/variables.tf: drop the operator-facing `cloud_run_allow_unauthenticated` variable declaration, replace with a comment block that explains the derivation (parallel to #574's treatment of `lambda_function_url_auth_type`). Update `cloud_run_ingress` description's cross-reference accordingly. * terraform/environments/gcp/{github-dev,github-staging,github-prod, dev.tfvars.example}.tfvars: remove the `cloud_run_allow_unauthenticated` line from all 4 tfvars files; add a short comment near the existing ingress override noting the derivation. * terraform/environments/gcp/README.md: update the "Cloud Run ingress" table — `allow_unauthenticated` row now describes it as derived from `enable_cdn`, not a directly-set variable; clarify that when an env flips `enable_cdn = true` the IAM gate auto-closes alongside dropping the `cloud_run_ingress` override. * terraform/modules/compute/gcp/cloud-run/main.tf: refresh the inline comment on the scheduled-task http_target — the IAM gate now references #384 (the derivation) rather than #78 (the network gate). The `cloud_run_ingress = "INGRESS_TRAFFIC_ALL"` override in the 3 env tfvars stays exactly as it was — that's a sibling concern (network door) tracked by #78 and gated on the same LB cutover, but kept as an explicit override per the existing comment so the network-vs-IAM doors are managed in lockstep but visibly separately. Verified: terraform fmt -check clean on both modules/compute/gcp and environments/gcp; terraform validate clean on both layers. Refs #384, #78, #574 --- terraform/environments/gcp/README.md | 10 ++--- terraform/environments/gcp/compute.tf | 36 ++++++++++++++-- terraform/environments/gcp/dev.tfvars.example | 6 ++- terraform/environments/gcp/github-dev.tfvars | 14 ++++--- terraform/environments/gcp/github-prod.tfvars | 14 ++++--- .../environments/gcp/github-staging.tfvars | 14 ++++--- terraform/environments/gcp/variables.tf | 41 +++++++------------ .../modules/compute/gcp/cloud-run/main.tf | 6 +-- 8 files changed, 84 insertions(+), 57 deletions(-) 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 0cea8da2a..d5f198aa9 100644 --- a/terraform/environments/gcp/github-dev.tfvars +++ b/terraform/environments/gcp/github-dev.tfvars @@ -18,17 +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 = false +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 73e89e68e..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 = false +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 aace51d5d..4d07137e7 100644 --- a/terraform/environments/gcp/github-staging.tfvars +++ b/terraform/environments/gcp/github-staging.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 = "1" -cloud_run_memory = "1Gi" -cloud_run_min_instances = 1 -cloud_run_max_instances = 10 -cloud_run_request_timeout = 300 -cloud_run_allow_unauthenticated = false +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