From 85cc35b2d006b4c3174dbc462367032fbec951cc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 29 Sep 2026 13:26:51 +0200 Subject: [PATCH 1/2] sec(iac): key CI/CD deploy trust on immutable repository IDs 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::repository_id::{ref:refs/heads/main, environment:}. - 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 --- .github/workflows/cleanup-staging.yml | 2 +- .github/workflows/deploy-aws-lambda.yml | 4 +- .github/workflows/deploy-azure.yml | 2 +- .github/workflows/deploy-gcp.yml | 2 +- .github/workflows/destroy-fargate-dev.yml | 4 +- scripts/bootstrap-github-deploy-config.sh | 129 ++++++++++ .../aws/ci-cd-permissions/github_oidc.tf | 16 +- .../aws/ci-cd-permissions/outputs.tf | 4 +- .../aws/ci-cd-permissions/role.tf | 65 +++-- .../terraform.tfvars.example | 28 ++- .../aws/ci-cd-permissions/variables.tf | 20 +- .../azure/ci-cd-permissions/sp.tf | 30 ++- .../terraform.tfvars.example | 30 ++- .../azure/ci-cd-permissions/variables.tf | 25 +- .../environments/deploy_trust_guard_test.go | 232 ++++++++++++++++++ .../gcp/ci-cd-permissions/github_oidc.tf | 42 ++-- .../gcp/ci-cd-permissions/outputs.tf | 2 +- .../terraform.tfvars.example | 28 ++- .../gcp/ci-cd-permissions/variables.tf | 20 +- 19 files changed, 581 insertions(+), 104 deletions(-) create mode 100755 scripts/bootstrap-github-deploy-config.sh create mode 100644 terraform/environments/deploy_trust_guard_test.go diff --git a/.github/workflows/cleanup-staging.yml b/.github/workflows/cleanup-staging.yml index 8d8a57cf..7fc05154 100644 --- a/.github/workflows/cleanup-staging.yml +++ b/.github/workflows/cleanup-staging.yml @@ -26,7 +26,7 @@ on: # deployment environment. `guard` authenticates to nothing and must not hold it. # # IMPORTANT — what the `environment:` binding does and does not buy. It scopes -# secrets/vars and sets the OIDC subject to `repo::environment:staging`. +# secrets/vars and sets the OIDC subject to `:environment:staging`. # What each cloud does with that subject differs; see the per-job comments. # # It is NOT a reviewer gate. GitHub only blocks a job once required-reviewer diff --git a/.github/workflows/deploy-aws-lambda.yml b/.github/workflows/deploy-aws-lambda.yml index ab56596a..da4a2b0c 100644 --- a/.github/workflows/deploy-aws-lambda.yml +++ b/.github/workflows/deploy-aws-lambda.yml @@ -205,7 +205,7 @@ jobs: needs: prepare permissions: # Assumes the AWS deploy role. The `environment:` binding below is what - # scopes this job's OIDC `sub` to `repo::environment:`, + # scopes this job's OIDC `sub` to `:environment:`, # which is the subject the trust policy matches. NOTE it is not by itself # a reviewer gate: an Environment only blocks a job once required-reviewer # protection rules are configured on it in repo settings, and as of this @@ -368,7 +368,7 @@ jobs: if: always() && needs.build-and-deploy.result == 'success' permissions: # Assumes the AWS deploy role. The `environment:` binding below is what - # scopes this job's OIDC `sub` to `repo::environment:`, + # scopes this job's OIDC `sub` to `:environment:`, # which is the subject the trust policy matches. NOTE it is not by itself # a reviewer gate: an Environment only blocks a job once required-reviewer # protection rules are configured on it in repo settings, and as of this diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml index fa6e5cbf..2482e760 100644 --- a/.github/workflows/deploy-azure.yml +++ b/.github/workflows/deploy-azure.yml @@ -134,7 +134,7 @@ jobs: permissions: # Assumes the Azure deploy SP via federated identity. The `environment:` # binding below scopes this job's OIDC `sub` to - # `repo::environment:`, which must have a matching + # `:environment:`, which must have a matching # `azuread_application_federated_identity_credential` (see # terraform/environments/azure/ci-cd-permissions/sp.tf) or `azure/login` # fails with AADSTS70021. NOTE the binding is not by itself a reviewer diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml index 727c03a8..7644d416 100644 --- a/.github/workflows/deploy-gcp.yml +++ b/.github/workflows/deploy-gcp.yml @@ -126,7 +126,7 @@ jobs: permissions: # Assumes the GCP deploy identity via Workload Identity Federation. The # `environment:` binding below scopes this job's OIDC `sub` to - # `repo::environment:`. NOTE it is not by itself a + # `:environment:`. NOTE it is not by itself a # reviewer gate: an Environment only blocks a job once required-reviewer # protection rules are configured on it in repo settings. See #141. id-token: write diff --git a/.github/workflows/destroy-fargate-dev.yml b/.github/workflows/destroy-fargate-dev.yml index d01fc2bb..f9e71df4 100644 --- a/.github/workflows/destroy-fargate-dev.yml +++ b/.github/workflows/destroy-fargate-dev.yml @@ -17,7 +17,7 @@ on: # environment. `guard` authenticates to nothing and must not hold it. # # IMPORTANT — what the `environment:` binding does and does not buy. It scopes -# secrets/vars and sets the OIDC subject to `repo::environment:dev`, +# secrets/vars and sets the OIDC subject to `:environment:dev`, # which the AWS trust policy's sub allowlist accepts (role.tf:30). # # It is NOT a reviewer gate. GitHub only blocks a job once required-reviewer @@ -87,7 +87,7 @@ jobs: permissions: id-token: write contents: read - # Binds the OIDC subject to repo::environment:dev, which the AWS + # Binds the OIDC subject to :environment:dev, which the AWS # trust policy matches on. Not a reviewer gate until protection rules exist # on this environment -- see the note at the top of this file. environment: dev diff --git a/scripts/bootstrap-github-deploy-config.sh b/scripts/bootstrap-github-deploy-config.sh new file mode 100755 index 00000000..7082efd2 --- /dev/null +++ b/scripts/bootstrap-github-deploy-config.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Configures the GitHub side of the CI/CD deploy trust: +# 1. every deployment environment the workflows bind to, with a deployment +# branch policy that admits only main; +# 2. the repository's OIDC sub claim template, which decides the subject the +# terraform/environments/*/ci-cd-permissions trusts match. +# +# Dry-run by default; --apply performs the changes. Idempotent: settings that +# already match are left alone. Needs a gh login with admin on the repository. +# Apply order and rollback: docs/deploy-trust-bootstrap.md. +set -euo pipefail + +REPO="LeanerCloud/cloud-commitments-platform" +DEPLOY_BRANCH="main" + +# Must stay in lockstep with local.github_oidc_sub_prefix in +# terraform/environments/{aws,azure}/ci-cd-permissions; the Go test +# terraform/environments/deploy_trust_guard_test.go enforces it. +SUB_CLAIM_KEYS='["repository_owner_id","repository_id","context"]' + +# Every environment a job in .github/workflows binds to, with the +# ${{ ... }} expressions resolved to their dev/staging/prod choices. +ENVIRONMENTS=( + dev staging prod + aws-fargate-dev aws-fargate-staging aws-fargate-prod + aws-db-dev aws-db-staging aws-db-prod + gcp-db-dev gcp-db-staging gcp-db-prod + azure-db-dev azure-db-staging azure-db-prod +) + +usage() { + echo "Usage: $0 [--dry-run | --apply]" + echo " --dry-run print what would change (default)" + echo " --apply make the changes on github.com/$REPO" +} + +APPLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --dry-run) APPLY=0 ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac + shift +done + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +# change DESCRIPTION CMD...: runs CMD under --apply, only describes it otherwise. +change() { + local desc="$1" + shift + if [ "$APPLY" -eq 1 ]; then + echo "APPLY: $desc" + "$@" >/dev/null + else + echo "WOULD: $desc" + fi +} + +command -v gh >/dev/null || die "gh CLI not found" +command -v jq >/dev/null || die "jq not found" + +repo_ids=$(gh api "repos/$REPO" --jq '"repository_id=\(.id) repository_owner_id=\(.owner.id)"') +echo "Repository: $REPO ($repo_ids)" +echo "Check these IDs against terraform.tfvars in each ci-cd-permissions module." +[ "$APPLY" -eq 1 ] || echo "Dry run: nothing will be changed. Re-run with --apply to make the changes." + +existing_envs=$(gh api --paginate "repos/$REPO/environments" --jq '.environments[]?.name') + +for env in "${ENVIRONMENTS[@]}"; do + current='{}' + if printf '%s\n' "$existing_envs" | grep -qxF "$env"; then + current=$(gh api "repos/$REPO/environments/$env") + fi + + policies='' + if jq -e '.deployment_branch_policy == {"protected_branches": false, "custom_branch_policies": true}' \ + <<<"$current" >/dev/null; then + echo "OK: environment $env uses custom branch policies" + # This endpoint only answers once custom branch policies are enabled. + policies=$(gh api --paginate "repos/$REPO/environments/$env/deployment-branch-policies" \ + --jq '.branch_policies[] | "\(.type // "branch"):\(.name)"') + else + # PUT replaces the environment's settings, so carry over any wait timer + # and required reviewers it already has. + payload=$(jq -c ' + (.protection_rules // []) as $r + | {deployment_branch_policy: {protected_branches: false, custom_branch_policies: true}} + + ([$r[] | select(.type == "wait_timer") | {wait_timer}] | add // {}) + + ([$r[] | select(.type == "required_reviewers") + | {prevent_self_review, reviewers: [.reviewers[] | {type, id: .reviewer.id}]}] | add // {}) + ' <<<"$current") + change "create/update environment $env with $payload" \ + gh api -X PUT "repos/$REPO/environments/$env" --input - <<<"$payload" + fi + + others=$(printf '%s\n' "$policies" | grep -vxF "branch:$DEPLOY_BRANCH" | grep -v '^$' || true) + [ -z "$others" ] || die "environment $env admits refs other than $DEPLOY_BRANCH: +$others +Remove them in Settings -> Environments -> $env, then re-run." + + if printf '%s\n' "$policies" | grep -qxF "branch:$DEPLOY_BRANCH"; then + echo "OK: environment $env admits branch $DEPLOY_BRANCH only" + else + change "limit environment $env to branch $DEPLOY_BRANCH" \ + gh api -X POST "repos/$REPO/environments/$env/deployment-branch-policies" \ + -f name="$DEPLOY_BRANCH" -f type=branch + fi +done + +sub_payload=$(jq -cn --argjson keys "$SUB_CLAIM_KEYS" '{use_default: false, include_claim_keys: $keys}') +current_sub=$(gh api "repos/$REPO/actions/oidc/customization/sub") +if jq -e --argjson want "$sub_payload" '. == $want' <<<"$current_sub" >/dev/null; then + echo "OK: OIDC sub claim template is $SUB_CLAIM_KEYS" +else + change "set OIDC sub claim template to $sub_payload (currently $current_sub)" \ + gh api -X PUT "repos/$REPO/actions/oidc/customization/sub" --input - <<<"$sub_payload" +fi diff --git a/terraform/environments/aws/ci-cd-permissions/github_oidc.tf b/terraform/environments/aws/ci-cd-permissions/github_oidc.tf index 233a41d5..5c7bdb40 100644 --- a/terraform/environments/aws/ci-cd-permissions/github_oidc.tf +++ b/terraform/environments/aws/ci-cd-permissions/github_oidc.tf @@ -1,6 +1,13 @@ -resource "aws_iam_openid_connect_provider" "github" { - count = var.github_repo != "" ? 1 : 0 +locals { + # Every OIDC `sub` this repo presents starts with this prefix once + # scripts/bootstrap-github-deploy-config.sh has set the repo's sub claim + # template to ["repository_owner_id", "repository_id", "context"]. Keep the + # two in lockstep: the template decides the subject GitHub mints, this + # decides the subject AWS accepts. + github_oidc_sub_prefix = "repository_owner_id:${var.github_repository_owner_id}:repository_id:${var.github_repository_id}" +} +resource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] @@ -9,3 +16,8 @@ resource "aws_iam_openid_connect_provider" "github" { # is effectively a formality, but at least one thumbprint is required by the API. thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] } + +moved { + from = aws_iam_openid_connect_provider.github[0] + to = aws_iam_openid_connect_provider.github +} diff --git a/terraform/environments/aws/ci-cd-permissions/outputs.tf b/terraform/environments/aws/ci-cd-permissions/outputs.tf index f3f6a842..7de65b1b 100644 --- a/terraform/environments/aws/ci-cd-permissions/outputs.tf +++ b/terraform/environments/aws/ci-cd-permissions/outputs.tf @@ -18,6 +18,6 @@ output "policy_arns" { } output "oidc_provider_arn" { - description = "ARN of the GitHub Actions OIDC provider (null if github_repo is empty)" - value = var.github_repo != "" ? aws_iam_openid_connect_provider.github[0].arn : null + description = "ARN of the GitHub Actions OIDC provider" + value = aws_iam_openid_connect_provider.github.arn } diff --git a/terraform/environments/aws/ci-cd-permissions/role.tf b/terraform/environments/aws/ci-cd-permissions/role.tf index 6d4cc3b6..0ce9fcdc 100644 --- a/terraform/environments/aws/ci-cd-permissions/role.tf +++ b/terraform/environments/aws/ci-cd-permissions/role.tf @@ -12,19 +12,23 @@ resource "aws_iam_role" "cudly_deploy" { Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:${var.trust_principal}" } Action = "sts:AssumeRole" }] : [], - # Optional: allow GitHub Actions via OIDC - var.github_repo != "" ? [{ + # GitHub Actions via OIDC + [{ Effect = "Allow" Principal = { - Federated = aws_iam_openid_connect_provider.github[0].arn + Federated = aws_iam_openid_connect_provider.github.arn } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" - # Restrict to protected branches and named deployment environments. - # Any-branch wildcard (repo:org/repo:*) would let a developer push to - # an unprotected feature branch and mint valid deploy credentials. + # Restrict to main and named deployment environments. An any-ref + # wildcard (`:*`) would let a developer push to an + # unprotected feature branch and mint valid deploy credentials. + # Subjects carry the repository's immutable owner and repo IDs, + # not its name, so a rename or a new repo reusing a freed name + # neither gains nor loses deploy rights (see local + # github_oidc_sub_prefix in github_oidc.tf). # # An OIDC `sub` is EITHER `...:ref:refs/heads/` OR # `...:environment:` -- never both -- so every job in every @@ -45,11 +49,11 @@ resource "aws_iam_role" "cudly_deploy" { # the first of them: # 1. Allowlist membership (THIS list): can the job authenticate # to AWS at all? Nothing else below matters if this says no. - # 2. Deployment branch policy (GitHub environment setting, - # manual, NOT configured by this module): which BRANCH may - # trigger a deploy to that environment. This is what "only - # main may deploy" actually requires, and nothing here sets - # it. + # 2. Deployment branch policy (GitHub environment setting, NOT + # configured by this module; set to main-only by + # scripts/bootstrap-github-deploy-config.sh): which BRANCH + # may trigger a deploy to that environment. This is what + # "only main may deploy" actually requires. # 3. Required reviewers / protection rules (GitHub environment # setting, manual, NOT configured by this module): WHO must # approve before a job bound to that environment proceeds. @@ -64,11 +68,9 @@ resource "aws_iam_role" "cudly_deploy" { # environment-bound job presents the identical subject a `main` # run would. Until a deployment branch policy exists on that # environment, this allowlist delivers "only these environments - # deploy, from any branch", not "only main deploys" -- and per a - # live check against the GitHub API, none of the environments - # below have one, or any protection rules, today. See the PR that - # added this comment for the full list of environments needing - # that policy, and which of them exist yet at all. + # deploy, from any branch", not "only main deploys". Run the + # bootstrap script before applying this module so every + # environment below exists with a main-only branch policy. # # Deliberately NOT listed, and why: # - `pull_request` subjects: no job that assumes THIS role @@ -89,7 +91,7 @@ resource "aws_iam_role" "cudly_deploy" { # caller is ever added, this must be revisited before that # caller can authenticate. "token.actions.githubusercontent.com:sub" = [ - "repo:${var.github_repo}:ref:refs/heads/main", + "${local.github_oidc_sub_prefix}:ref:refs/heads/main", # Bare deployment environments. Bound directly by # deploy-aws-lambda.yml's build-and-deploy / test-deployment jobs @@ -98,34 +100,27 @@ resource "aws_iam_role" "cudly_deploy" { # once #1674 merges -- by cleanup-staging.yml's two AWS destroy # jobs (environment: staging) and destroy-fargate-dev.yml's # destroy job (environment: dev). - # Live check (`gh api repos/.../environments`): `dev` exists, - # `staging`/`prod` do not. None has a deployment branch policy or - # protection rules -- see the top-of-list note. - "repo:${var.github_repo}:environment:dev", - "repo:${var.github_repo}:environment:staging", - "repo:${var.github_repo}:environment:prod", + "${local.github_oidc_sub_prefix}:environment:dev", + "${local.github_oidc_sub_prefix}:environment:staging", + "${local.github_oidc_sub_prefix}:environment:prod", # deploy-aws-fargate.yml's `deploy` job binds to # `aws-fargate-${{ needs.prepare.outputs.environment }}`, and that # output is always dev/staging/prod (workflow_dispatch choice # input, workflow_call from deploy-all.yml which is itself # choice-constrained, or the untriggered-input "dev" fallback). - # Live check: `aws-fargate-dev` and `aws-fargate-staging` exist, - # `aws-fargate-prod` does not. Same "no branch policy, no - # protection rules" caveat as above applies to all three. - "repo:${var.github_repo}:environment:aws-fargate-dev", - "repo:${var.github_repo}:environment:aws-fargate-staging", - "repo:${var.github_repo}:environment:aws-fargate-prod", + "${local.github_oidc_sub_prefix}:environment:aws-fargate-dev", + "${local.github_oidc_sub_prefix}:environment:aws-fargate-staging", + "${local.github_oidc_sub_prefix}:environment:aws-fargate-prod", # database-migration.yml's `migrate-aws` job binds to # `aws-db-${{ inputs.environment }}` on its `workflow_dispatch` # trigger, where `inputs.environment` is a choice input # constrained to dev/staging/prod. See the workflow_call caveat - # above for what is deliberately excluded. Live check: none of - # the three exist yet. - "repo:${var.github_repo}:environment:aws-db-dev", - "repo:${var.github_repo}:environment:aws-db-staging", - "repo:${var.github_repo}:environment:aws-db-prod", + # above for what is deliberately excluded. + "${local.github_oidc_sub_prefix}:environment:aws-db-dev", + "${local.github_oidc_sub_prefix}:environment:aws-db-staging", + "${local.github_oidc_sub_prefix}:environment:aws-db-prod", # rollback.yml's rollback-aws-lambda binds to plain # dev/staging/prod (covered above) and rollback-aws-fargate @@ -138,7 +133,7 @@ resource "aws_iam_role" "cudly_deploy" { ] } } - }] : [] + }] ) }) diff --git a/terraform/environments/aws/ci-cd-permissions/terraform.tfvars.example b/terraform/environments/aws/ci-cd-permissions/terraform.tfvars.example index 1be17f02..42279acc 100644 --- a/terraform/environments/aws/ci-cd-permissions/terraform.tfvars.example +++ b/terraform/environments/aws/ci-cd-permissions/terraform.tfvars.example @@ -10,17 +10,29 @@ # aws_region = "us-east-1" -# ─── github_repo ─────────────────────────────────────────────────────────────── -# GitHub repository (owner/name) whose Actions workflows may assume the deploy -# role via OIDC (keyless — no access keys needed). +# ─── github_repository_id / github_repository_owner_id ───────────────────────── +# Immutable numeric IDs of the GitHub repository whose Actions workflows may +# deploy, and of its owning account. The deploy trust is keyed on these IDs, +# not on owner/name, so a rename (or a new repo that reuses a freed name) +# neither gains nor loses deploy rights. # -# Creates an IAM OIDC identity provider for token.actions.githubusercontent.com -# and updates the role trust policy to allow sts:AssumeRoleWithWebIdentity from -# the specified repo. +# Look them up (read-only): +# gh api repos/LeanerCloud/cloud-commitments-platform --jq '.id,.owner.id' # -# Set to "" to skip OIDC provider creation. +# Values for LeanerCloud/cloud-commitments-platform as of 2026-09-29: +# github_repository_id = 1391259743 +# github_repository_owner_id = 86753534 (LeanerCloud) # -github_repo = "LeanerCloud/cloud-commitments-platform" +# The trust only matches once scripts/bootstrap-github-deploy-config.sh --apply +# has set the repo's OIDC sub claim template; see +# docs/deploy-trust-bootstrap.md for the apply order. +# +# Subjects allowed to assume the role: +# repository_owner_id::repository_id::ref:refs/heads/main +# repository_owner_id::repository_id::environment: +# +github_repository_id = "1391259743" +github_repository_owner_id = "86753534" # ─── trust_principal ─────────────────────────────────────────────────────────── # (Optional) IAM principal in the same account allowed to assume the deploy diff --git a/terraform/environments/aws/ci-cd-permissions/variables.tf b/terraform/environments/aws/ci-cd-permissions/variables.tf index 2eae3ddd..d0bc6afa 100644 --- a/terraform/environments/aws/ci-cd-permissions/variables.tf +++ b/terraform/environments/aws/ci-cd-permissions/variables.tf @@ -10,8 +10,22 @@ variable "trust_principal" { default = "" } -variable "github_repo" { - description = "GitHub repository (owner/name) whose Actions workflows may assume the deploy role via OIDC (e.g. 'LeanerCloud/cloud-commitments-platform'). Leave empty to skip OIDC setup." +variable "github_repository_id" { + description = "Immutable numeric ID of the GitHub repository whose Actions workflows may deploy (gh api repos// --jq .id). Trust is keyed on this ID, not owner/name, so a rename or a new repo reusing a freed name neither gains nor loses deploy rights." type = string - default = "LeanerCloud/cloud-commitments-platform" + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_id)) + error_message = "github_repository_id must be the repository's numeric ID (gh api repos// --jq .id), not its owner/name." + } +} + +variable "github_repository_owner_id" { + description = "Immutable numeric ID of the GitHub account that owns the repository (gh api repos// --jq .owner.id)." + type = string + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_owner_id)) + error_message = "github_repository_owner_id must be the owner's numeric ID (gh api repos// --jq .owner.id), not its login." + } } diff --git a/terraform/environments/azure/ci-cd-permissions/sp.tf b/terraform/environments/azure/ci-cd-permissions/sp.tf index e7e26a2c..f55ecc27 100644 --- a/terraform/environments/azure/ci-cd-permissions/sp.tf +++ b/terraform/environments/azure/ci-cd-permissions/sp.tf @@ -25,9 +25,16 @@ resource "azuread_service_principal" "cudly_deploy" { # Azure federated credentials require one entry per allowed subject (no # wildcards), so each named deployment environment needs its own resource. # +# Subjects carry the repository's immutable owner and repo IDs, not its name, +# so a rename or a new repo reusing a freed name neither gains nor loses deploy +# rights. GitHub mints this shape only after +# scripts/bootstrap-github-deploy-config.sh sets the repo's OIDC sub claim +# template to ["repository_owner_id", "repository_id", "context"]; keep the +# two in lockstep. +# # The `github_environment` entries below are what let an environment-bound job # authenticate at all. A job carrying `environment: staging` presents the -# subject `repo::environment:staging`, NOT the main-branch subject, +# subject `:environment:staging`, NOT the main-branch subject, # so without a matching credential `azure/login` fails with AADSTS70021 even # though the workflow is running on main. Adding an `environment:` binding to # an Azure job and forgetting this is the exact breakage recorded in #1648. @@ -36,15 +43,22 @@ resource "azuread_service_principal" "cudly_deploy" { # by a privileged human, not by the deploy workflow. The Azure destroy job in # cleanup-staging.yml cannot authenticate until that re-apply happens. -resource "azuread_application_federated_identity_credential" "github_main" { - count = var.github_repo != "" ? 1 : 0 +locals { + github_oidc_sub_prefix = "repository_owner_id:${var.github_repository_owner_id}:repository_id:${var.github_repository_id}" +} +resource "azuread_application_federated_identity_credential" "github_main" { application_id = azuread_application.cudly_deploy.id display_name = "github-actions-main" - description = "GitHub Actions OIDC — ${var.github_repo} main branch deployments" + description = "GitHub Actions OIDC - repository ${var.github_repository_id} main branch deployments" audiences = ["api://AzureADTokenExchange"] issuer = "https://token.actions.githubusercontent.com" - subject = "repo:${var.github_repo}:ref:refs/heads/main" + subject = "${local.github_oidc_sub_prefix}:ref:refs/heads/main" +} + +moved { + from = azuread_application_federated_identity_credential.github_main[0] + to = azuread_application_federated_identity_credential.github_main } # No `pull_request`-subject credential: this service principal holds @@ -67,12 +81,12 @@ resource "azuread_application_federated_identity_credential" "github_main" { # AWS role's trust policy already allows # (terraform/environments/aws/ci-cd-permissions/role.tf). resource "azuread_application_federated_identity_credential" "github_environment" { - for_each = var.github_repo != "" ? toset(var.github_environments) : toset([]) + for_each = toset(var.github_environments) application_id = azuread_application.cudly_deploy.id display_name = "github-actions-env-${each.value}" - description = "GitHub Actions OIDC — ${var.github_repo} ${each.value} environment" + description = "GitHub Actions OIDC - repository ${var.github_repository_id} ${each.value} environment" audiences = ["api://AzureADTokenExchange"] issuer = "https://token.actions.githubusercontent.com" - subject = "repo:${var.github_repo}:environment:${each.value}" + subject = "${local.github_oidc_sub_prefix}:environment:${each.value}" } diff --git a/terraform/environments/azure/ci-cd-permissions/terraform.tfvars.example b/terraform/environments/azure/ci-cd-permissions/terraform.tfvars.example index e4a3d1af..b2fe604d 100644 --- a/terraform/environments/azure/ci-cd-permissions/terraform.tfvars.example +++ b/terraform/environments/azure/ci-cd-permissions/terraform.tfvars.example @@ -15,18 +15,32 @@ # subscription_id = "00000000-0000-0000-0000-000000000000" -# ─── github_repo ─────────────────────────────────────────────────────────────── -# GitHub repository (owner/name) whose Actions workflows will authenticate -# via Azure AD Federated Identity Credentials (keyless — no client secrets). +# ─── github_repository_id / github_repository_owner_id ───────────────────────── +# Immutable numeric IDs of the GitHub repository whose Actions workflows may +# deploy, and of its owning account. The deploy trust is keyed on these IDs, +# not on owner/name, so a rename (or a new repo that reuses a freed name) +# neither gains nor loses deploy rights. +# +# Look them up (read-only): +# gh api repos/LeanerCloud/cloud-commitments-platform --jq '.id,.owner.id' +# +# Values for LeanerCloud/cloud-commitments-platform as of 2026-09-29: +# github_repository_id = 1391259743 +# github_repository_owner_id = 86753534 (LeanerCloud) +# +# The trust only matches once scripts/bootstrap-github-deploy-config.sh --apply +# has set the repo's OIDC sub claim template; see +# docs/deploy-trust-bootstrap.md for the apply order. # # Federated credentials are created for: -# - main branch deployments: repo:/:ref:refs/heads/main -# - each entry in github_environments: repo:/:environment: +# - main branch deployments: +# repository_owner_id::repository_id::ref:refs/heads/main +# - each entry in github_environments: +# repository_owner_id::repository_id::environment: # # No pull_request-subject credential is created (see #90): it is not scoped to # any branch or environment protection, and nothing in this repo authenticates # to Azure on a pull_request trigger. # -# Set to "" to skip federated credential and service principal creation. -# -github_repo = "LeanerCloud/cloud-commitments-platform" +github_repository_id = "1391259743" +github_repository_owner_id = "86753534" diff --git a/terraform/environments/azure/ci-cd-permissions/variables.tf b/terraform/environments/azure/ci-cd-permissions/variables.tf index a11cd6f3..45cb8f73 100644 --- a/terraform/environments/azure/ci-cd-permissions/variables.tf +++ b/terraform/environments/azure/ci-cd-permissions/variables.tf @@ -3,17 +3,32 @@ variable "subscription_id" { type = string } -variable "github_repo" { - description = "GitHub repository (owner/name) whose Actions workflows may authenticate via federated identity credentials (e.g. 'LeanerCloud/cloud-commitments-platform'). Leave empty to skip federated credential setup." +variable "github_repository_id" { + description = "Immutable numeric ID of the GitHub repository whose Actions workflows may deploy (gh api repos// --jq .id). Trust is keyed on this ID, not owner/name, so a rename or a new repo reusing a freed name neither gains nor loses deploy rights." type = string - default = "LeanerCloud/cloud-commitments-platform" + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_id)) + error_message = "github_repository_id must be the repository's numeric ID (gh api repos// --jq .id), not its owner/name." + } +} + +variable "github_repository_owner_id" { + description = "Immutable numeric ID of the GitHub account that owns the repository (gh api repos// --jq .owner.id)." + type = string + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_owner_id)) + error_message = "github_repository_owner_id must be the owner's numeric ID (gh api repos// --jq .owner.id), not its login." + } } variable "github_environments" { description = <<-EOT GitHub deployment environment names whose jobs may authenticate via federated identity credentials. A job carrying `environment: ` presents the OIDC - subject repo::environment:, so a name absent from this list + subject :environment: (see local.github_oidc_sub_prefix in + sp.tf), so a name absent from this list cannot authenticate (AADSTS70021). NOTE the subject is ref-agnostic: it does not encode the branch, so a @@ -49,6 +64,6 @@ variable "github_environments" { validation { condition = alltrue([for e in var.github_environments : trimspace(e) != ""]) - error_message = "github_environments entries must be non-empty; an empty name yields the unmatchable subject repo::environment:." + error_message = "github_environments entries must be non-empty; an empty name yields the unmatchable subject :environment:." } } diff --git a/terraform/environments/deploy_trust_guard_test.go b/terraform/environments/deploy_trust_guard_test.go new file mode 100644 index 00000000..156643ad --- /dev/null +++ b/terraform/environments/deploy_trust_guard_test.go @@ -0,0 +1,232 @@ +// Guards the rename-proof CI/CD deploy trust (cloud-commitments-platform#2). +// GitHub's `repository` claim and default `sub` follow the repo's current +// name, so a trust keyed on owner/name breaks on a rename and can be claimed +// by a new repo that reuses a freed name. The ci-cd-permissions modules must +// match only the immutable repository_id / repository_owner_id, and the +// bootstrap script must configure GitHub to mint the subject shape the +// modules expect and gate every environment the modules trust. +package environments_test + +import ( + "os" + "regexp" + "strings" + "testing" +) + +const ( + subPrefixRef = "${local.github_oidc_sub_prefix}:" + bootstrap = "../../scripts/bootstrap-github-deploy-config.sh" +) + +var ( + // Identifiers that tie trust to the repository name rather than its ID. + nameKeyedTrust = []string{ + "var.github_repo}", "var.github_repo ", `"repo:`, + "assertion.repository ==", "attribute.repository/", + } + subPrefixDecl = regexp.MustCompile(`github_oidc_sub_prefix\s*=\s*"([^"]+)"`) + prefixClaim = regexp.MustCompile(`([a-z_]+):\$\{var\.github_[a-z_]+\}`) +) + +// readHCL returns the file with `#` comment lines removed, so prose that +// mentions the old subject shape cannot satisfy or trip a check. +func readHCL(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + var kept []string + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "#") { + kept = append(kept, line) + } + } + return strings.Join(kept, "\n") +} + +func assertNotNameKeyed(t *testing.T, path, src string) { + t.Helper() + for _, bad := range nameKeyedTrust { + if strings.Contains(src, bad) { + t.Errorf("%s: trust references the repository name via %q; key it on github_repository_id / github_repository_owner_id", path, bad) + } + } +} + +// subPrefixClaims returns the claim keys, in order, of the module's +// github_oidc_sub_prefix local, after checking it binds both immutable IDs. +func subPrefixClaims(t *testing.T, path, src string) []string { + t.Helper() + m := subPrefixDecl.FindStringSubmatch(src) + if m == nil { + t.Fatalf("%s: no github_oidc_sub_prefix local", path) + } + const want = "repository_owner_id:${var.github_repository_owner_id}:repository_id:${var.github_repository_id}" + if m[1] != want { + t.Fatalf("%s: github_oidc_sub_prefix = %q, want %q", path, m[1], want) + } + var keys []string + for _, c := range prefixClaim.FindAllStringSubmatch(m[1], -1) { + keys = append(keys, c[1]) + } + return keys +} + +func assertPrefixed(t *testing.T, path string, subjects []string) { + t.Helper() + if len(subjects) == 0 { + t.Fatalf("%s: found no trusted subjects to check", path) + } + for _, s := range subjects { + if !strings.HasPrefix(s, subPrefixRef) { + t.Errorf("%s: subject %q does not start with %s", path, s, subPrefixRef) + } + } +} + +func quoted(block string) []string { + var out []string + for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(block, -1) { + out = append(out, m[1]) + } + return out +} + +func environmentsOf(subjects []string) []string { + var envs []string + for _, s := range subjects { + if env, ok := strings.CutPrefix(s, subPrefixRef+"environment:"); ok { + envs = append(envs, env) + } + } + return envs +} + +func bootstrapScript(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(bootstrap) + if err != nil { + t.Fatalf("reading %s: %v", bootstrap, err) + } + return string(data) +} + +func bootstrapClaimKeys(t *testing.T, script string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^SUB_CLAIM_KEYS='([^']*)'$`).FindStringSubmatch(script) + if m == nil { + t.Fatalf("%s: no SUB_CLAIM_KEYS assignment", bootstrap) + } + return m[1] +} + +func bootstrapEnvironments(t *testing.T, script string) map[string]bool { + t.Helper() + m := regexp.MustCompile(`(?s)\nENVIRONMENTS=\(\n(.*?)\n\)`).FindStringSubmatch(script) + if m == nil { + t.Fatalf("%s: no ENVIRONMENTS array", bootstrap) + } + envs := map[string]bool{} + for _, e := range strings.Fields(m[1]) { + envs[e] = true + } + return envs +} + +func TestAWSDeployTrustIsKeyedOnRepositoryID(t *testing.T) { + oidc := readHCL(t, "aws/ci-cd-permissions/github_oidc.tf") + role := readHCL(t, "aws/ci-cd-permissions/role.tf") + assertNotNameKeyed(t, "aws github_oidc.tf", oidc) + assertNotNameKeyed(t, "aws role.tf", role) + checkClaimKeysMatchBootstrap(t, "aws", subPrefixClaims(t, "aws github_oidc.tf", oidc)) + + m := regexp.MustCompile(`(?s)"token\.actions\.githubusercontent\.com:sub"\s*=\s*\[(.*?)\]`).FindStringSubmatch(role) + if m == nil { + t.Fatal("aws role.tf: no token.actions.githubusercontent.com:sub condition") + } + subjects := quoted(m[1]) + assertPrefixed(t, "aws role.tf", subjects) + checkEnvironmentsGated(t, "aws", environmentsOf(subjects)) +} + +func TestAzureDeployTrustIsKeyedOnRepositoryID(t *testing.T) { + sp := readHCL(t, "azure/ci-cd-permissions/sp.tf") + assertNotNameKeyed(t, "azure sp.tf", sp) + checkClaimKeysMatchBootstrap(t, "azure", subPrefixClaims(t, "azure sp.tf", sp)) + + var subjects []string + for _, m := range regexp.MustCompile(`(?m)^\s*subject\s*=\s*"([^"]*)"`).FindAllStringSubmatch(sp, -1) { + subjects = append(subjects, m[1]) + } + assertPrefixed(t, "azure sp.tf", subjects) + + vars := readHCL(t, "azure/ci-cd-permissions/variables.tf") + m := regexp.MustCompile(`(?s)variable "github_environments".*?default\s*=\s*\[([^\]]*)\]`).FindStringSubmatch(vars) + if m == nil { + t.Fatal("azure variables.tf: no github_environments default") + } + checkEnvironmentsGated(t, "azure", quoted(m[1])) +} + +func TestGCPDeployTrustIsKeyedOnRepositoryID(t *testing.T) { + src := readHCL(t, "gcp/ci-cd-permissions/github_oidc.tf") + assertNotNameKeyed(t, "gcp github_oidc.tf", src) + + for _, want := range []string{ + `"attribute.repository_id" = "assertion.repository_id"`, + `"attribute.repository_owner_id" = "assertion.repository_owner_id"`, + } { + if !strings.Contains(src, want) { + t.Errorf("gcp github_oidc.tf: attribute_mapping missing %s", want) + } + } + + m := regexp.MustCompile(`attribute_condition\s*=\s*"([^"]*)"`).FindStringSubmatch(src) + if m == nil { + t.Fatal("gcp github_oidc.tf: no attribute_condition") + } + for _, want := range []string{ + "assertion.repository_owner_id == '${var.github_repository_owner_id}'", + "assertion.repository_id == '${var.github_repository_id}'", + "assertion.ref == '${var.deploy_ref}'", + } { + if !strings.Contains(m[1], want) { + t.Errorf("gcp attribute_condition %q missing %q", m[1], want) + } + } + if strings.Contains(m[1], "||") { + t.Errorf("gcp attribute_condition %q contains ||; every clause must be required", m[1]) + } + + if !strings.Contains(src, `/attribute.repository_id/${var.github_repository_id}"`) { + t.Error("gcp github_oidc.tf: workloadIdentityUser member must be the attribute.repository_id principalSet") + } +} + +// The bootstrap script's sub claim template decides the subject GitHub mints; +// the modules' prefix decides what the clouds accept. A mismatch fails every +// deploy with an opaque auth error. +func checkClaimKeysMatchBootstrap(t *testing.T, cloud string, prefixKeys []string) { + t.Helper() + want := `["` + strings.Join(append(prefixKeys, "context"), `","`) + `"]` + if got := bootstrapClaimKeys(t, bootstrapScript(t)); got != want { + t.Errorf("%s: bootstrap SUB_CLAIM_KEYS = %s, want %s to match github_oidc_sub_prefix", cloud, got, want) + } +} + +// An environment subject is ref-agnostic, so a trusted environment the +// bootstrap script does not limit to main can be deployed from any branch. +func checkEnvironmentsGated(t *testing.T, cloud string, envs []string) { + t.Helper() + if len(envs) == 0 { + t.Fatalf("%s: found no trusted environments to check", cloud) + } + gated := bootstrapEnvironments(t, bootstrapScript(t)) + for _, e := range envs { + if !gated[e] { + t.Errorf("%s trusts environment %q, which %s does not limit to main", cloud, e, bootstrap) + } + } +} diff --git a/terraform/environments/gcp/ci-cd-permissions/github_oidc.tf b/terraform/environments/gcp/ci-cd-permissions/github_oidc.tf index 94aecf87..ba16a0cc 100644 --- a/terraform/environments/gcp/ci-cd-permissions/github_oidc.tf +++ b/terraform/environments/gcp/ci-cd-permissions/github_oidc.tf @@ -1,16 +1,17 @@ resource "google_iam_workload_identity_pool" "github" { - count = var.github_repo != "" ? 1 : 0 - workload_identity_pool_id = "github-actions" display_name = "GitHub Actions" description = "Workload Identity Pool for GitHub Actions OIDC" project = var.project_id } -resource "google_iam_workload_identity_pool_provider" "github" { - count = var.github_repo != "" ? 1 : 0 +moved { + from = google_iam_workload_identity_pool.github[0] + to = google_iam_workload_identity_pool.github +} - workload_identity_pool_id = google_iam_workload_identity_pool.github[0].workload_identity_pool_id +resource "google_iam_workload_identity_pool_provider" "github" { + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id workload_identity_pool_provider_id = "github-actions" display_name = "GitHub Actions" project = var.project_id @@ -20,28 +21,41 @@ resource "google_iam_workload_identity_pool_provider" "github" { } # Map GitHub token claims to Google attributes for use in conditions and bindings. + # attribute.repository is kept for audit logs only; nothing grants on it. attribute_mapping = { - "google.subject" = "assertion.sub" - "attribute.actor" = "assertion.actor" - "attribute.repository" = "assertion.repository" - "attribute.ref" = "assertion.ref" + "google.subject" = "assertion.sub" + "attribute.actor" = "assertion.actor" + "attribute.repository" = "assertion.repository" + "attribute.repository_id" = "assertion.repository_id" + "attribute.repository_owner_id" = "assertion.repository_owner_id" + "attribute.ref" = "assertion.ref" } # Restrict to the specific repo AND only allow the designated deploy branch. - # PRs, tags, forks, and workflow_dispatch from other refs are blocked. + # PRs, tags, forks, and workflow_dispatch from other refs are blocked. The repo + # is matched on its immutable owner and repo IDs, not its name, so a rename or + # a new repo reusing a freed name neither gains nor loses deploy rights. # # deploy_ref defaults to refs/heads/main. Override in terraform.tfvars only # when temporarily deploying from a feature branch; flip back to main once # that branch merges. Forgetting to flip locks out main-branch deploys with # "unauthorized_client: The given credential is rejected by the attribute condition." - attribute_condition = "assertion.repository == '${var.github_repo}' && assertion.ref == '${var.deploy_ref}'" + attribute_condition = "assertion.repository_owner_id == '${var.github_repository_owner_id}' && assertion.repository_id == '${var.github_repository_id}' && assertion.ref == '${var.deploy_ref}'" +} + +moved { + from = google_iam_workload_identity_pool_provider.github[0] + to = google_iam_workload_identity_pool_provider.github } # Allow workflows from the permitted repo+ref to impersonate the deploy SA. resource "google_service_account_iam_member" "github_actions" { - count = var.github_repo != "" ? 1 : 0 - service_account_id = google_service_account.cudly_deploy.name role = "roles/iam.workloadIdentityUser" - member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github[0].name}/attribute.repository/${var.github_repo}" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository_id/${var.github_repository_id}" +} + +moved { + from = google_service_account_iam_member.github_actions[0] + to = google_service_account_iam_member.github_actions } diff --git a/terraform/environments/gcp/ci-cd-permissions/outputs.tf b/terraform/environments/gcp/ci-cd-permissions/outputs.tf index 99b8204f..103f83af 100644 --- a/terraform/environments/gcp/ci-cd-permissions/outputs.tf +++ b/terraform/environments/gcp/ci-cd-permissions/outputs.tf @@ -15,5 +15,5 @@ output "roles" { output "workload_identity_provider" { description = "Full resource name of the Workload Identity Pool Provider — use as GCP_WORKLOAD_IDENTITY_PROVIDER in GitHub Actions" - value = var.github_repo != "" ? google_iam_workload_identity_pool_provider.github[0].name : null + value = google_iam_workload_identity_pool_provider.github.name } diff --git a/terraform/environments/gcp/ci-cd-permissions/terraform.tfvars.example b/terraform/environments/gcp/ci-cd-permissions/terraform.tfvars.example index e650fd97..dae1337d 100644 --- a/terraform/environments/gcp/ci-cd-permissions/terraform.tfvars.example +++ b/terraform/environments/gcp/ci-cd-permissions/terraform.tfvars.example @@ -19,17 +19,29 @@ # project_id = "your-gcp-project-id" -# ─── github_repo ─────────────────────────────────────────────────────────────── -# GitHub repository (owner/name) whose Actions workflows may impersonate the -# deploy service account via Workload Identity Federation (keyless — no JSON -# key files needed). +# ─── github_repository_id / github_repository_owner_id ───────────────────────── +# Immutable numeric IDs of the GitHub repository whose Actions workflows may +# deploy, and of its owning account. The deploy trust is keyed on these IDs, +# not on owner/name, so a rename (or a new repo that reuses a freed name) +# neither gains nor loses deploy rights. # -# Creates a WIF pool + OIDC provider and binds the SA with -# roles/iam.workloadIdentityUser restricted to the specified repository. +# Look them up (read-only): +# gh api repos/LeanerCloud/cloud-commitments-platform --jq '.id,.owner.id' # -# Set to "" to skip WIF setup. +# Values for LeanerCloud/cloud-commitments-platform as of 2026-09-29: +# github_repository_id = 1391259743 +# github_repository_owner_id = 86753534 (LeanerCloud) # -github_repo = "LeanerCloud/cloud-commitments-platform" +# The trust only matches once scripts/bootstrap-github-deploy-config.sh --apply +# has set the repo's OIDC sub claim template; see +# docs/deploy-trust-bootstrap.md for the apply order. +# +# Creates a WIF pool + OIDC provider whose attribute condition requires both +# IDs and deploy_ref, and binds the SA with roles/iam.workloadIdentityUser on +# attribute.repository_id. +# +github_repository_id = "1391259743" +github_repository_owner_id = "86753534" # ─── deploy_ref ──────────────────────────────────────────────────────────────── # Git ref whose workflows are permitted to impersonate the deploy SA. diff --git a/terraform/environments/gcp/ci-cd-permissions/variables.tf b/terraform/environments/gcp/ci-cd-permissions/variables.tf index 573965b9..eb83f96a 100644 --- a/terraform/environments/gcp/ci-cd-permissions/variables.tf +++ b/terraform/environments/gcp/ci-cd-permissions/variables.tf @@ -3,10 +3,24 @@ variable "project_id" { type = string } -variable "github_repo" { - description = "GitHub repository (owner/name) whose Actions workflows may impersonate the deploy SA via Workload Identity Federation (e.g. 'LeanerCloud/cloud-commitments-platform'). Leave empty to skip WIF setup." +variable "github_repository_id" { + description = "Immutable numeric ID of the GitHub repository whose Actions workflows may deploy (gh api repos// --jq .id). Trust is keyed on this ID, not owner/name, so a rename or a new repo reusing a freed name neither gains nor loses deploy rights." type = string - default = "LeanerCloud/cloud-commitments-platform" + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_id)) + error_message = "github_repository_id must be the repository's numeric ID (gh api repos// --jq .id), not its owner/name." + } +} + +variable "github_repository_owner_id" { + description = "Immutable numeric ID of the GitHub account that owns the repository (gh api repos// --jq .owner.id)." + type = string + + validation { + condition = can(regex("^[1-9][0-9]*$", var.github_repository_owner_id)) + error_message = "github_repository_owner_id must be the owner's numeric ID (gh api repos// --jq .owner.id), not its login." + } } variable "deploy_ref" { From c991696db10407510e830dcbaf143ff8d5ac4b32 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 29 Sep 2026 13:27:02 +0200 Subject: [PATCH 2/2] docs(iac): deploy trust bootstrap runbook with apply order and rollback 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 --- docs/deploy-trust-bootstrap.md | 142 ++++++++++++++++++ .../aws/ci-cd-permissions/README.md | 64 ++++---- .../azure/ci-cd-permissions/README.md | 25 ++- .../gcp/ci-cd-permissions/README.md | 18 ++- 4 files changed, 201 insertions(+), 48 deletions(-) create mode 100644 docs/deploy-trust-bootstrap.md diff --git a/docs/deploy-trust-bootstrap.md b/docs/deploy-trust-bootstrap.md new file mode 100644 index 00000000..114e6102 --- /dev/null +++ b/docs/deploy-trust-bootstrap.md @@ -0,0 +1,142 @@ +# Deploy trust bootstrap runbook + +The CI/CD deploy identities in `terraform/environments/{aws,azure,gcp}/ci-cd-permissions/` trust +GitHub Actions tokens from this repository, matched on its immutable IDs rather than its +`owner/name`. A rename, or a new repository that reuses a freed name, neither gains nor loses +deploy rights. Only `main` and the defined deployment environments can deploy. + +This is a bootstrap step (see "CI/CD IAM" in `CLAUDE.md`). A privileged human runs it by hand. +The deploy workflows never run it. + +## What each cloud accepts + +The repository's OIDC `sub` claim template is set to +`["repository_owner_id", "repository_id", "context"]`, so GitHub mints subjects of the form: + +```text +repository_owner_id::repository_id::ref:refs/heads/main +repository_owner_id::repository_id::environment: +``` + +| Cloud | Trust | Matches | +| --- | --- | --- | +| AWS | `cudly-terraform-deploy` role, `StringEquals` on `token.actions.githubusercontent.com:sub` | the main-branch subject and one environment subject per name listed in `role.tf` | +| Azure | `cudly-terraform-deploy` app, one federated credential per subject | the main-branch subject and one environment subject per `var.github_environments` entry | +| GCP | WIF provider `github-actions` | `attribute_condition`: `assertion.repository_owner_id == '' && assertion.repository_id == '' && assertion.ref == ''`; the deploy SA grants `roles/iam.workloadIdentityUser` to `attribute.repository_id/` | + +An environment subject does not say which branch triggered the run. Each environment's +deployment branch policy, limited to `main`, is what keeps other branches out. The bootstrap +script sets that policy. + +For `LeanerCloud/cloud-commitments-platform`, `gh api repos/LeanerCloud/cloud-commitments-platform +--jq '.id,.owner.id'` returns `1391259743` and `86753534`. + +## Prerequisites + +- `main` is protected (#154). Protection controls who can reach the `ref:refs/heads/main` subject. +- A `gh` login with admin rights on the repository, plus `jq`. +- For each cloud, the credentials listed under "Prerequisites" in that module's `README.md`. + +## Apply order + +### 0. Record the current state + +The rollback steps below restore from these files. + +```bash +gh api repos/LeanerCloud/cloud-commitments-platform/actions/oidc/customization/sub > oidc-sub.before.json +gh api repos/LeanerCloud/cloud-commitments-platform/environments > environments.before.json +``` + +### 1. Configure GitHub + +```bash +scripts/bootstrap-github-deploy-config.sh # dry run: prints each WOULD: line +scripts/bootstrap-github-deploy-config.sh --apply +scripts/bootstrap-github-deploy-config.sh # expect only OK: lines +``` + +The script: + +- creates every environment the workflows bind to (`dev`, `staging`, `prod`, + `aws-fargate-*`, `aws-db-*`, `gcp-db-*` and `azure-db-*`, each for `dev`/`staging`/`prod`), with + custom deployment branch policies that admit only `main`. It keeps existing required reviewers + and wait timers; +- stops with an error if an environment admits any other branch or tag. Remove the extra policy in + **Settings > Environments**, then re-run; +- sets the OIDC `sub` claim template. + +The old name-based subjects stop matching from this step on. They already fail for this repository +after the split, so this step causes no new outage. + +### 2. Re-apply each cloud's `ci-cd-permissions` + +Run this for `aws`, `azure` and `gcp`: + +```bash +cd terraform/environments//ci-cd-permissions +# In terraform.tfvars: delete the old github_repo line and set +# github_repository_id = "1391259743" +# github_repository_owner_id = "86753534" +terraform init -backend-config=backend.hcl +terraform plan +terraform apply +``` + +Check that the plan contains only these changes. If it shows anything else, stop. + +- **AWS**: `aws_iam_openid_connect_provider.github[0]` has moved to + `aws_iam_openid_connect_provider.github`. `aws_iam_role.cudly_deploy` is updated in place, and + its `sub` list changes from `repo:...` to `repository_owner_id:...` subjects. +- **Azure**: `github_main[0]` has moved to `github_main`. Each federated credential's `subject` + and `description` change. +- **GCP**: the pool and provider move from `[0]` to unindexed addresses. The provider's + `attribute_mapping` and `attribute_condition` are updated in place. + `google_service_account_iam_member.github_actions` is replaced, because its `member` changes + from `attribute.repository/` to `attribute.repository_id/`. + +After the apply, no trust names a repository, so the entries for the old monorepo name are gone. + +### 3. Test a deploy from `main` + +```bash +gh workflow run deploy-aws-lambda.yml -R LeanerCloud/cloud-commitments-platform --ref main -f environment=dev +gh workflow run deploy-azure.yml -R LeanerCloud/cloud-commitments-platform --ref main -f environment=dev +gh workflow run deploy-gcp.yml -R LeanerCloud/cloud-commitments-platform --ref main -f environment=dev +``` + +Each run must get past its cloud login step. Then dispatch one of the workflows from any other +branch. GitHub must refuse the environment-bound job ("not allowed to deploy to dev due to +environment protection rules"). + +## Rollback + +Restore the GitHub template and the Terraform together. Either one alone leaves every deploy +failing at the cloud login step. + +1. Restore the default `sub` template: + + ```bash + gh api -X PUT repos/LeanerCloud/cloud-commitments-platform/actions/oidc/customization/sub -F use_default=true + ``` + +2. Before you apply the previous module code with `github_repo` set, move each resource back to + its indexed address. If you skip this, Terraform destroys and recreates the resources. A + deleted GCP pool keeps its ID reserved for 30 days, so the recreate fails. + + ```bash + # aws + terraform state mv aws_iam_openid_connect_provider.github 'aws_iam_openid_connect_provider.github[0]' + # azure + terraform state mv azuread_application_federated_identity_credential.github_main \ + 'azuread_application_federated_identity_credential.github_main[0]' + # gcp + terraform state mv google_iam_workload_identity_pool.github 'google_iam_workload_identity_pool.github[0]' + terraform state mv google_iam_workload_identity_pool_provider.github 'google_iam_workload_identity_pool_provider.github[0]' + terraform state mv google_service_account_iam_member.github_actions 'google_service_account_iam_member.github_actions[0]' + ``` + + Then run `terraform plan` and `terraform apply` from the previous commit. + +3. You can leave the environments and their `main`-only branch policies in place, because they + only restrict. `environments.before.json` records what existed before step 1. diff --git a/terraform/environments/aws/ci-cd-permissions/README.md b/terraform/environments/aws/ci-cd-permissions/README.md index d8d7dad5..cfe0d967 100644 --- a/terraform/environments/aws/ci-cd-permissions/README.md +++ b/terraform/environments/aws/ci-cd-permissions/README.md @@ -15,7 +15,7 @@ CI/CD pipeline uses to deploy infrastructure on AWS. It optionally sets up keyle | `aws_iam_policy.data` | RDS, ElastiCache, S3 (state bucket), Secrets Manager | | `aws_iam_policy.iam` | IAM role creation and policy attachment, gated on the permissions boundary below (#1705) | | `aws_iam_policy.workload_boundary` | `cudly-deploy-boundary`: the permissions ceiling every role the deploy role creates must carry | -| `aws_iam_openid_connect_provider.github` | GitHub Actions OIDC provider (conditional on `github_repo`) | +| `aws_iam_openid_connect_provider.github` | GitHub Actions OIDC provider | ## Apply this root BEFORE merging changes that touch the boundary @@ -86,7 +86,8 @@ to `terraform init -backend-config=backend.hcl`. cp terraform.tfvars.example terraform.tfvars cp backend.hcl.example backend.hcl -# 2. Fill in terraform.tfvars (only github_repo is strictly required if using OIDC) +# 2. Fill in terraform.tfvars (github_repository_id and github_repository_owner_id are required; +# run scripts/bootstrap-github-deploy-config.sh first, see docs/deploy-trust-bootstrap.md) # 3. Fill in backend.hcl with your S3 bucket details # 4. Initialise @@ -148,14 +149,21 @@ for temporary AWS credentials via `sts:AssumeRoleWithWebIdentity`, and injects ` ### Trust policy conditions -The trust policy does **not** allow all workflows from the repo: `repo:LeanerCloud/cloud-commitments-platform:*` would -let any branch, including an unprotected feature branch, mint valid deploy credentials. Instead the +Subjects carry the repository's immutable owner and repository IDs, not its `owner/name`, so a rename +or a new repository reusing a freed name neither gains nor loses deploy rights. GitHub mints this +shape only after `scripts/bootstrap-github-deploy-config.sh` has set the repository's OIDC `sub` +claim template; see [docs/deploy-trust-bootstrap.md](../../../../docs/deploy-trust-bootstrap.md) for +the apply order and rollback. + +The trust policy does **not** allow all workflows from the repo: a `:*` wildcard would let +any branch, including an unprotected feature branch, mint valid deploy credentials. Instead the OIDC `sub` claim is checked against an explicit, enumerated allowlist (`role.tf`'s -`token.actions.githubusercontent.com:sub` condition): +`token.actions.githubusercontent.com:sub` condition), where `` is +`repository_owner_id::repository_id:`: -- `repo:LeanerCloud/cloud-commitments-platform:ref:refs/heads/main`, for workflows dispatched on `main` with no +- `:ref:refs/heads/main`, for workflows dispatched on `main` with no `environment:` binding. -- `repo:LeanerCloud/cloud-commitments-platform:environment:`, one entry per exact environment name a job that +- `:environment:`, one entry per exact environment name a job that assumes this role binds to. A job's `environment:` **replaces** the ref-based subject with an environment-scoped one (never both), so every such value must be listed explicitly or that job cannot authenticate. See the comment above the `sub` list in `role.tf` for the full derivation: @@ -163,8 +171,10 @@ OIDC `sub` claim is checked against an explicit, enumerated allowlist (`role.tf` jobs, and an unreachable `workflow_call` input path), kept in one place so it cannot drift out of sync with the policy itself. -If you fork CUDly or use a different repo, set `github_repo` in `terraform.tfvars` to the new -`owner/repo` value and re-apply; the prefix changes, the enumerated suffixes do not. If you add a +If you fork CUDly or use a different repo, set `github_repository_id` and +`github_repository_owner_id` in `terraform.tfvars` to that repo's IDs +(`gh api repos// --jq '.id,.owner.id'`) and re-apply; the prefix changes, the +enumerated suffixes do not. If you add a new workflow job that assumes this role and binds to a not-yet-listed `environment:`, add its exact subject to `role.tf` and re-apply *before* that job runs, or `configure-aws-credentials` fails with `AssumeRoleWithWebIdentity`/`Not authorized`. @@ -177,9 +187,9 @@ implements only the first of them: 1. **Allowlist membership** (`role.tf`'s `sub` list, this module): can the job authenticate to AWS at all? Nothing below matters if this says no. -2. **Deployment branch policy** (a GitHub environment setting, not configured by this module): which - *branch* may trigger a deploy to that environment. This is what "only `main` may deploy" actually - requires, and nothing in this module sets it. +2. **Deployment branch policy** (a GitHub environment setting, not configured by this module; set + to `main` only by `scripts/bootstrap-github-deploy-config.sh`): which *branch* may trigger a + deploy to that environment. This is what "only `main` may deploy" actually requires. 3. **Required reviewers / protection rules** (a GitHub environment setting, not configured by this module): *who* must approve before a job bound to that environment proceeds. The subject of #1591/#1674/#1660, not this module. @@ -187,41 +197,23 @@ implements only the first of them: `ref:refs/heads/main` in the allowlist enforces (2) on its own for a job with no `environment:` binding: no environment, no policy needed, the ref check *is* the restriction. `environment:` subjects enforce **neither (2) nor (3)** on their own, because the OIDC `sub` for those is -`repo::environment:`, which is ref-agnostic: it says which environment the job bound to, +`:environment:`, which is ref-agnostic: it says which environment the job bound to, nothing about which branch triggered the run. A `workflow_dispatch` fired from any branch against an environment-bound job presents the exact same subject a `main` run would, so this allowlist admits it. The only control that reattaches the branch requirement is a deployment branch policy (`custom_branch_policies` restricted to `main`) on that environment, configured on the GitHub side. -**Live state, checked against the GitHub API directly** (`gh api repos/LeanerCloud/cloud-commitments-platform/environments`), -not assumed from an earlier issue's snapshot: - -| Environment | Exists today? | Protection rules | Deployment branch policy | -| --- | --- | --- | --- | -| `dev` | Yes | none | none | -| `staging` | **No** | n/a | n/a | -| `prod` | **No** | n/a | n/a | -| `aws-fargate-dev` | Yes | none | none | -| `aws-fargate-staging` | Yes | none | none | -| `aws-fargate-prod` | **No** | n/a | n/a | -| `aws-db-dev` / `aws-db-staging` / `aws-db-prod` | **No** (all three) | n/a | n/a | +`scripts/bootstrap-github-deploy-config.sh` creates every environment this allowlist names (and +the `gcp-db-*`/`azure-db-*` environments the other clouds use) with a deployment branch policy that +admits only `main`. Run it before applying this module; see +[docs/deploy-trust-bootstrap.md](../../../../docs/deploy-trust-bootstrap.md). `rollback.yml`'s rollback-aws-lambda and rollback-aws-fargate jobs bind to `dev`/`staging`/`prod` and `aws-fargate-` respectively -- the same environments their corresponding deploy jobs use, -listed above -- rather than a separate `--rollback` family (#139). This allowlist +already in the allowlist -- rather than a separate `--rollback` family (#139). This allowlist previously carried six `aws-{lambda,fargate}--rollback` subjects added for #1648; they were removed once nothing presented them any more, rather than left as unused trust surface. -Only 3 of the 9 environments this allowlist names exist yet, and none of the 3, including `dev` -which multiple deploy jobs already use in production, has a branch policy or protection rules of any -kind. Every environment above needs (2) configured (and the 6 that don't exist yet also need to be -created) before this allowlist actually delivers "only `main` deploys" rather than "only these -environments deploy, from any branch". This module has no GitHub provider configured (no -`provider "github"` or `github_repository_*` resource anywhere under `terraform/` or `iac/`), so -none of this, creation, branch policy, or reviewers, can be done declaratively today; it is a -manual, per-environment step in **Settings -> Environments** on the repo. Adding a GitHub Terraform -provider so this becomes code is #1660's scope, not this module's. - Related: #1648 (this allowlist gap, control 1), #1660 (controls 2 and 3: environments need creating, a branch policy, and protection rules), #1674 (binds the destroy workflows' jobs to environments covered by this list). diff --git a/terraform/environments/azure/ci-cd-permissions/README.md b/terraform/environments/azure/ci-cd-permissions/README.md index b71b432c..59b3687e 100644 --- a/terraform/environments/azure/ci-cd-permissions/README.md +++ b/terraform/environments/azure/ci-cd-permissions/README.md @@ -92,7 +92,9 @@ to `terraform init -backend-config=backend.hcl`. cp terraform.tfvars.example terraform.tfvars cp backend.hcl.example backend.hcl -# 2. Fill in terraform.tfvars (only subscription_id is required) +# 2. Fill in terraform.tfvars (subscription_id, github_repository_id and github_repository_owner_id +# are required; run scripts/bootstrap-github-deploy-config.sh first, see +# docs/deploy-trust-bootstrap.md) # 3. Fill in backend.hcl with your storage account details # 4. Initialise @@ -159,12 +161,18 @@ the `az` CLI context for all subsequent steps. ### Federated credential subjects -Azure federated credentials allow no wildcards, so one resource is required per subject: +Azure federated credentials allow no wildcards, so one resource is required per subject. Subjects +carry the repository's immutable IDs, not its `owner/name`, so a rename or a new repository reusing +a freed name neither gains nor loses deploy rights. `` below is +`repository_owner_id::repository_id:`; GitHub +mints it only after `scripts/bootstrap-github-deploy-config.sh` has set the repository's OIDC `sub` +claim template (apply order and rollback: +[docs/deploy-trust-bootstrap.md](../../../../docs/deploy-trust-bootstrap.md)). | Credential | Subject | Use case | | --- | --- | --- | -| `github-actions-main` | `repo:LeanerCloud/cloud-commitments-platform:ref:refs/heads/main` | Deployments from main | -| `github-actions-env-` | `repo:LeanerCloud/cloud-commitments-platform:environment:` | Jobs bound to a deployment environment (one per `var.github_environments`) | +| `github-actions-main` | `:ref:refs/heads/main` | Deployments from main | +| `github-actions-env-` | `:environment:` | Jobs bound to a deployment environment (one per `var.github_environments`) | No `pull_request`-subject credential is provisioned: it is not scoped to any branch or environment protection, and no workflow in this repo runs @@ -179,10 +187,11 @@ main-branch one, so it cannot authenticate unless `` is in Note the environment subject is **ref-agnostic** — it does not encode the branch. Adding a name here therefore lets any branch that can reach a job bound to that environment obtain the deploy service principal. Restrict the branch via the environment's -`deployment_branch_policy`; a ref check inside the workflow does **not** bind, because +`deployment_branch_policy` (the bootstrap script limits every workflow environment to `main`); a ref check inside the workflow does **not** bind, because `workflow_dispatch` runs the workflow file as it exists on the dispatched ref, so the check can be removed on that branch. -To allow a new deployment environment, add its name to `var.github_environments`. To -deploy from a different branch or repo, add a further -`azuread_application_federated_identity_credential` resource to `sp.tf`. +To allow a new deployment environment, add it to the bootstrap script's `ENVIRONMENTS` and +run the script, then add its name to `var.github_environments`. To deploy from a different +repo, set `github_repository_id` and `github_repository_owner_id` to that repo's IDs +(`gh api repos// --jq '.id,.owner.id'`). diff --git a/terraform/environments/gcp/ci-cd-permissions/README.md b/terraform/environments/gcp/ci-cd-permissions/README.md index 2a07afaf..44fdbb3c 100644 --- a/terraform/environments/gcp/ci-cd-permissions/README.md +++ b/terraform/environments/gcp/ci-cd-permissions/README.md @@ -55,7 +55,8 @@ to `terraform init -backend-config=backend.hcl`. cp terraform.tfvars.example terraform.tfvars cp backend.hcl.example backend.hcl -# 2. Fill in terraform.tfvars (project_id is required) +# 2. Fill in terraform.tfvars (project_id, github_repository_id and github_repository_owner_id +# are required; see docs/deploy-trust-bootstrap.md for the apply order) # 3. Fill in backend.hcl with your GCS bucket name # 4. Initialise @@ -119,12 +120,20 @@ Workload Identity Federation endpoint for a short-lived SA access token, and set ### Attribute condition -The WIF provider enforces **two** conditions simultaneously: +The WIF provider enforces **three** conditions simultaneously: ```text -assertion.repository == '' && assertion.ref == '' +assertion.repository_owner_id == '' && +assertion.repository_id == '' && +assertion.ref == '' ``` +and the deploy SA grants `roles/iam.workloadIdentityUser` only to +`attribute.repository_id/`. The repository is matched on its immutable IDs, +not its `owner/name`, so a rename or a new repository reusing a freed name neither gains nor loses +deploy rights. Apply order and rollback: +[docs/deploy-trust-bootstrap.md](../../../../docs/deploy-trust-bootstrap.md). + Only workflows running from the exact repository **and** the exact ref (branch) configured in `terraform.tfvars` can obtain a GCP token. Any other branch, PR, fork, tag, or `workflow_dispatch` from a different ref is rejected with: @@ -148,4 +157,5 @@ gcloud iam workload-identity-pools providers describe github-actions \ --format="value(attributeCondition)" ``` -To change the allowed repo, update `github_repo` in `terraform.tfvars` and re-apply. +To change the allowed repo, set `github_repository_id` and `github_repository_owner_id` in +`terraform.tfvars` to its IDs (`gh api repos// --jq '.id,.owner.id'`) and re-apply.