Security hardening: least-privilege IAM, plan/apply split, bucket protections#20
Merged
Merged
Conversation
…tections Implements the agreed findings from the security review: - Split CI identities (#1): new read-only `gha-cf-tf-plan` SA for PR plans (viewer + state read/lock only) vs. privileged `gha-cloud-functions-deployment` for apply. Workload-identity binding pins the apply SA to refs/heads/main and maps google.subject to assertion.sub. Apply job gated behind the `production` GitHub Environment for human approval. - Drop project-wide roles/iam.serviceAccountUser from the deploy SA (#3); per-SA actAs is already granted in each module. - Remove project-wide roles/secretmanager.secretAccessor (#4); replace with a narrow custom role that can create/manage secrets and their IAM bindings WITHOUT reading secret values. - Protect the Terraform state bucket: force_destroy=false + prevent_destroy (#5). - Fix staging-bucket IAM binding that wrongly targeted the state bucket (#6). - Remove unnecessary project-wide monitoring.viewer from pub/sub SAs (part of #8). - Remove self-actAs bindings on function/workflow/eventarc SAs and a duplicate deploy-SA actAs in the workflow module (#9). - Enforce uniform_bucket_level_access on all buckets. - Add Dependabot config for github-actions, terraform, and pip. - Add required_version (>= 1.5.0); rename varables.tf -> variables.tf. Note: scoping the two remaining roles/workflows.invoker grants is not possible in Terraform today — the hashicorp/google provider has no google_workflows_workflow_iam_* resource in any version (v6 or v7) and Workflows does not support resource.name IAM Conditions. Documented in-code; revisit if the provider adds resource-level Workflows IAM. Validated with `terraform validate` and `terraform fmt` against the v6.20 provider schema. A real `terraform plan` against the project is still required before apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Terraform plan in . Plan: 1 to add, 1 to change, 1 to destroy.Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
!~ update in-place
-/+ destroy and then create replacement
Terraform will perform the following actions:
# module.functions.module.cloud_function_gen2["example-gen2-cron"].google_cloudfunctions2_function.function will be updated in-place
!~ resource "google_cloudfunctions2_function" "function" {
id = "projects/jeffreyhung-test/locations/us-west1/functions/example-gen2-cron"
name = "example-gen2-cron"
# (11 unchanged attributes hidden)
!~ build_config {
# (7 unchanged attributes hidden)
!~ source {
!~ storage_source {
!~ object = "src-90ebbf1bfdc8d1b0c1deff70132cdc47.zip" -> "src-03bdfabf8c7adb2ef887fe89f69a0dbe.zip"
# (2 unchanged attributes hidden)
}
}
}
# (1 unchanged block hidden)
}
# module.functions.module.cloud_function_gen2["example-gen2-cron"].google_storage_bucket_object.zip must be replaced
-/+ resource "google_storage_bucket_object" "zip" {
+ content = (sensitive value)
!~ crc32c = "N2/bfA==" -> (known after apply)
!~ detect_md5hash = "kOu/G/3I0bDB3v9wEyzcRw==" -> "different hash"
- event_based_hold = false -> null
!~ generation = 1782338383704324 -> (known after apply)
!~ id = "********************************************************************************" -> (known after apply)
+ kms_key_name = (known after apply)
!~ md5hash = "kOu/G/3I0bDB3v9wEyzcRw==" -> (known after apply)
!~ media_link = "https://storage.googleapis.com/download/storage/v1/b/jeffreyhung-test-cloud-function-staging/o/src-90ebbf1bfdc8d1b0c1deff70132cdc47.zip?generation=1782338383704324&alt=media" -> (known after apply)
!~ name = "src-90ebbf1bfdc8d1b0c1deff70132cdc47.zip" -> "src-03bdfabf8c7adb2ef887fe89f69a0dbe.zip" # forces replacement
!~ output_name = "src-90ebbf1bfdc8d1b0c1deff70132cdc47.zip" -> (known after apply)
!~ self_link = "https://www.googleapis.com/storage/v1/b/jeffreyhung-test-cloud-function-staging/o/src-90ebbf1bfdc8d1b0c1deff70132cdc47.zip" -> (known after apply)
!~ storage_class = "STANDARD" -> (known after apply)
- temporary_hold = false -> null
# (8 unchanged attributes hidden)
}
Plan: 1 to add, 1 to change, 1 to destroy.✅ Plan applied in Terraform Apply #47 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 232ffa2. Configure here.
Addresses the failing `terraform plan` and the code-review finding on the follow-up "fix perms" commits: - Plan SA could not refresh bucket/SA/secret IAM (storage.buckets.getIamPolicy denied). roles/storage.legacyBucketReader does NOT grant getIamPolicy, so the previous fix was ineffective. Replace it with project-level roles/iam.securityReviewer (read-only *.getIamPolicy across services) on the plan SA, and add the same to the apply SA so apply can refresh IAM resources too. Removes the legacyBucketReader per-bucket resources. - Remove the cfTemplateServiceAccountIam custom role that granted project-wide iam.serviceAccounts.setIamPolicy. As flagged in review, that reintroduces (and broadens) the privilege-escalation primitive the PR set out to remove — it lets the apply SA grant any principal actAs on any SA and rewrite SA policies. - Instead, grant the apply SA project-level roles/iam.serviceAccountUser (actAs only). This is required for autonomous deployment with dedicated runtime SAs and is strictly narrower than setIamPolicy. Per-SA actAs scoping is not feasible: creating a binding on a freshly-created SA itself needs setIamPolicy on it (circular), and IAM Conditions don't apply to SA resources. Remove the now-redundant per-SA deploy_sa_actas_iam bindings from all four resource modules (function, workflow, eventarc, cron); the project grant covers them. - README: correct the identity descriptions (securityReviewer, not legacyBucketReader; apply SA does hold serviceAccountUser) and document that project-IAM-changing applies must be run by a project IAM admin. Validated with `terraform validate` + `fmt`. Project-level IAM grants (incl. the plan SA's roles) must be applied by an owner/projectIamAdmin on first run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auditing the full set of get/getIamPolicy permissions each managed resource type needs against roles/viewer + roles/iam.securityReviewer revealed three remaining gaps for the plan SA (cause of the storage.objects.get 403): - storage.objects.get (function source zips) — roles/viewer grants no object reads. Granted via roles/storage.objectViewer scoped to the STAGING bucket only, so the plan identity still cannot read other buckets' object contents (e.g. pub/sub sink data). - storage.buckets.get (every bucket resource) — not in viewer/securityReviewer. Granted via a minimal custom role (bucket metadata only, no object listing or content) project-wide. - iam.workloadIdentityPools.get / providers.get — securityReviewer only grants list + getIamPolicy. Granted via roles/iam.workloadIdentityPoolViewer. Verified the union of the plan SA's roles now covers every get/getIamPolicy needed across storage, IAM, SAs, custom roles, WIF, functions, run, secrets, pub/sub, scheduler, workflows, and eventarc. These are project/bucket IAM grants, so an owner/projectIamAdmin must apply them before the plan SA can run plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously the privileged apply SA was bound to principalSet attribute.ref == refs/heads/main, so the `production` approval gate was enforced only inside terraform-apply.yaml. Any other main-triggered workflow with id-token: write could have minted the privileged token without human approval. Bind it instead to the exact GitHub subject `principal://.../subject/repo:<org>/<repo>:environment:production`. A token only carries that subject when its job declares `environment: production`, so the approval gate is now enforced at the GCP IAM layer, not just at the workflow level. The repo is still constrained by the provider attribute_condition and the environment's deployment-branch rule constrains it to main. This makes the `production` environment required for apply auth (documented in README); google.subject is already mapped to assertion.sub so the binding matches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Implements the agreed findings from the in-depth security review.
Changes
gha-cf-tf-planSA (viewer + state read/lock only) for PRterraform plan; privilegedgha-cloud-functions-deploymentreserved forapply. Workload-identity binding pins the apply SA torefs/heads/main;google.subjectmapped toassertion.sub. Apply job gated behind theproductionGitHub Environment.roles/iam.serviceAccountUserfrom the deploy SA (per-SAactAsalready granted in each module).roles/secretmanager.secretAccessor; replaced with a narrow custom role that creates/manages secrets + their IAM bindings without reading secret values.force_destroy = false+lifecycle { prevent_destroy = true }.monitoring.viewerfrom pub/sub SAs.actAsbindings on function/workflow/eventarc SAs and a duplicate deploy-SAactAsin the workflow module.uniform_bucket_level_access = trueon all buckets.github-actions,terraform, andpip.required_version >= 1.5.0; renamedvarables.tf→variables.tf.README updated for the two-identity model, the
productionenvironment requirement, and state-bucket protection.Not done: scoping the
workflows.invokergrantsThe remaining part of #8 is not achievable in Terraform today: the
hashicorp/googleprovider exposes nogoogle_workflows_workflow_iam_*resource in any version (verified v6 and v7.38), and Cloud Workflows does not supportresource.nameIAM Conditions. The grants stay project-wide with accurate in-code NOTEs. Residual risk is low — the role only permits executing workflows and is held by dedicated single-purpose SAs.terraform validate+fmtagainst the v6.20 provider schema). Run a realterraform planagainst the project to review the IAM diff before applying.gha-cf-tf-plandoesn't exist yet. Create it via an admin-runapplyfirst (per the README initial-run flow), or merge despite the failing check so the apply creates it. Later PRs will plan cleanly. The merge-to-main apply keeps working — the oldattribute.repositorybinding still coversmainuntil this tightens it torefs/heads/main.productionenvironment (Settings → Environments) with required reviewers + amain-only branch rule, or the approval gate won't enforce.🤖 Generated with Claude Code