Skip to content

sec(iac): narrow three hosted-infra least-privilege grants (GCP CI trust ref, rotation RDS ARN, Cloud Run compute reads) - #1290

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-323
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-323

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #323 lists four least-privilege gaps from the 2026-09-02 audit. I re-checked each one against current main (a14dc8f). This PR narrows three of them. The fourth (Azure) is deferred, and so is the pool rename half of A13-014, because each could break a path that works today. I didn't find any merged PR that had already fixed these. The one commit whose message cites #323 (#832, a frontend savings-percentage fix) refers to an older, unrelated issue with that number.

Refs #323

Per-finding status

Finding Still present on main? This PR Takes effect via
A13-014 GCP CI workloadIdentityUser not pinned to a ref Partly. #1066 moved the member from attribute.repository to attribute.repository_id (immutable). It still had no ref, and the pool is still named github-actions. Ref pinned. New mapping attribute.repository_id_ref = assertion.repository_id + ':' + assertion.ref. The member is now principalSet://…/attribute.repository_id_ref/<github_repo_id>:<deploy_ref>. Pool rename deferred (see below). Manual ci-cd-permissions re-apply (GCP) by a privileged human.
A13c-021 rotation Lambda RDS grant arn:aws:rds:<region>:*:db:* Yes, unchanged. Still latent: rotation_function.zip doesn't exist, so the rotation Lambda can't be applied. Resource = "arn:aws:rds:${var.region}:${data.aws_caller_identity.current.account_id}:db:${var.rds_cluster_id}", plus a validation on rds_cluster_id (RDS identifier shape, null still allowed) so a * input can't widen it again. Normal AWS deploy. It only matters where enable_secret_rotation = true, and that is false by default and in every committed tfvars.
A13c-022 Cloud Run SA holds project-wide roles/compute.viewer Yes, unchanged. compute_viewer is replaced by the custom role cudlyComputeReader, bound to the Cloud Run SA (permissions below). Normal GCP deploy. GCP deploys are paused (since 2026-10-20), so this only takes effect after the pause lifts. The deploy SA already holds roles/iam.roleAdmin for the existing custom roles, so no bootstrap re-apply is needed.
A13c-023 Container App identity holds subscription Reader unconditionally Yes, unchanged. Deferred, no change (see below). n/a. Azure deploys are paused too.

A13c-022: why the permission list differs from the issue's suggestion

The issue suggested compute.commitments.list/get and compute.machineTypes.list/get. Following only that list would have broken two paths. I traced every Compute and Resource Manager call in the pinned cloud-commitments-go/providers/gcp (v0.0.0-20261017134002-3def3f898617) and the cloud.google.com/go/compute v1.54.0 operation poller, and the role holds exactly what those calls need:

Permission Caller
compute.regions.list Regions.List: the first call of GCP recommendation collection (recommendations.go, GetRegions)
compute.machineTypes.list MachineTypes.List (GetValidResourceTypes, purchase validation)
compute.commitments.list RegionCommitments.List (existing CUDs)
compute.commitments.get RegionCommitments.Get in adoptExistingCommitment (a re-driven purchase that hits a 409)
compute.regionOperations.get op.Wait() after RegionCommitments.Insert polls RegionOperations.Get. Without this permission, a CUD that was actually bought is reported as a failed purchase.
resourcemanager.projects.get ValidateCredentials and IsConfigured call GetProject. The ambient path (no GCP account registered, the default on a hosted GCP deploy) also resolves its project through projects.list, which only returns projects the caller can get. I believe roles/compute.viewer was what supplied this, so it is kept explicitly.

compute.machineTypes.get is left out because nothing calls it. The commitment write side (cudlyCommitmentWriter) and the recommender read side (cudlyRecommendationReader) are unchanged.

Deferred, with reasons

  • A13-014 pool rename (github-actions to a CUDly-scoped id). workload_identity_pool_id is ForceNew. Renaming it destroys and recreates the pool and its provider, which changes the provider resource name stored in vars.GCP_WORKLOAD_IDENTITY_PROVIDER. deploy-gcp, rollback, database-migration and cleanup-staging all use that variable, so they would fail until it was updated. GCP also soft-deletes pool ids for 30 days, which blocks an easy rollback. With the ref pinned in the member, a second provider in the pool would also need to emit repository_id_ref for this repo and the deploy ref. I added a comment on the pool saying to keep it dedicated to this trust. The rename belongs in a planned bootstrap migration: create the new pool alongside the old one, switch the variable, then remove the old pool.
  • A13c-023 Azure subscription_reader. The audit treats host-subscription ingestion as opt-in, but the scheduler disagrees. In internal/scheduler/scheduler.go, collectAzureRecommendations falls back to ambient managed-identity collection against AZURE_SUBSCRIPTION_ID whenever no Azure account is registered. The container-apps module always sets that variable. The pinned Azure provider's GetAllRecommendations then lists Redis, Cosmos, SQL, Search and Compute SKUs on the host subscription, and Advisor recommendations. Those calls need Reader, not just Cost Management Reader. So a enable_self_account_ingestion gate that defaults to false would quietly break default recommendation collection on any hosted Azure deployment that has no Azure accounts registered, and also for any registered Self (managed_identity) account. Owner decision: should the hosted Azure deploy ingest its own subscription by default? If not, the Reader gate and the scheduler's ambient fallback should be switched off together by the same flag, for example a flag that also stops AZURE_SUBSCRIPTION_ID from enabling the fallback. That second half is an app change and outside this issue's scope.

Notes for operators

  • A13-014 re-apply (manual, GCP ci-cd-permissions): a plain apply updates the provider in place (adds the mapping), creates the new member and then destroys the old one. depends_on plus create_before_destroy keeps at least one matching grant at every step. As before, pin deploy_ref in terraform.tfvars, because the member now follows deploy_ref just as the condition does. Every token the existing condition accepts already has ref == deploy_ref, so the new member matches every workflow that can authenticate today.
  • A13c-022 deploy: the old binding is removed and the custom role and new binding are created in the same apply. There may be a few seconds, plus IAM propagation time, when the runtime SA has neither. A scheduled collection that runs in that window could log a 403 and recover on the next run.

Verification

All of this is unit, mock-provider and fixture evidence. I made no real cloud calls and ran no plan or apply against real backends. I couldn't check that GCP accepts each permission in a custom role, or that the principalSet attribute value format works, without calling GCP. Both are expected to hold: these are standard custom-role permissions, and : and / already appear in documented GitHub principal/principalSet values.

Fail before, pass after:

  • A13-014: terraform test in terraform/environments/gcp/ci-cd-permissions (mocked google provider), with the new immutable_trust.tftest.hcl run against the HEAD module in a temp copy: Failure! 11 passed, 2 failed. Four new assertions fail: the member suffix /attribute.repository_id_ref/1391259743:refs/heads/main, the old /attribute.repository_id/ member being absent, the mapping, and the member following deploy_ref (refs/heads/feature/x). On this branch: Success! 13 passed, 0 failed. This file already runs in CI's "Test CI/CD trust (immutable ids)" step.
  • A13c-021: go test -count=1 ./terraform/modules/secrets/aws/ (new rotation_rds_iam_test.go) fails before the fix (wildcard Resource, missing aws_caller_identity, missing rds_cluster_id validation) and passes after. The validation test pulls the regex out of variables.tf and checks it in Go's RE2. It rejects *, cudly-*, a:b, a/b, leading digits or hyphens and 64-character ids, and accepts cudly-dev-postgres. I also ran an uncommitted mock-provider terraform test probe on a temp copy. It confirmed the rendered policy Resource is arn:aws:rds:us-east-1:<mock account>:db:cudly-dev-postgres, that rds_cluster_id = "*" fails validation, and that null still produces no policy.
  • A13c-022: go test -count=1 -run TestCloudRunComputeGrantIsNarrow ./terraform/modules/compute/gcp/ (new runtime_compute_iam_test.go) fails before ("must not grant a predefined roles/compute.* role", "must define … cudlyComputeReader") and passes after. It checks the exact permission set and that the role is bound exactly once, project-scoped, to the Cloud Run SA.

Other checks (all passed):

export PATH=$HOME/.local/go/bin:$HOME/go/bin:$HOME/.local/bin:$PATH
export AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true
terraform fmt -check -recursive terraform/
# In a temp copy (committed lock files lack linux hashes):
terraform init -backend=false && terraform validate   # environments/gcp, environments/aws,
                                                       # modules/compute/gcp/cloud-run, modules/secrets/aws,
                                                       # environments/gcp/ci-cd-permissions
terraform test                                        # environments/gcp/ci-cd-permissions: 13 passed
go build ./... && go vet ./... && go vet -tags integration ./...
go test -count=1 ./terraform/modules/compute/gcp/ ./terraform/modules/secrets/aws/ ./internal/mocks/
golangci-lint run --allow-parallel-runners ./terraform/modules/compute/gcp/ ./terraform/modules/secrets/aws/   # 0 issues
actionlint .github/workflows/cleanup-staging.yml
bash scripts/check-aws-iam-parity.sh && bash scripts/test-aws-iam-parity.sh

shellcheck scripts/gcp-import-dev-state.sh reports only SC2295 (info) at line 162. That warning already exists on main in a line this PR doesn't touch. No non-test Go files changed, so gocyclo doesn't apply.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope (noted, not fixed)

  • scripts/gcp-import-dev-state.sh was already stale: it imports compute_commitment_admin with roles/compute.admin, and that resource no longer exists. I only removed the compute_viewer import that this PR makes invalid.
  • The GKE runtime SA (modules/compute/gcp/gke) has no Compute read grant at all, so the regions and commitments reads above would 403 there. That's a separate gap, not an over-grant.
  • terraform/modules/compute/gcp/cloud-run/main.tf was already over the 500-line guideline before this change (576 lines, now 608).
  • The rotation Lambda itself is still unappliable (no rotation_function.zip, tracked in fix(iac/gcp): cleanup-function ships a placeholder.zip source that doesn't exist, breaking apply #124 / chore(terraform/modules): medium/low findings from the 2026-07-28 full review #131). If a future rotation function calls DescribeDBInstances without an identifier, it would need an account-scoped db:* describe-only statement. ModifyDBInstance should stay instance-scoped.

Maintainer: please mirror the issue's triage labels on this PR: impact/internal, triaged, priority/p3, severity/low, urgency/eventually, effort/s, type/security.

🤖 Generated with Claude Code

From the 2026-09-02 audit (issue LeanerCloud#323):

- A13-014: the GCP CI impersonation member pinned only the repo id, so any
  other provider in the pool that mapped attribute.repository_id could
  impersonate the deploy SA from any ref. Map repo id and ref into one
  attribute (attribute.repository_id_ref) and key the member on
  <github_repo_id>:<deploy_ref>, matching the provider's condition. The
  member is replaced after the provider gains the mapping and before the
  old member goes (depends_on + create_before_destroy). The pool rename is
  deferred: it replaces the pool and provider and changes
  GCP_WORKLOAD_IDENTITY_PROVIDER for every GCP workflow.
- A13c-021: the rotation role's RDS grant was arn:aws:rds:<region>:*:db:*.
  Build it from the caller account and var.rds_cluster_id, and validate the
  id so a wildcard cannot re-widen it.
- A13c-022: replace the Cloud Run SA's roles/compute.viewer with a custom
  role cudlyComputeReader holding only the reads the pinned GCP provider
  makes (regions.list, machineTypes.list, commitments.list/get,
  regionOperations.get for the purchase op.Wait, resourcemanager.projects.get
  for credential validation and the ambient project lookup). Drop the stale
  compute_viewer import from gcp-import-dev-state.sh.

A13c-023 (Azure subscription Reader) is deferred: the scheduler's ambient
Azure fallback collects from the host subscription by default, so gating
Reader off by default could break that path.

Refs LeanerCloud#323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours type/security Security finding labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/security Security finding urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant