Skip to content

fix(iac): key the GCP deploy trust on immutable repository ids - #1066

Merged
cristim merged 2 commits into
mainfrom
fix/oidc-trust-immutable-repo-ids
Oct 17, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/oidc-trust-immutable-repo-ids

Conversation

@cristim

@cristim cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Summary

Part of #2 (P1 security). This PR covers GCP and adds the CI test step. AWS and Azure follow in a second PR that completes #2.

The live CI/CD deploy trust was re-pointed on 2026-09-29. GitHub's repository setting already emits immutable OIDC subjects (use_immutable_subject: true, sub_claim_prefix: repo:LeanerCloud@86753534/cloud-commitments-platform@1391259743). The ci-cd-permissions Terraform in this repo, however, still keys trust on the repository name (var.github_repo). The next bootstrap re-apply would therefore bring back name-based trust, which a rename or a reused repository name can silently break or hijack.

GCP (terraform/environments/gcp/ci-cd-permissions)

  • New required variables github_owner_id and github_repo_id, validated with ^[1-9][0-9]*$. The real values (86753534, 1391259743) are in terraform.tfvars.example.
  • The provider adds the attribute.repository_id and attribute.repository_owner_id mappings. The existing attribute.repository mapping stays, so the old binding keeps resolving during apply.
  • attribute_condition becomes assertion.repository_owner_id == '86753534' && assertion.repository_id == '1391259743' && assertion.ref == '<deploy_ref>'.
  • The service account binding member becomes principalSet://…/attribute.repository_id/1391259743, with lifecycle { create_before_destroy = true }. The new binding is added before the old one is removed, so deploys keep access throughout.
  • deploy_ref is validated as ^refs/(heads|tags)/[A-Za-z0-9._/-]+$, because it is interpolated into CEL. github_repo is validated as owner/name or "". It is still the skip toggle, but it no longer feeds the condition or the member. The CLAUDE.md note about pinning deploy_ref still applies.
  • The README documents the condition and how to look up the IDs. It also documents that AWS and Azure depend on the GitHub immutable-subject setting.

CI

One step, "Test CI/CD trust (immutable ids)", in the existing terraform-validate job. For each matrix.cloud it runs terraform init -backend=false and terraform test on terraform/environments/<cloud>/ci-cd-permissions, but only when a tests/ directory exists. It is a no-op for AWS and Azure until the second PR adds their tests, so that PR needs no CI change.

The cleanup-staging.yml change is a refreshed comment only.

Tests

tests/immutable_trust.tftest.hcl (mock provider, 13 runs):

  • the exact condition string, with no name-only clause;
  • the three mappings;
  • the member keyed on repository_id;
  • the condition follows deploy_ref;
  • rejected IDs: empty, non-numeric, leading-zero;
  • deploy_ref values x' || true and "" rejected;
  • github_repo values a/b'c and noslash rejected;
  • skip mode (github_repo="") creates 0 pool, provider and member resources.

Fail-before: on origin/main the first run fails (the member was …/attribute.repository/LeanerCloud/cloud-commitments-platform, and the new mappings were absent). After the change, 13/13 pass.

Mutants killed:

  • dropping the owner clause;
  • reverting the member to attribute.repository;
  • loosening the repo-id regex;
  • replacing the github_repo validation with .*.

create_before_destroy cannot be asserted by a test. See the apply runbook.

Verification (local, macOS)

  • terraform fmt -check -recursive terraform/, validate and test are clean on Terraform 1.10.5, the CI version.
  • actionlint exits 0. All pre-commit hooks pass.
  • Nothing was applied, and no live cloud or GitHub state was changed.

Apply runbook (bootstrap: a privileged human, per CLAUDE.md)

  1. Pre-check that gh api repos/LeanerCloud/cloud-commitments-platform/actions/oidc/customization/sub still shows use_immutable_subject: true.
  2. Save the live state. Then, in terraform/environments/gcp/ci-cd-permissions, set the IDs in the gitignored tfvars, run terraform plan, and confirm:
    • the provider is updated in place (new mappings and condition);
    • the member shows "create replacement and then destroy";
    • nothing else changes.
  3. Apply when no deploy is running. The binding is not interrupted.
  4. Verify a deploy from main authenticates to GCP.
  5. Rollback: revert this PR and re-apply. The old mapping is still present.

Review

  • Plan: plans/2-oidc-trust-immutable-ids.md, independently reviewed in 2 passes, revisions RV1-RV6.
  • This diff: independent non-author review, 2 passes, clean at 5b1ad3d.
  • Owner-decision defaults taken: IDs are required variables with their values in the tfvars example; the GCP owner-ID clause is kept; the sec(iac): key CI/CD deploy trust on immutable repository IDs #451 sub-template approach is retired.

🤖 Generated with Claude Code

@cristim cristim added urgency/this-sprint Within the current sprint impact/internal Team-internal only priority/p1 Next up; this sprint severity/high Significant harm effort/m Days type/security Security finding triaged Item has been triaged labels Oct 17, 2026
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 7 billable files and costs up to $1.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 32 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 77 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 86006ceb-67ab-45ea-8b46-e2243a31b414
📥 Commits

Reviewing files that changed from the base of the PR and between 8768459 and ffd7749.

📒 Files selected for processing: 7
  • .github/workflows/ci.yml
  • .github/workflows/cleanup-staging.yml
  • terraform/environments/gcp/ci-cd-permissions/README.md
  • terraform/environments/gcp/ci-cd-permissions/github_oidc.tf
  • terraform/environments/gcp/ci-cd-permissions/terraform.tfvars.example
  • terraform/environments/gcp/ci-cd-permissions/tests/immutable_trust.tftest.hcl
  • terraform/environments/gcp/ci-cd-permissions/variables.tf
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@coderabbitai

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim cristim left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review of 5b1ad3d: no actionable findings

No BLOCKING and no NON-BLOCKING findings survived.

What I attacked

  • CEL vs OIDC claims. github_oidc.tf condition is assertion.repository_owner_id == '${var.github_owner_id}' && assertion.repository_id == '${var.github_repo_id}' && assertion.ref == '${var.deploy_ref}'. GitHub emits repository_id, repository_owner_id and ref as string claims, so comparing to quoted strings is type-correct. The provider mappings attribute.repository_id = assertion.repository_id and attribute.repository_owner_id = assertion.repository_owner_id are valid custom attributes. The member is .../attribute.repository_id/${var.github_repo_id}, which matches the mapping.
  • No widening. The condition still pins repo (now by id), owner id and the exact deploy_ref, so forks, PRs, tags and other branches stay rejected. Owner id plus repo id is strictly narrower than the old name match. The deploy_ref logic from CLAUDE.md is untouched (default refs/heads/main, test condition_follows_deploy_ref).
  • Injection. deploy_ref is validated ^refs/(heads|tags)/[A-Za-z0-9._/-]+$ before being interpolated into CEL (quote is excluded). IDs are ^[1-9][0-9]*$. Test rejects x' || true.
  • Defaults and secrets. github_owner_id and github_repo_id have no default, so an old tfvars fails loud at plan. The IDs in terraform.tfvars.example are public GitHub ids, not secrets. No project or account ids added. *.tfvars is gitignored (.gitignore:108-109).
  • Migration. The attribute.repository mapping is kept, so the old member keeps resolving while create_before_destroy adds the new one. outputs.tf:18 only uses github_repo as the skip toggle, still consistent.
  • Deploy-on-merge gate (CRITICAL). Nothing applies ci-cd-permissions on merge or push. The only references to the path in .github are ci.yml:620 (new step, init -backend=false + terraform test, mock provider, no credentials) and comments or an error message (deploy-azure.yml:328, cleanup-staging.yml, rollback.yml:27). Every terraform plan/apply runs in a different directory: deploy-gcp.yml:188,195,206 (terraform/environments/gcp), deploy-aws-lambda.yml:268-325 and deploy-aws-fargate.yml:192-225 (.../aws), deploy-azure.yml:209,221,277 (.../azure), rollback.yml:278-552 (aws/gcp/azure). Terraform does not recurse into subdirectories, and the gcp env root has no module source pointing at ci-cd-permissions (module sources are ../../modules/...). scripts/*.sh apply only in other contexts (tf-deploy.sh, test scripts), none invoked by a workflow on merge for this path. Merging changes no live trust; the owner's manual bootstrap apply is still required.
  • Local runs (Terraform 1.10.5, no credentials, GOOGLE_APPLICATION_CREDENTIALS=/dev/null, mock provider only, no plan/apply): terraform fmt -check -recursive OK, validate OK, terraform test 13 passed, 0 failed. actionlint on ci.yml and cleanup-staging.yml exit 0.
  • Fail-before. Ran the PR's test file against a copy of origin/main's module: trust_uses_immutable_ids fails (invalid index on the missing mappings, assertion failures), the remaining runs are skipped. Reproduces the author's claim.

Gate status (origin/main CLAUDE.md "Review gate")

  • Independent adversarial review naming the SHA: this comment.
  • CI on 5b1ad3d: gh pr checks 1066 all pass, including Validate Terraform (gcp), Lint Workflows, pre-commit, Security Scanning, Snyk, Trivy. CodeRabbit status is "rate limited" (optional; not pinged).
  • No unresolved actionable review threads; the PR has no human review comments.
  • Local verification on macOS: above. Gap: the real IAM replacement ordering (create_before_destroy) and live token minting cannot be exercised without a real project; the PR's apply runbook (plan must show "create replacement and then destroy", then verify a main deploy) covers that and stays an owner step.
  • Labels: no closing issue ("Part of #2"). PR labels (priority/p1, severity/high, urgency/this-sprint, impact/internal, effort/m, type/security, triaged) match issue #2 exactly.
  • Scope: AWS and Azure are explicitly left to the follow-up PR; the CI step is a no-op for them until tests exist.

cristim and others added 2 commits October 17, 2026 21:02
Live GCP trust is still name-based (assertion.repository == name), so a
renamed or re-created repo under the same name could match. Key the WIF
attribute condition and the SA member on the immutable GitHub
repository_owner_id and repository_id instead, keeping the ref clause and
the attribute.repository mapping. Add github_owner_id and github_repo_id as
required, validated variables and validate deploy_ref, which is
interpolated into the CEL condition.

The member is replaced with create_before_destroy, and the old member keeps
resolving through the retained mapping, so there is no access gap. Parity
with the already-applied AWS/Azure immutable subjects lands in a separate
change. Add a mock-provider terraform test and a CI step running it.

Apply is a manual bootstrap step for a privileged human; the runbook is in
the PR body (plan 2-oidc-trust-immutable-ids, section 5). Refs #2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add tftest runs that kill two surviving validation mutants: a loosened
repo-id regex (leading zero accepted) and a github_repo validation
replaced by a match-all. Add a skip-mode run (github_repo empty creates no
WIF resources). Refresh the stale GCP comment in cleanup-staging.yml to the
repository_id keying and note in the README that github_repo is still the
skip toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim
cristim force-pushed the fix/oidc-trust-immutable-repo-ids branch from 5b1ad3d to ffd7749 Compare October 17, 2026 19:05
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

The independent review above covers 5b1ad3d. Rebased by claude-cc-pr-merge-3 onto main to resolve a ci.yml conflict with #1023: main's 'Test /health gate modules' step kept, this PR's 'Test CI/CD trust (immutable ids)' step unchanged after it; actionlint clean.
Pure rebase: git range-diff 34c91bbd..5b1ad3d1 8768459d..ffd7749e:

1:  cea0049f = 1:  924446f4 fix(iac): key the GCP deploy trust on immutable repository ids
2:  5b1ad3d1 = 2:  ffd7749e test(iac): pin the GCP trust input validations

The PR's own commits are unchanged, so the verdict carries to ffd7749 (owner #631 rule).

@cristim
cristim merged commit 63c464a into main Oct 17, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant