Skip to content

sec(ci): bind destroy jobs to an environment and narrow id-token on destroy workflows - #1674

Merged
cristim merged 3 commits into
mainfrom
sec/1591-destroy-rollback-environment
Aug 3, 2026
Merged

cristim merged 3 commits into
mainfrom
sec/1591-destroy-rollback-environment

Conversation

@cristim

@cristim cristim commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes #1591

Read this first: this PR does not add a reviewer gate

The issue title says these workflows have "no environment binding, so no reviewer gate". This PR adds the binding. It does not add the reviewer gate, and closing #1591 should not be read as "destroy is now gated".

environment: buys exactly two things: it scopes secrets/vars, and it sets the OIDC subject to repo:<org/repo>:environment:<name>. GitHub only blocks a job once protection rules are configured on the environment, and re-confirmed against the live API on this branch:

$ gh api repos/LeanerCloud/CUDly/environments
total_count: 5
aws-fargate-dev:     protection_rules=[]  can_admins_bypass=true
aws-fargate-staging: protection_rules=[]  can_admins_bypass=true
azure-:              protection_rules=[]  can_admins_bypass=true
dev:                 protection_rules=[]  can_admins_bypass=true
gcp-:                protection_rules=[]  can_admins_bypass=true

No environment in this repo has any protection rules, and staging does not exist at all. GitHub auto-creates a referenced environment bare on first use, so the first cleanup-staging dispatch will mint staging with no reviewers and run straight through. Until someone configures rules out-of-band, these destroy jobs still run unapproved.

The code / manual split, explicitly

Half Who does it Status
Bind destroy jobs to an environment; narrow id-token: write; per-env Azure federated credentials this PR done
Create staging; set required reviewers on dev and staging; set a deployment branch policy; consider can_admins_bypass=false privileged human, repo settings or API not done
Re-apply the Azure ci-cd-permissions bootstrap module privileged human (bootstrap-only per CLAUDE.md) not done — Azure destroy fails until then

Exact manual steps, on environments dev and staging:

  1. Create staging (dev already exists). Neither can be created declaratively today: there is no github_repository_environment resource anywhere under terraform/ or iac/, and no GitHub Terraform provider is configured in this repo. Adding that provider is sec(ci): deployment environments have no protection rules, so environment-bound credentialed jobs run unapproved cloud-commitments-platform#141's scope, not this PR's.
  2. Required reviewers — the actual gate. Without this, nothing in this PR stops anything.
  3. Deployment branch policy — custom_branch_policies with pattern main. See below for why this one is load-bearing.
  4. Re-apply terraform/environments/azure/ci-cd-permissions so the new per-environment federated credentials exist. Until this runs, the Azure destroy job fails with AADSTS70021 — it is knowingly merged broken, matching the pre-existing state recorded in fix(iac/aws): rollback environments are absent from the OIDC trust allowlist, so AWS/Azure rollback cannot authenticate cloud-commitments-platform#139.

Cross-referenced, not duplicated: #1660 owns environments-have-no-protection-rules and main-is-unprotected. #1648 owns the trust-policy sub allowlist gaps. #1659 owns the sibling deploy workflows. Nothing new filed; this family already has enough overlapping issues.

The binding is a widening unless the branch is pinned

An OIDC sub is either …:ref:refs/heads/<branch> or …:environment:<name> — never both (absent a custom sub-claim template; this repo sets none). So adding environment: replaces the ref-scoped subject with a ref-agnostic one. Since neither dev nor staging carries a deployment branch policy, the binding on its own trades a main-only restriction for any-branch access — a widening, on the workflows this change exists to narrow.

There is a Restrict to main step in each guard job for this. It is defense-in-depth against accidents and is not a security boundary, and the comments say so in those words. workflow_dispatch runs the workflow file as it exists on the dispatched ref, so anyone who can push a branch can delete the step and still present the environment:<name> subject. The control that survives that is the deployment branch policy in step 3 above, which GitHub evaluates before the job starts and before the token is minted — and which does not require main to be protected.

An earlier revision of this branch got that reason wrong, claiming a branch policy was unavailable because main is unprotected. It was corrected in c39adc3; custom_branch_policies takes an explicit pattern and works on an unprotected branch.

Also in scope, since I was in these files

permissions: — both workflows declared permissions: id-token: write at workflow level, handing an OIDC-mintable token to every job including the confirmation guard. Now contents: read at workflow level, id-token: write granted per job only to the five jobs that assume a cloud role, and permissions: {} on both guard jobs (neither checks out code). Same split #1657 applied to deploy-aws-lambda.yml. This is the destroy-workflow analogue of #1665.

Residual ${{ }} in run: blocks — #1641's rule is zero. destroy-fargate-dev.yml was already clean. cleanup-staging.yml had one, vars.GCP_PROJECT_ID interpolated into the Cloud SQL cleanup step; routed through env: and referenced as a quoted shell variable. Admin-settable, so hardening rather than a live injection. Both files are now at zero, as is rollback.yml.

workflow_dispatch input validation — the only input on either workflow is confirm, already compared against the literal destroy via env:. No input reaches anything credentialed. Note the guard is in a preceding job in the same run, typed by the same person who dispatched — it is a typo-catcher, not an authorization control, which is the substance of #1591.

rollback.yml is untouched, deliberately. #1591 names it, but PR #1641 already fixed it: workflow level is contents: read, id-token: write sits only on the four environment-bound rollback-* jobs, and validate/summary are contents: read. Verified on current main, not assumed. Its remaining gap is the missing protection rules — LeanerCloud/cloud-commitments-platform#141 — not a missing binding.

Verification

I did not dispatch a real destroy, so no end-to-end verification is claimed. Neither workflow can be exercised without deleting a live estate. What was actually run, on c39adc3:

check result
actionlint — cleanup-staging.yml exit 0
actionlint — destroy-fargate-dev.yml exit 0
actionlint — rollback.yml (untouched, regression check) exit 0
YAML parses (yaml.safe_load) pass, both files
${{ }} inside any run: block 0, both files
jobs with id-token: write and no environment: 0, both files
every destroy job transitively needs: guard confirmed, all 5
pre-commit (incl. terraform fmt/validate/tflint, trivy) passed

Structural checks were done by parsing the YAML rather than grepping, so a job that inherits id-token cannot hide from them.

Independent adversarial review of the branch confirmed the OIDC subject claim, confirmed the needs: graph has no bypass, confirmed permissions: {} does not break either guard (neither checks out code), and confirmed role.tf:30-31 allowlists environment:dev and environment:staging. Its two high findings were both about my own comments overstating the ref check; both are fixed in c39adc3.

Known-broken on merge

The Azure destroy job in cleanup-staging.yml cannot authenticate until the bootstrap re-apply in manual step 4. This is pre-existing (#1648) and is flagged in the workflow itself, but it is a checklist item, not just a comment: do not treat Azure staging cleanup as working until that apply has run.

cristim added 3 commits July 29, 2026 11:17
`cleanup-staging.yml` and `destroy-fargate-dev.yml` declared
`id-token: write` at workflow level and bound no job to a deployment
environment. Five `terraform destroy -auto-approve` jobs across AWS, GCP
and Azure therefore ran with production deploy credentials and no
environment scoping, guarded only by a typed-string confirmation entered
by the same person who dispatched the run.

Changes:

- Grant `id-token: write` per job, only to the five jobs that assume a
  cloud role. The two `guard` jobs authenticate to nothing and now hold
  no permissions at all.
- Bind the four staging destroy jobs to the `staging` environment and the
  Fargate dev destroy job to `dev`, so their OIDC subject is
  `environment:<name>` rather than a bare branch ref.
- Enforce `refs/heads/main` in each `guard` job. An OIDC subject is
  either `ref:refs/heads/<branch>` or `environment:<name>`, never both,
  so the binding above REPLACES the ref-scoped subject with a
  ref-agnostic one. Neither `dev` nor `staging` carries a
  deployment_branch_policy, so without this check the change would have
  traded a main-only restriction for any-branch access — a widening, on
  the workflows it is meant to narrow. The check lives in the workflow
  rather than in the environment's branch policy because `main` is not a
  protected branch, so a protected-branches policy would match nothing
  and block every deploy.
- Add per-environment Azure federated identity credentials. Azure allows
  no wildcards and had only `ref:refs/heads/main` and `pull_request`
  subjects, so an environment-bound job would have failed AADSTS70021 —
  the breakage recorded in #1648. Defaults to dev and staging only;
  `prod` is omitted because no Azure job binds to it.

The `environment:` binding is NOT a reviewer gate and the comments say
so: no environment in this repo has any protection rules, and `staging`
does not exist yet, so the first dispatch auto-creates it bare. That is
tracked in #1660 and needs a privileged human, not code.

`rollback.yml`, also named in the issue, is deliberately untouched — PR
#1641 already removes workflow-level `id-token: write`
from it.

Verified: actionlint (with shellcheck) exit 0 on both workflows;
terraform fmt and validate clean; both new variable validations proven
to reject duplicate and empty environment names.

Refs #1591
The Cloud SQL cleanup step interpolated `vars.GCP_PROJECT_ID` directly into
its shell source, the last such interpolation in either destroy workflow.
#1641 established the rule for rollback.yml: no expression interpolation in
any `run:` block, route through `env:` and reference a quoted shell variable.

`vars.*` is settable only by a repo admin, so this is hardening rather than a
live injection, but the rule is worth holding uniformly on a workflow that
runs `terraform destroy -auto-approve`.

Also fails loud when the variable is unset. Previously an empty project made
`gcloud sql instances list` match nothing, so the step silently skipped the
deletion it exists to perform and left the destroy to hit the dependency
deadlock it was meant to avoid.

actionlint: exit 0. Zero run-block interpolations remain in cleanup-staging.yml,
destroy-fargate-dev.yml and rollback.yml.

Refs #1591
…ck binds

Adversarial review found the new comments overstating two controls, which is
the same defect class #1591 is about: a control that reads as a restriction
and does not restrict.

1. The "Restrict to main" step was described as enforcing main-only dispatch.
   It does not. `workflow_dispatch` runs the workflow file as it exists on the
   dispatched ref, so anyone who can push a branch can delete the step and
   still present the `environment:<name>` OIDC subject. Relabelled as
   defense-in-depth against accidents, explicitly not a security boundary.

2. The comments justified checking the ref in-workflow on the grounds that a
   deployment branch policy needs `main` to be a protected branch. That is
   wrong. Environments support `custom_branch_policies` with an explicit
   branch pattern, which works on an unprotected branch and is evaluated
   before the job starts and before the token is minted. Naming the wrong
   reason steered the reader away from the only control that actually holds.

Also:
- github_environments no longer reads as a complete enumeration. It now names
  the Azure jobs it knowingly does not cover (rollback.yml's
  azure-<env>-rollback, database-migration.yml's azure-db-<env>) and says why,
  cross-referencing #1648, so absence is not misread as "no job uses it".
- Live environment state (which environments exist, which have protection
  rules) is no longer asserted in code comments, only referenced via #1660,
  so it cannot rot into false reassurance once that issue is fixed.
- GCP_PROJECT_ID validated for shape rather than mere non-emptiness; a
  whitespace-only value previously reached `gcloud --project=`, matched no
  instance and silently skipped the deletion.
- AWS_REGION moved to job level in destroy-fargate-dev.yml. Workflow-level
  env is resolved before the job's environment is in scope, so an
  environment-scoped override would have been invisible.

The OIDC subject claim itself was independently verified and holds: `sub` is
either ref-scoped or environment-scoped, never both, absent a custom sub-claim
template, and this repo sets none.

Refs #1591
@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/high Significant harm urgency/now Drop other things impact/internal Team-internal only effort/m Days type/security Security finding labels Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 57 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5cbeb952-7ed0-46f9-bc0c-9a7a22cadf1d

📥 Commits

Reviewing files that changed from the base of the PR and between 6ded401 and c39adc3.

📒 Files selected for processing (5)
  • .github/workflows/cleanup-staging.yml
  • .github/workflows/destroy-fargate-dev.yml
  • terraform/environments/azure/ci-cd-permissions/README.md
  • terraform/environments/azure/ci-cd-permissions/sp.tf
  • terraform/environments/azure/ci-cd-permissions/variables.tf

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

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 57 minutes.

cristim added a commit that referenced this pull request Aug 3, 2026
… cudly_deploy (#1683)

* fix(iac/aws): extend OIDC trust allowlist to cover every job assuming 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.

* docs(iac/aws): state the environment-subject branch-scoping gap explicitly

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.

* docs(iac/aws): distinguish allowlist, branch policy, and reviewers explicitly

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.
@cristim
cristim merged commit f6bb36a into main Aug 3, 2026
19 checks passed
@cristim
cristim deleted the sec/1591-destroy-rollback-environment branch August 3, 2026 11:39
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review record (merged)

Independent reviewer, distinct from the author. Recording it here because the merge rested on this — this PR's CodeRabbit status read success while its description said "Review rate limited", a green status over a review that never ran.

Lead concern tested and cleared: does binding jobs to an environment break OIDC? An environment:-bound job presents repo:O/R:environment:<name> instead of a ref subject, so a mismatched trust policy would silently break every bound job. Checked per cloud against the real policies:

  • AWS (role.tf): allowlist is a StringEquals list matched as OR; all three AWS jobs bound here present subjects already in it. No break.
  • GCP: attribute_condition checks assertion.repository/assertion.ref and never sub; the binding is a no-op for authentication either way. No break, no gain — correctly stated as such in the PR.
  • Azure: only ref:refs/heads/main and pull_request federated credentials exist. This PR adds an environment credential in the bootstrap-only module, so destroy-azure gets AADSTS70021 until a privileged human re-applies. Real, but disclosed in the PR's own "Known-broken on merge" checklist rather than hidden.

permissions: narrowing — no regressions. Both files parsed as YAML rather than grepped: guard jobs have exactly two run: steps, no checkout, no API calls, so permissions: {} is sufficient. All five credentialed jobs carry both environment: and id-token: write; the converse was checked explicitly (zero of either alone). All five needs: [guard] with no if: override, so a failed guard blocks every one.

rollback.yml deliberately untouched — verified independently against current main, not taken on assertion. It already has contents: read at workflow level with id-token: write only on the four environment-bound rollback-* jobs.

Zero ${{ }} in any run: block in either file, by regex scan over every step's run:.

Honest gap, stated by the PR: no real destroy was dispatched. actionlint exit 0 on all three files (including rollback.yml as a regression check), YAML parses, and environment: confirmed at job level for every credentialed job — a binding at the wrong level protects nothing.

Order-independent with #1683: this PR binds those jobs to staging/dev, both already in that allowlist. Either merge order is safe.

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/p0 Drop everything; same-day fix severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(ci): destroy and rollback workflows have no environment binding, so no reviewer gate applies

1 participant