Skip to content

fix(security/gcp): Cloud Scheduler + GKE jobs still embed plaintext SCHEDULED_TASK_SECRET in Authorization header #159

@cristim

Description

@cristim

Summary

Discovered while verifying #50 (Azure Logic App plaintext secret). The Azure side has been fixed via per-workflow user-assigned managed identities + a get-secret first-action (see terraform/modules/compute/azure/container-apps/scheduled-tasks.tf:4-15 for the security model and lines 198/303/398 for the runtime-resolved Authorization = "Bearer @{body('get-secret')['value']}" headers).

The GCP equivalent has the same plaintext-interpolation anti-pattern that #50 flagged on Azure:

  • terraform/modules/compute/gcp/cloud-run/main.tf:273 — Cloud Run service-to-Cloud-Run scheduler call.
  • terraform/modules/compute/gcp/cloud-run/main.tf:326 — second call site, same shape.
  • terraform/modules/compute/gcp/gke/main.tf:676 — GKE-flavoured equivalent.

Each of those reads:

"Authorization" = "Bearer ${var.scheduled_task_secret}"

The plaintext therefore lives in:

  • The Cloud Scheduler / Cloud Run job definition stored in GCP.
  • Terraform state (any var.* value flowing into a resource attribute is captured in state).
  • terraform plan / terraform show output captured in CI logs and operator screens.

Expected behaviour

Match the Azure model: the Cloud Scheduler / Cloud Run resource pulls the secret from GCP Secret Manager at runtime so the plaintext never lands in the resource definition or Terraform state.

Proposed fix

Two GCP-native paths, pick whichever is simpler:

  1. Cloud Scheduler with OIDC + Secret Manager: have Cloud Scheduler authenticate to Cloud Run via OIDC (already supported by the SDK) and drop the bespoke Bearer header entirely. The receiving service validates the OIDC token instead of the shared secret. Eliminates the secret outright.
  2. Secret Manager API call from the scheduler body: keep the Bearer auth scheme but have the scheduler/job fetch the secret at runtime (via Secret Manager REST + the runtime SA's identity), analogous to Azure's get-secret action.

Path 1 is the cleaner endpoint — modern GCP best practice for service-to-service auth. Path 2 mirrors the Azure migration and may be cheaper to implement if other callers still rely on the shared-secret scheme.

Steps to reproduce

cd terraform/environments/gcp
terraform plan -out tfplan
terraform show -json tfplan | jq '.. | select(.values.headers? != null) | .values.headers'
# Observe the Bearer token's plaintext value in the rendered headers map.

Or post-apply: gcloud scheduler jobs describe <job> --format=yaml returns the job definition with the plaintext header value.

References

Severity

Medium — same shared-secret as the Azure-side and Container App caller, exposed via a different surface. Both other surfaces are now closed; GCP is the remaining gap.

Effort

Medium — Cloud Scheduler + Secret Manager wiring. Larger if path 1 (OIDC migration) is chosen because the receiving service needs to verify OIDC tokens instead of the shared secret; smaller if path 2 (mirror Azure) is chosen because the Cloud Run side stays unchanged.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions