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
20 changes: 20 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
version: 2
updates:
# Keep pinned GitHub Actions (SHA-pinned in .github/workflows) up to date.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "github-actions"

# Keep the Terraform provider constraints (required_providers in main.tf) and
# any pinned module sources up to date.
- package-ecosystem: "terraform"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "terraform"
6 changes: 6 additions & 0 deletions .github/workflows/terraform-apply.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ jobs:
build:
name: tf apply
runs-on: ubuntu-latest
# Gate privileged apply behind a protected GitHub Environment. Configure
# "production" in repo Settings > Environments with required reviewers and a
# deployment branch rule limiting it to `main`, so a human approves before
# the privileged service account is used.
environment: production
permissions:
contents: read
id-token: write
Expand All @@ -22,6 +27,7 @@ jobs:
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193
with:
workload_identity_provider: projects/546928617664/locations/global/workloadIdentityPools/gha-terraform-checker-pool/providers/gha-terraform-checker-provider
# Privileged identity, scoped via workload identity to refs/heads/main.
service_account: gha-cloud-functions-deployment@jeffreyhung-test.iam.gserviceaccount.com

- name: terraform apply
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/terraform-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ jobs:
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193
with:
workload_identity_provider: projects/546928617664/locations/global/workloadIdentityPools/gha-terraform-checker-pool/providers/gha-terraform-checker-provider
service_account: gha-cloud-functions-deployment@jeffreyhung-test.iam.gserviceaccount.com
# Read-only identity: plan runs against untrusted PR code and must not
# hold write or secret-read permissions.
service_account: gha-cf-tf-plan@jeffreyhung-test.iam.gserviceaccount.com

- name: terraform plan
id: terraform-plan
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,27 @@ Update the local variables in `terraform.tfvars` with your own GCP project and s
```
also update the `workload_identity_provider` and `service_account` in both the `.github/workflows/terraform-apply.yaml` and `.github/workflows/terraform-plan.yaml` file to match what you have in Terraform.

## Two deployment identities (plan vs apply)
This template provisions **two** service accounts instead of one, because `terraform plan` runs on pull requests and therefore executes attacker-controllable configuration (Terraform data sources and providers run during `plan`):

- **`gha-cf-tf-plan`** — read-only. It gets `roles/viewer` + `roles/iam.securityReviewer` (resource + IAM-policy reads), `roles/iam.workloadIdentityPoolViewer` (WIF reads), a custom role for `storage.buckets.get` (bucket metadata), object read/lock on the **state** bucket, and object read on the **staging** bucket only. Used by the `terraform plan` workflow on pull requests. It cannot write resources, cannot read secret values, and cannot read object contents of other buckets (e.g. pub/sub sinks), so a malicious PR cannot escalate or exfiltrate through it.
- **`gha-cloud-functions-deployment`** — privileged. Used by the `terraform apply` workflow. Its workload-identity binding is pinned to the GitHub **`production` environment subject** (`repo:<org>/<repo>:environment:production`), not merely to `refs/heads/main` — so the approval gate is enforced at the **GCP IAM layer**: only a job that declares `environment: production` can mint this token, and no other main-triggered workflow can. It holds **no** `secretmanager.secretAccessor` (secret *management* — create + set IAM, without reading values — is granted via a narrow custom role). It does hold project-wide `roles/iam.serviceAccountUser` so it can `actAs` the per-resource runtime SAs it deploys; this is the narrowest grant that supports autonomous function onboarding (see the NOTE in `infrastructure/permissions.tf` for why it can't be scoped further, and the more-locked-down alternative).

> **Bootstrap note:** Neither CI identity holds project-IAM-admin, so any apply that changes **project-level IAM** — including granting the plan SA its roles on first run — must be run by a principal with `roles/owner` or `roles/resourcemanager.projectIamAdmin`. Run the initial/permission-changing `apply` as such an admin, not as the plan or apply SA.

### Required: the `production` environment
The `terraform apply` workflow runs in the GitHub Environment named **`production`**. This is **required**, not optional: the apply SA's workload-identity binding only matches tokens whose subject is `repo:<org>/<repo>:environment:production`, which a job gets only by declaring `environment: production`. Create it under `Settings > Environments` and add:
- **Required reviewers** — so a human approves before the privileged service account is ever used.
- **Deployment branch rule** limiting the environment to `main`.

If the environment doesn't exist, GitHub auto-creates it on first run with no protection — apply still authenticates (the subject still includes `environment:production`), but without the human approval gate until you add reviewers. A workflow that does **not** declare `environment: production` cannot obtain the apply token at all.

### Initial Run
On the first run, you will have to manually create the GCS bucket in your GCP project to store the TF state, then import it
then with `terraform import google_storage_bucket.tf-state tf-state` after you run `terraform init` and `terraform plan`.

> The state bucket is created with `force_destroy = false` and `lifecycle { prevent_destroy = true }` so it cannot be deleted by accident. To intentionally tear it down you must first remove that lifecycle block.

Once the GCS bucket that stores terraform backend is created and imported, you can then run the following to setup all the required permissions and service accounts.

```bash
Expand All @@ -38,12 +55,14 @@ Once that's set, you can update this repo with the following steps to configure
- In `terraform.tfvars`, set the `deploy_sa_email` as the service account you created.
- Update `.github/workflows/terraform-plan.yaml` and `.github/workflows/terraform-apply.yaml` with your workload_identity_provider and service_account in the `gcp auth` step

> **Note on BYO mode and the plan/apply split:** When `deploy_sa_email` is set, this repo does **not** create the `gha-cf-tf-plan` / `gha-cloud-functions-deployment` accounts — you bring a single account. For the same least-privilege benefit, create a separate read-only account in security-as-code for the `terraform plan` workflow and a privileged one (scoped to `refs/heads/main`) for `terraform apply`, then point each workflow at the matching account.

# CI/CD (Continuous Integrations and Continuous Deployments)
We have GitHub Action workflows in place, running `terraform plan` on Pull Requests ([workflow](.github/workflows/terraform-plan.yaml)) and running `terraform apply` on merge to main ([workflow](.github/workflows/terraform-apply.yaml)).

When you created a Pull Request to main on this repository, `terraform plan` will run automatically and post the output of the plan in a comment to your Pull Request. You can inspect and review the output before merging your PRs.
When you created a Pull Request to main on this repository, `terraform plan` will run automatically and post the output of the plan in a comment to your Pull Request. You can inspect and review the output before merging your PRs. This runs as the **read-only** `gha-cf-tf-plan` identity.

Once merged, `terraform apply` will kick in and automatically apply changes to ensure your environment matches terraform state.
Once merged, `terraform apply` will kick in and apply changes to ensure your environment matches terraform state. It runs as the privileged `gha-cloud-functions-deployment` identity inside the protected `production` environment, so it waits for reviewer approval (once you've configured required reviewers) before any privileged action is taken.

# Secrets Management

Expand Down
59 changes: 43 additions & 16 deletions infrastructure/main.tf
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
resource "google_storage_bucket" "staging_bucket" {
name = "${var.project}-cloud-function-staging"
location = "US"
force_destroy = true
public_access_prevention = "enforced"
name = "${var.project}-cloud-function-staging"
location = "US"
force_destroy = true
public_access_prevention = "enforced"
uniform_bucket_level_access = true
labels = {
owner = var.owner
owner = var.owner
terraformed = "true"
}
}

resource "google_storage_bucket_iam_binding" "staging-bucket-iam" {
bucket = google_storage_bucket.tf-state.name
bucket = google_storage_bucket.staging_bucket.name
role = "roles/storage.objectUser"

members = ["serviceAccount:${var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email}"]
members = ["serviceAccount:${local.apply_sa_email}"]

depends_on = [
google_storage_bucket.staging_bucket
Expand All @@ -23,29 +24,55 @@ resource "google_storage_bucket_iam_binding" "staging-bucket-iam" {
resource "google_storage_bucket_iam_member" "staging_bucket_get" {
bucket = google_storage_bucket.staging_bucket.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email}"
member = "serviceAccount:${local.apply_sa_email}"
}

# Plan SA: object-content read (storage.objects.get) to refresh the function
# source zip objects, scoped to the staging bucket ONLY. roles/viewer grants no
# object reads, and this is deliberately not project-wide so the read-only plan
# identity cannot read other buckets' object contents (e.g. pub/sub sink data).
resource "google_storage_bucket_iam_member" "staging_bucket_plan_object_read" {
count = var.deploy_sa_email != null ? 0 : 1
bucket = google_storage_bucket.staging_bucket.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.gha_tf_plan[0].email}"
}

resource "google_storage_bucket" "tf-state" {
name = "${var.project}-tfstate"
force_destroy = true
location = "US"
storage_class = "STANDARD"
public_access_prevention = "enforced"
name = "${var.project}-tfstate"
force_destroy = false
location = "US"
storage_class = "STANDARD"
public_access_prevention = "enforced"
uniform_bucket_level_access = true
versioning {
enabled = true
}
labels = {
owner = var.owner
owner = var.owner
terraformed = "true"
}

# The state bucket is the source of truth for managing this project. Guard
# against accidental deletion (e.g. a stray `terraform destroy`).
lifecycle {
prevent_destroy = true
}
}

resource "google_storage_bucket_iam_binding" "tfstate-bucket-iam" {
bucket = google_storage_bucket.tf-state.name
role = "roles/storage.objectUser"

members = ["serviceAccount:${var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email}"]
# Apply SA always; plan SA also needs object read + lock-object write to run
# `terraform plan` against the GCS backend. (This binding is authoritative for
# the role, so both members must be listed here.)
members = var.deploy_sa_email != null ? [
"serviceAccount:${local.apply_sa_email}",
] : [
"serviceAccount:${local.apply_sa_email}",
"serviceAccount:${google_service_account.gha_tf_plan[0].email}",
]

depends_on = [
google_storage_bucket.tf-state
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Expand All @@ -55,5 +82,5 @@ resource "google_storage_bucket_iam_binding" "tfstate-bucket-iam" {
resource "google_storage_bucket_iam_member" "tfstate_bucket_get" {
bucket = google_storage_bucket.tf-state.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email}"
member = "serviceAccount:${local.apply_sa_email}"
}
8 changes: 7 additions & 1 deletion infrastructure/outputs.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
output "deploy_sa_email" {
value = var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email
description = "Privileged service account used by `terraform apply`."
value = local.apply_sa_email
}

output "plan_sa_email" {
description = "Read-only service account used by `terraform plan` (null in BYO mode)."
value = var.deploy_sa_email != null ? null : google_service_account.gha_tf_plan[0].email
}
99 changes: 94 additions & 5 deletions infrastructure/permissions.tf
Original file line number Diff line number Diff line change
@@ -1,22 +1,111 @@
# Project-wide roles
# Project-wide roles for the privileged "apply" service account.
locals {
roles = [
"roles/viewer", # general read-only access to most Google Cloud resources
"roles/iam.securityReviewer", # READ-ONLY *.getIamPolicy across services, so plan/apply can refresh IAM resources
"roles/storage.admin", # full access to manage GCS buckets and objects
"roles/secretmanager.secretAccessor", # access to Secret Manager
"roles/cloudfunctions.developer", # deploy and manage Cloud Functions
"roles/logging.viewer", # view logs
"roles/iam.serviceAccountUser", # necessary to invoke Cloud Functions
"roles/iam.workloadIdentityPoolViewer", # view workload identity pool
"roles/iam.serviceAccountCreator", # create service accounts
"roles/iam.serviceAccountCreator", # create the per-resource runtime service accounts
"roles/iam.serviceAccountUser", # actAs runtime SAs in order to deploy functions/workflows/cron as them
"roles/pubsub.admin", # full access to Pub/Sub
# NOTE on roles/iam.serviceAccountUser: this is a project-wide actAs (impersonation)
# primitive. It is required because the template autonomously creates dedicated
# runtime SAs and must actAs them to deploy. It CANNOT be scoped to just those SAs:
# creating a per-SA actAs binding on a freshly-created SA itself requires
# iam.serviceAccounts.setIamPolicy on that SA (circular), and IAM Conditions are not
# supported for service-account resources. actAs is deliberately chosen over a custom
# role with iam.serviceAccounts.setIamPolicy because setIamPolicy is strictly broader
# (it can grant ANY principal actAs on ANY SA and rewrite policies). Residual risk is
# bounded by: apply only runs on refs/heads/main behind the `production` approval gate,
# is never exposed to untrusted PR code (that path uses the read-only plan SA), and the
# template assumes a dedicated project. For maximum lockdown, drop this role and create
# runtime-SA actAs bindings via a privileged bootstrap apply instead (loses CI self-service).
# NOTE: roles/secretmanager.secretAccessor is intentionally NOT granted. Deploying/binding
# secrets does not require reading their values; the custom role below grants
# create + setIamPolicy on secrets without value access.
]
}

resource "google_project_iam_member" "project_roles" {
for_each = toset(local.roles)
project = var.project
role = each.value
member = "serviceAccount:${var.deploy_sa_email != null ? var.deploy_sa_email : google_service_account.gha_cloud_functions_deployment[0].email}"
member = "serviceAccount:${local.apply_sa_email}"
}

# Custom role letting the apply SA create and manage Secret Manager secrets and
# their IAM bindings WITHOUT the ability to read secret payloads. This replaces
# the previous project-wide roles/secretmanager.secretAccessor grant so that a
# compromised CI run cannot exfiltrate secret values.
resource "google_project_iam_custom_role" "tf_secret_manager" {
role_id = "cfTemplateSecretManager"
title = "CF Template Secret Manager (no value access)"
description = "Create and manage Secret Manager secrets and their IAM bindings without reading secret values"
permissions = [
"secretmanager.secrets.create",
"secretmanager.secrets.delete",
"secretmanager.secrets.get",
"secretmanager.secrets.list",
"secretmanager.secrets.update",
"secretmanager.secrets.getIamPolicy",

Check notice on line 52 in infrastructure/permissions.tf

View check run for this annotation

@sentry/warden / warden: security-review

Apply SA's custom Secret Manager role allows self-granting secret-value read via setIamPolicy

The `cfTemplateSecretManager` custom role claims to manage secrets "WITHOUT the ability to read secret payloads," but `secretmanager.secrets.setIamPolicy` lets the apply SA grant itself `roles/secretmanager.secretAccessor` on any managed secret and read its value, weakening the PR's stated goal that a compromised CI run cannot exfiltrate secrets. Note this permission is functionally needed for the template's own secret IAM bindings, so the residual risk should be documented/accepted rather than simply removed.
"secretmanager.secrets.setIamPolicy",
]
}

resource "google_project_iam_member" "apply_secret_manager" {
project = var.project
role = google_project_iam_custom_role.tf_secret_manager.name
member = "serviceAccount:${local.apply_sa_email}"
}

Comment thread
sentry-warden[bot] marked this conversation as resolved.
# Read-only project access for the plan SA. roles/viewer covers resource reads
# (gets/lists) and roles/iam.securityReviewer covers *.getIamPolicy so that
# `terraform plan` can refresh IAM resources. Neither grants
# secretmanager.versions.access, so the plan identity cannot read secret values.
# (State-bucket read + lock-object write is granted on the bucket in main.tf.)
resource "google_project_iam_member" "plan_viewer" {
count = var.deploy_sa_email != null ? 0 : 1
project = var.project
role = "roles/viewer"
member = "serviceAccount:${google_service_account.gha_tf_plan[0].email}"
}

resource "google_project_iam_member" "plan_security_reviewer" {
count = var.deploy_sa_email != null ? 0 : 1
project = var.project
role = "roles/iam.securityReviewer"
member = "serviceAccount:${google_service_account.gha_tf_plan[0].email}"
}

# Plan SA needs read on the workload identity pool/provider resources to refresh
# them (securityReviewer only grants list + getIamPolicy, not get).
resource "google_project_iam_member" "plan_wif_viewer" {
count = var.deploy_sa_email != null ? 0 : 1
project = var.project
role = "roles/iam.workloadIdentityPoolViewer"
member = "serviceAccount:${google_service_account.gha_tf_plan[0].email}"
}

# roles/viewer does not include storage.buckets.get, which plan needs to refresh
# every bucket resource. Grant ONLY bucket-metadata get project-wide (no object
# listing or content). Object-content read is granted separately and scoped to
# the staging bucket (see main.tf), so the plan identity cannot read the contents
# of other buckets (e.g. pub/sub sink data) even though it is exposed to PR code.
resource "google_project_iam_custom_role" "tf_plan_bucket_reader" {
count = var.deploy_sa_email != null ? 0 : 1
role_id = "cfTemplatePlanBucketReader"
title = "CF Template Plan Bucket Metadata Reader"
description = "Read bucket metadata (storage.buckets.get) for terraform plan refresh, no object access"
permissions = [
"storage.buckets.get",
]
}

resource "google_project_iam_member" "plan_bucket_reader" {
count = var.deploy_sa_email != null ? 0 : 1
project = var.project
role = google_project_iam_custom_role.tf_plan_bucket_reader[0].name
member = "serviceAccount:${google_service_account.gha_tf_plan[0].email}"
}
Loading
Loading