fix(iac/aws): extend OIDC trust allowlist to cover every job assuming cudly_deploy - #1683
fix(iac/aws): extend OIDC trust allowlist to cover every job assuming cudly_deploy#1683cristim wants to merge 3 commits into
Conversation
… cudly_deploy
The AWS deploy role's trust policy sub allowlist (role.tf) covered only
ref:refs/heads/main and the three bare environment:{dev,staging,prod}
subjects. Live AWS still runs the pre-b876d7fa0 wildcard
(repo:LeanerCloud/CUDly:*), so that allowlist was never applied. Tightening
it as committed would immediately break every job whose environment binding
isn't in it.
Enumerated every workflow job that assumes this role (role-to-assume:
${{ vars.AWS_ROLE_TO_ASSUME }}, 9 job-steps across 7 files) and traced each
environment: binding to its concrete value. Twelve subjects were missing:
deploy-aws-fargate.yml (aws-fargate-{dev,staging,prod}), database-migration.yml
(aws-db-{dev,staging,prod}), and rollback.yml's two AWS jobs
(aws-{lambda,fargate}-{dev,staging,prod}-rollback).
pull_request is deliberately not added: the only pull_request-triggered
workflow that calls configure-aws-credentials (aws_sanity.yml) assumes a
separate read-only role, not this one. database-migration.yml's
workflow_call path takes an unconstrained string environment input, but
nothing in this repo calls it that way today, so it's excluded and flagged
rather than covered with a wildcard.
Also corrects ci-cd-permissions/README.md's "Trust policy conditions"
section, which still described the wildcard rather than the allowlist
role.tf has actually specified since b876d7f.
Not applied here, this module is bootstrap-only, applied manually by a
privileged human. Refs #1648, #1674.
📝 WalkthroughWalkthroughThe AWS deployment role now explicitly allows additional GitHub Actions environment OIDC subjects for Fargate, database, and rollback workflows. The README documents the subject formats, exclusions, repository configuration, and required allowlist updates for new jobs or environments. ChangesAWS OIDC trust policy
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…citly The owner constraint on this repo is that only main may deploy. ref:refs/heads/main in the allowlist enforces that for unbound jobs, but environment:<name> subjects are ref-agnostic: a workflow_dispatch from any branch against an environment-bound job presents the identical subject a main run would. Reattaching the branch requirement needs a deployment branch policy per environment, which none of the 15 environments this allowlist names currently has (several don't exist yet), and this module has no GitHub provider to configure that declaratively. Makes this explicit in role.tf's comment and adds a README section naming every environment that needs the policy, so the allowlist isn't read as delivering "only main deploys" before that manual step happens.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
terraform/environments/aws/ci-cd-permissions/role.tf (1)
74-105: 🔒 Security & Privacy | 🔵 TrivialNew environments have no protection rules — self-service credential minting.
The comment at Line 96-99 already flags that
aws-lambda-*-rollback/aws-fargate-*-rollback(and, by the same reasoning, the newaws-fargate-*/aws-db-*entries) are auto-created bare with no reviewer gate. GitHub's own guidance backs treating this as a real gap, not just documentation: When environments are used in workflows or in OIDC policies, we recommend adding protection rules to the environment for additional security. For example, you can configure deployment rules on an environment to restrict which branches and tags can deploy to the environment or access environment secrets. Until#1660lands, anyone able to trigger theseworkflow_dispatchjobs can self-mint credentials forcudly_deploywith no human approval step — worth confirming that's an acceptable interim risk before this bootstrap change is manually applied.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@terraform/environments/aws/ci-cd-permissions/role.tf` around lines 74 - 105, Update the Terraform change around the rollback, Fargate, and database environment subjects to avoid enabling self-service credential minting for environments without protection rules. Before applying these new OIDC subjects, obtain explicit confirmation that the interim risk is acceptable, or defer/remove the entries until the corresponding GitHub environment protection rules are provisioned by `#1660`. Preserve only subjects backed by approved, protected environments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@terraform/environments/aws/ci-cd-permissions/role.tf`:
- Around line 74-105: Update the Terraform change around the rollback, Fargate,
and database environment subjects to avoid enabling self-service credential
minting for environments without protection rules. Before applying these new
OIDC subjects, obtain explicit confirmation that the interim risk is acceptable,
or defer/remove the entries until the corresponding GitHub environment
protection rules are provisioned by `#1660`. Preserve only subjects backed by
approved, protected environments.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7f400399-78bb-475a-91e8-6959ee889ec9
📒 Files selected for processing (2)
terraform/environments/aws/ci-cd-permissions/README.mdterraform/environments/aws/ci-cd-permissions/role.tf
…plicitly Team feedback: the previous pass stated that this allowlist does not restrict deploys to main, but didn't name the three separate GitHub-side controls that keep getting conflated across this repo's issues (allowlist membership, deployment branch policy, required reviewers), and didn't say which of the 15 named environments exist yet. Checked live via `gh api repos/LeanerCloud/CUDly/environments`: only 3 of 15 exist today (dev, aws-fargate-dev, aws-fargate-staging), none with any protection rules or branch policy. Adds the three-way distinction to role.tf's top comment and a short existence note next to each entry group, and replaces the README section with the same distinction plus a per-environment existence table.
Refs #1648, #1674
Not applied. Do not apply.
terraform/environments/aws/ci-cd-permissions/is bootstrap-only, applied manually by a privileged human, never by a deploy workflow. Live AWS currently trusts the pre-b876d7fa0wildcardrepo:LeanerCloud/CUDly:*, not even the 4-entry allowlist already committed torole.tf, which is why that role has never been re-applied: doing so as currently written would immediately break every job whose OIDC subject isn't one of the 4 listed values. This PR extends the allowlist so that when it is applied, nothing breaks. The apply itself is a separate, manual step for whoever holds the credentials.Owner constraint: only
mainmay deploy. This PR's code half does not fully deliver that yet.pull_requestis not in this allowlist, and was never a candidate. I checked whether any job assuming this specific role (cudly_deploy) runs onpull_request: none does (see the judgment-call section below for the evidence). Nothing to report, nothing to allowlist.Three separate GitHub-side controls keep getting conflated across this repo's issues. This PR implements only the first.
role.tf'ssublist, this PR): can the job authenticate to AWS at all? Nothing below matters if this says no.mainmay deploy" actually requires.ref:refs/heads/mainenforces (2) on its own for a job with noenvironment:binding: no environment, no policy needed, the ref check is the restriction.environment:<name>subjects enforce neither (2) nor (3) on their own, because that OIDCsubis ref-agnostic: it encodes which environment the job bound to, not which branch triggered the run. Aworkflow_dispatchfired from any branch against an environment-bound job presents the identical subject amainrun would. So allowlistingenvironment:stagingadmits astagingdeploy dispatched from an arbitrary branch, exactly what "only main deploys" is supposed to rule out. The only control that reattaches the branch requirement is a deployment branch policy (custom_branch_policies, restricted tomain) configured per-environment on GitHub's side.Live state, checked directly against the GitHub API just now (
gh api repos/LeanerCloud/CUDly/environments), not assumed from an earlier issue's snapshot:devstagingprodaws-fargate-devaws-fargate-stagingaws-fargate-prodaws-db-{dev,staging,prod}aws-lambda-{dev,staging,prod}-rollbackaws-fargate-{dev,staging,prod}-rollbackOnly 3 of the 15 environments this allowlist names exist yet (
dev,aws-fargate-dev,aws-fargate-staging), and none of those 3, includingdevwhich live deploy jobs already use today, has a branch policy or protection rules of any kind. Until (2) is configured on every environment above, and the 12 that don't exist yet are created, this allowlist delivers "only these environments deploy, from any branch", not "only main deploys". That is the honest characterization; I am not calling this "main-only" anywhere in this PR.Correction from the previous revision of this table: an earlier pass here said "3 environments exist" without qualifying it against this allowlist's 15 names, which read as (and was pointed out to be) a different, broader claim than intended. The account has 5 environments in total; the table above covers the 3 that overlap with this allowlist. The other 2,
azure-andgcp-(malformed, trailing-hyphen names), are unrelated to this PR: investigated and confirmed dead code (root-caused to apush-triggered job indeploy-azure.yml/deploy-gcp.ymlat commitcb386249e, interpolating an emptyinputs.environment; fixed by a later refactor, no deployment against either since 2026-03-25) and structurally unreachable by any job that assumescudly_deploy(wrong cloud, wrong role, wrong action entirely). Filed as its own issue, #1684, cross-linked to #1660 rather than folded into this PR.No job that assumes
cudly_deploycan bind to an empty-suffix environment, so applying this allowlist is safe on that axis. Every compound name any AWS-role job currently produces carries a non-empty prefix segment or a suffix after the interpolation point (aws-fargate-,aws-db-,aws-lambda-...-rollback,aws-fargate-...-rollback), never a bareaws-or a name that could collapse to something outside the 16 entries above given thetype: choiceconstraint on every reachable input.rollback.yml'sgcp-*-rollback(google-github-actions/auth) andazure-*-rollback(azure/login) jobs, anddatabase-migration.yml'smigrate-gcp/migrate-azurejobs (same two actions), authenticate to their own clouds, not this role; onlyrollback.yml's twoaws-*-rollbackjobs anddatabase-migration.yml'smigrate-awsjob usevars.AWS_ROLE_TO_ASSUME, confirmed by re-reading every credentialed step, not by pattern-matching names.Deliverable is therefore two halves:
role.tf's allowlist, exactly the subjects credentialed jobs presentmainon all 15; set required reviewers where warrantedprovider "github"/github_repository_*resource anywhere underterraform/oriac/; adding one is #1660's scope)I did not solve the manual half in code, and I did not omit any of these environment entries to force branch-scoping by omission, that would just reproduce #1648: break the destroy and rollback jobs' authentication entirely. Landing the allowlist and stating the residual gap, per-environment, is the honest version of this change.
Method: evidence, not a list handed down
Grepped every workflow for
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}(the var that resolves to this role's ARN, confirmed viaoutputs.tf's description) rather than trusting any prior characterization of which jobs need what. 9 job-steps across 7 files assume this specific role. For each, traced the job'senvironment:binding to its concrete value(s) by reading the job's own resolution logic (apreparejob'sif/elifchain, or aworkflow_dispatchinput'stype: choiceoptions:), not by assuming the binding is a literal.An OIDC
subis either...:ref:refs/heads/<branch>or...:environment:<name>, never both, so a job'senvironment:binding replaces its ref-based subject. Every environment-bound job's exact subject has to be in the allowlist or that job cannot authenticate at all, independent of whether the environment has any protection rules (#1660 is a separate concern from the allowlist gap this PR closes).The table
environment:bindingdeploy-aws-lambda.ymlbuild-and-deploy,test-deployment${{ needs.prepare.outputs.environment }}dev,staging,prod(prepare job:release→prod,workflow_dispatch/workflow_call→inputs.environmentchoice, else→dev)deploy-aws-fargate.ymldeployaws-fargate-${{ needs.prepare.outputs.environment }}aws-fargate-dev,aws-fargate-staging,aws-fargate-proddatabase-migration.ymlmigrate-awsaws-db-${{ inputs.environment }}aws-db-dev,aws-db-staging,aws-db-prod(workflow_dispatch path only, see below)rollback.ymlrollback-aws-lambdaaws-lambda-${{ inputs.environment }}-rollbackaws-lambda-dev-rollback,aws-lambda-staging-rollback,aws-lambda-prod-rollbackrollback.ymlrollback-aws-fargateaws-fargate-${{ inputs.environment }}-rollbackaws-fargate-dev-rollback,aws-fargate-staging-rollback,aws-fargate-prod-rollbackcleanup-staging.ymldestroy-aws-lambda,destroy-aws-fargatemain;stagingonce #1674 mergesref:refs/heads/maintoday,environment:stagingafter #1674destroy-fargate-dev.ymldestroymain;devonce #1674 mergesref:refs/heads/maintoday,environment:devafter #167412 new entries added:
aws-fargate-{dev,staging,prod},aws-db-{dev,staging,prod},aws-lambda-{dev,staging,prod}-rollback,aws-fargate-{dev,staging,prod}-rollback. Existing 4 entries (ref:refs/heads/main,environment:{dev,staging,prod}) kept unchanged. Total 16.inputs.environmenton everyworkflow_dispatchabove is atype: choicewithoptions: [dev, staging, prod](checked each one individually, not assumed uniform), so every compound value is exhaustively enumerable, noStringLikepattern needed or used. Confirmed thedeploy-all.ymlorchestrator, the only caller ofdeploy-aws-fargate.yml/deploy-aws-lambda.ymlviaworkflow_call, is itself constrained the same way (its ownenvironmentinput istype: choice options: [dev, staging, prod], and itsrelease-triggered path hardcodesprod), so theworkflow_calltrigger on those two files can't introduce a value outside the enumerated set either.Two judgment calls, made explicitly
1.
pull_requestis excluded, not forgotten, and is now dropped as a matter of policy, not just because nothing needs it today. Checked whether any job assuming this role (cudly_deploy) runs onpull_request: none does.aws_sanity.ymlis the onlypull_request-triggered workflow callingconfigure-aws-credentials, and it usesrole-to-assume: ${{ secrets.AWS_CICD_READONLY_ROLE_ARN }}, a separate, already-scoped-down role, confirmed by grep (role-to-assumeappears inaws_sanity.ymlexactly once, with that secret, notvars.AWS_ROLE_TO_ASSUME). Given the owner's "only main may deploy" constraint,pull_requestwould be excluded regardless of what I'd found; the wildcard's over-permission of it today is exactly the hole this allowlist exists to close, not a capability to carry forward. For the record: GitHub does not issue OIDC tokens topull_request-triggered runs from forked repositories by default, independent of this trust policy. I could not find the readonly role's own trust policy anywhere underterraform/, so I can't speak to its scoping; out of scope for this PR since it's a different role entirely.2. No
StringLikepatterns. Every value in the table above is aStringEqualsexact match, drawn from a finite, enumerated set. The temptation with 16 entries is to collapseaws-fargate-*-shaped names into a pattern; resisted it, because aStringLikein a trust policy is exactly the substring-standing-in-for-equality shape that produced four separate defects elsewhere in this codebase today (#1544/#1667's GCP WIF mapping/condition bypasses, the AWS user-pathcontains()bypass in #1667's Terraform sibling). A trust policy is the worst place to add a fifth. If a pattern is ever proposed here later, it should have to prove what it admits beyond the intended set, which none of the current 16 values need.Deliberately not covered, and why
database-migration.yml'sworkflow_calltrigger declaresenvironmentas an unconstrainedtype: string(notchoice), unlike itsworkflow_dispatchpath (type: choice,options: [dev, staging, prod], cannot be empty). Grepped.github/workflows/foruses:.*database-migration.yml: zero hits. Nothing in this repo calls it viaworkflow_calltoday, so that path is unreachable and its arbitrary-stringenvironmentvalue was not enumerated (there's nothing to enumerate, and adding a wildcard to pre-cover a hypothetical future caller would be exactly the kind of unjustified widening this PR argues against elsewhere). If a caller is ever added, itsenvironment:binding needs an explicit new entry here before it can authenticate.This is not a hypothetical failure shape, it is the concrete one: a
workflow_callcaller passingenvironment: ""would produceaws-db-onmigrate-aws, the job atdatabase-migration.yml:170/190that does assumecudly_deploy. That is the exact same empty-interpolation defect class that produced theazure-/gcp-junk environments (#1684), just on a job that would authenticate to AWS instead of one that couldn't reach this role at all.migrate-gcp(:246/264,google-github-actions/auth) andmigrate-azure(:313/331,azure/login) use their own clouds' credentials, not this role, same split confirmed forrollback.yml's GCP/Azure jobs below. Flagged in bothrole.tf's comment and the README so it doesn't silently rot into a "why doesn't this work" surprise later.Also fixed: stale README
terraform/environments/aws/ci-cd-permissions/README.md's "Trust policy conditions" section said "The trust policy allows only workflows fromrepo:LeanerCloud/CUDly:*", describing the wildcard, not whatrole.tfhas actually specified sinceb876d7fa0(predates this PR). Directly adjacent to what I'm changing and actively misleading about what the committed policy does, so corrected it to describe the enumerated allowlist, added the "does not restrict tomainby itself" section with the same environment list as above, and cross-linked #1648/#1660/#1674 rather than duplicating their content.Verification
terraform fmt -check -diffonterraform/environments/aws/ci-cd-permissions/: exit 0.terraform init -backend=false && terraform validate: exit 0, "Success! The configuration is valid."pre-commit runon both changed files (both commits): exit 0, all applicable hooks passed (Terraform format/validate/lint, Trivy config scanner, secret scanners, Markdown lint); no hook skipped via--no-verifyor similar.github_repository_environmentresource found anywhere underterraform/oriac/(grep), confirming the manual-half table above is accurate: these environments and their branch policies cannot be created declaratively in this repo today.What this does not do
Does not touch Azure or GCP trust policies (separate concerns, separate files). Does not provision the GitHub environments themselves, their protection rules, or their deployment branch policies (#1660, and the manual half above). Does not apply anything to live AWS (bootstrap-only, manual). Does not add a reviewer gate; adding an entry here only lets an already environment-bound job authenticate, it says nothing about whether that environment has required reviewers. Does not, on its own, deliver "only
maindeploys" for environment-bound jobs; see the constraint section above.Summary by CodeRabbit
Documentation
Chores