Skip to content

sec(iac): key CI/CD deploy trust on immutable repository IDs - #451

Closed
cristim wants to merge 2 commits into
mainfrom
sec/2-rename-proof-deploy-trust
Closed

cristim wants to merge 2 commits into
mainfrom
sec/2-rename-proof-deploy-trust

Conversation

@cristim

@cristim cristim commented Sep 29, 2026

Copy link
Copy Markdown
Member

Summary

This PR covers the code half of #2. The CI/CD deploy trust in terraform/environments/{aws,azure,gcp}/ci-cd-permissions/ no longer uses the repository name. It is keyed on the immutable repository_id and repository_owner_id. It also adds a reviewed bootstrap script for the GitHub side and a runbook with the apply order and rollback. Nothing was applied: no terraform apply was run and no GitHub setting was changed. The bootstrap step is still pending, so this PR does not close #2.

Root cause

The trusts matched repo:${var.github_repo}:... (AWS sub list and Azure federated credential subjects) and assertion.repository == var.github_repo (GCP). GitHub's default sub and repository claims follow the repository's current name. A rename breaks deploys, and a new repo that reuses a freed name would gain deploy rights.

Fix

  • OIDC sub template (include_claim_keys): ["repository_owner_id","repository_id","context"]. With it, GitHub mints repository_owner_id:86753534:repository_id:1391259743:ref:refs/heads/main and ...:environment:<name>.
  • AWS: the StringEquals token.actions.githubusercontent.com:sub list is ${local.github_oidc_sub_prefix}:ref:refs/heads/main plus the same 9 environment subjects as before (dev, staging, prod, aws-fargate-*, aws-db-*). aud is unchanged.
  • Azure: the federated credential subjects are ${prefix}:ref:refs/heads/main and ${prefix}:environment:<each github_environments>.
  • GCP: new mappings attribute.repository_id and attribute.repository_owner_id. The condition is assertion.repository_owner_id == '<owner>' && assertion.repository_id == '<id>' && assertion.ref == '<deploy_ref>'. workloadIdentityUser is granted on principalSet://.../attribute.repository_id/<id>.
  • github_repo is replaced by the required variables github_repository_id and github_repository_owner_id. Both have no default and must match ^[1-9][0-9]*$. terraform.tfvars.example documents their values. Because trust now requires the IDs, the OIDC resources are no longer optional (the old "set github_repo empty to skip" mode is gone). moved blocks cover the former [0] addresses. After the apply, no trust names any repository, so the old monorepo-name entries go away.
  • scripts/bootstrap-github-deploy-config.sh runs as a dry run by default, and --apply makes the changes. It is idempotent. It creates all 15 workflow environments (dev, staging, prod, and aws-fargate-*, aws-db-*, gcp-db-*, azure-db-* for each of dev/staging/prod). Each gets custom_branch_policies=true limited to main, and existing reviewers and wait timers are kept. The script stops with an error if an environment admits any other ref. It also sets the sub template.
  • docs/deploy-trust-bootstrap.md covers the apply order: record state, run the GitHub script, then plan and apply each cloud's module (with the expected plan diff), then a test deploy from main. Its rollback section includes the state mv commands needed to return to the indexed addresses. The three ci-cd-permissions READMEs and 7 workflow comments are updated to the new subject format.

Human steps remaining (#2)

Follow docs/deploy-trust-bootstrap.md. In each module's terraform.tfvars, set github_repository_id = "1391259743" and github_repository_owner_id = "86753534" and delete github_repo. Protect main first (#154). Copying the Actions secrets and variables (step 4 of #2) is not in this PR.

Regression test

terraform/environments/deploy_trust_guard_test.go checks three things:

  • no trust references the repo name;
  • every AWS and Azure subject carries the ID prefix, and the GCP condition and member use the IDs and deploy_ref;
  • the script's SUB_CLAIM_KEYS matches the Terraform prefix, and every environment a cloud trusts is gated by the script.

Evidence that the tests have teeth:

  • With the pre-fix .tf files stashed, all 3 tests fail.
  • Reordering the claim keys fails the claim-key check.
  • Dropping staging or aws-db-prod from the script fails the environment check.

Verification

  • go test ./terraform/environments/ (GOTOOLCHAIN=go1.26.6): 3 passed. go vet and gofmt are clean.
  • terraform fmt -check -recursive: clean. terraform init -backend=false && terraform validate passes on aws, azure and gcp.
  • tflint --config=.tflint.hcl exits 0 on all 3 modules.
  • terraform console confirms the variable validation rejects LeanerCloud/x and 0123 and accepts 1391259743.
  • shellcheck is clean on the script. I ran the script only against a stub gh, never against GitHub:
    • the dry run made 0 mutations;
    • --apply made the expected PUT/POST set and kept an existing reviewer and wait timer;
    • a second run on converged state made 0 mutations;
    • an extra feature/* branch policy stops the script with exit 1.
  • pre-commit run on the changed files passes, with SKIP=actionlint because the Docker daemon on this host is hung. The native actionlint passes on all workflows.

Refs #2

GitHub's default OIDC sub and repository claims follow the repository's
current name, so the ci-cd-permissions trusts keyed on var.github_repo
break on a rename and could be claimed by a new repo reusing a freed name.

- AWS role and Azure federated credentials now match subjects of the form
  repository_owner_id:<owner_id>:repository_id:<repo_id>:{ref:refs/heads/main,
  environment:<name>}.
- GCP maps repository_id / repository_owner_id and conditions on both plus
  deploy_ref; the workloadIdentityUser member is the repository_id
  principalSet.
- github_repo is replaced by required, validated github_repository_id and
  github_repository_owner_id variables; the OIDC resources are no longer
  optional, with moved blocks for the former [0] addresses.
- scripts/bootstrap-github-deploy-config.sh (dry run by default) creates
  every workflow environment with a main-only deployment branch policy and
  sets the matching OIDC sub claim template.
- deploy_trust_guard_test.go fails if any trust references the repo name,
  if the script's claim keys drift from the Terraform prefix, or if a
  trusted environment is not gated by the script.

Refs #2
Documents the GitHub script, per-cloud terraform plan/apply, the expected
plan diff, a test deploy from main, and rollback (including the state mv
needed to return to the indexed addresses). Updates the three
ci-cd-permissions READMEs to the ID-based subjects.

Refs #2
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.

sec(iac): rename-proof OIDC deploy trust + branch-restricted environments for the split platform repo

1 participant