Skip to content

Security hardening: least-privilege IAM, plan/apply split, bucket protections#20

Merged
Jeffreyhung merged 6 commits into
mainfrom
security/least-privilege-hardening
Jun 24, 2026
Merged

Security hardening: least-privilege IAM, plan/apply split, bucket protections#20
Jeffreyhung merged 6 commits into
mainfrom
security/least-privilege-hardening

Conversation

@Jeffreyhung

Copy link
Copy Markdown
Member

Implements the agreed findings from the in-depth security review.

Changes

Item Change
#1 Plan/apply SA split New read-only gha-cf-tf-plan SA (viewer + state read/lock only) for PR terraform plan; privileged gha-cloud-functions-deployment reserved for apply. Workload-identity binding pins the apply SA to refs/heads/main; google.subject mapped to assertion.sub. Apply job gated behind the production GitHub Environment.
#3 Removed project-wide roles/iam.serviceAccountUser from the deploy SA (per-SA actAs already granted in each module).
#4 Removed project-wide roles/secretmanager.secretAccessor; replaced with a narrow custom role that creates/manages secrets + their IAM bindings without reading secret values.
#5 Protected the Terraform state bucket: force_destroy = false + lifecycle { prevent_destroy = true }.
#6 Fixed the staging-bucket IAM binding that wrongly targeted the state bucket; plan SA added to the authoritative state-bucket binding.
#8 (partial) Removed the unnecessary project-wide monitoring.viewer from pub/sub SAs.
#9 Removed self-actAs bindings on function/workflow/eventarc SAs and a duplicate deploy-SA actAs in the workflow module.
UBLA uniform_bucket_level_access = true on all buckets.
Dependabot New config for github-actions, terraform, and pip.
Misc Added required_version >= 1.5.0; renamed varables.tfvariables.tf.

README updated for the two-identity model, the production environment requirement, and state-bucket protection.

Not done: scoping the workflows.invoker grants

The remaining part of #8 is not achievable in Terraform today: the hashicorp/google provider exposes no google_workflows_workflow_iam_* resource in any version (verified v6 and v7.38), and Cloud Workflows does not support resource.name IAM 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.

⚠️ Before merging / applying

  • Validation was offline only (terraform validate + fmt against the v6.20 provider schema). Run a real terraform plan against the project to review the IAM diff before applying.
  • Plan SA bootstrap: this PR's own plan job will fail at auth because gha-cf-tf-plan doesn't exist yet. Create it via an admin-run apply first (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 old attribute.repository binding still covers main until this tightens it to refs/heads/main.
  • Configure the production environment (Settings → Environments) with required reviewers + a main-only branch rule, or the approval gate won't enforce.
  • The new custom role + IAM bindings are created by a privileged (owner) apply, same as the existing project-IAM bindings.

🤖 Generated with Claude Code

…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>
Comment thread infrastructure/permissions.tf
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown

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

Comment thread infrastructure/permissions.tf Outdated
Comment thread infrastructure/main.tf

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread modules/eventarc-workflow-trigger/main.tf
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>
Comment thread infrastructure/workload_identity.tf
Jeffreyhung and others added 2 commits June 24, 2026 14:39
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>
Comment thread infrastructure/workload_identity.tf
@Jeffreyhung Jeffreyhung merged commit 04c0539 into main Jun 24, 2026
8 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant