# GitHub OIDC role rollout

Helix GitHub Actions uses purpose-specific AWS roles. Never restore a shared
`repo:<org>/<repo>:*` trust or a shared `AWS_ROLE_ARN` repository variable.

| GitHub variable | Trusted OIDC subject | Allowed capability |
|---|---|---|
| `AWS_FRONTEND_CONFIG_ROLE_ARN` | main + `image-release.yml` | Read two public frontend build parameters |
| `AWS_IMAGE_PUBLISH_ROLE_ARN` | main + `image-release.yml` | Push the configured ECR repositories |
| `AWS_ARTIFACT_PUBLISH_ROLE_ARN` | main + CLI/plugin release workflow | Publish `cli-dist/*` and `plugin-dist/*` only |
| `AWS_DAX_DEPLOY_ROLE_ARN` | main + `environment:production` + one of three DAX workflows | Upload fixed-prefix helpers, touch exact DAX parameters, run the fixed document on the Helix EC2 instance |
| `AWS_PRODUCTION_DEPLOY_ROLE_ARN` | main + `environment:production` + `deploy-ec2.yml` | Stage deploy assets and run the fixed document on the Helix EC2 instance |

Pull requests deliberately receive **no AWS identity**. Even read-only access to
the production Terraform object discloses secrets stored in state, while PR
workflow and Terraform code are contributor-controlled. PR CI therefore runs
`terraform init -backend=false`, formatting, and validation only. A speculative
plan against production state must be run from a trusted administrator session.

The production subject is not sufficient by itself: the IAM trust also checks
the independent `ref=refs/heads/main` claim and a supported workflow claim.
Reusable jobs are pinned by exact `job_workflow_ref`; direct release/DAX jobs
are pinned by their unique `workflow` display names because AWS does not map
GitHub's `workflow_ref` claim into IAM. Contract tests keep those names aligned
with the workflow YAML and reject unsupported condition keys. After
the IAM cutover, keep the GitHub `production` environment restricted to the
exact branch `main`. Required reviewers are unavailable on the repository's
current private-plan feature set, so unattended deployment is disabled and the
active main ruleset plus explicit manual dispatch provide the compensating
human gate. IAM's main-ref check remains the fail-closed backstop if repository
settings later drift.

## Production environment protection (apply before workflow cutover)

The 2026-08-03 cutover found that GitHub rejects both required reviewers and
wait timers for this private repository's current billing plan (`HTTP 422`). Do
not claim that the environment supplies a reviewer gate while this limitation
exists. The supported environment control is enabled: custom deployment branch
policies are active and the sole policy is the exact branch `main`.

The repository uses these fail-closed compensating controls:

- the active `Protect production main` ruleset requires a pull request, one
  approval, stale-review dismissal, last-push approval, resolved conversations,
  linear history and stable CI checks before code reaches `main`;
- IAM additionally requires `ref=refs/heads/main`, the production subject and
  the exact credential-bearing workflow claim;
- post-merge CI publishes immutable images but cannot deploy them;
- the only live application deployment entry point is the explicit
  `workflow_dispatch` in `prod-deploy.yml`, with a full immutable image tag and
  matching main commit SHA;
- the privileged reusable job remains bound to `environment: production`, so
  the main-only environment policy is applied to the actual OIDC/SSM boundary.

Verify the supported environment policy with:

```bash
gh api repos/pingcap-inc/Helix/environments/production \
  --jq '{protection_rules,deployment_branch_policy}'
gh api repos/pingcap-inc/Helix/environments/production/deployment-branch-policies \
  --jq '{total_count,branch_policies}'
```

The expected export has custom branch policies enabled and exactly one branch
policy named `main` with type `branch`. If the GitHub plan later supports
required reviewers, add them with `prevent_self_review=true`; do not re-enable
automatic production deployment merely because that feature becomes available.
The manual wrapper deliberately has no separate environment job: the privileged
job inside `deploy-ec2.yml` is the one production boundary, avoiding duplicate
gates for one deployment.

## Non-mutating identity preflight

After the workflows are present on `main`, use these dispatches to verify all
five roles can exchange a GitHub OIDC token for the intended AWS identity without
writing an AWS resource:

```bash
gh workflow run iam-oidc-preflight.yml --ref main
gh workflow run cli-release.yml --ref main -f iam_preflight_only=true
gh workflow run deploy-feishu-dax-approval.yml --ref main \
  -f iam_preflight_only=true
```

`iam-oidc-preflight.yml` checks the frontend-config, image-publish, and
production-deploy roles. The CLI preflight checks artifact-publish, and the
Feishu preflight checks DAX-deploy. Inspect each run and require the expected
account and assumed-role ARN from `aws sts get-caller-identity`. The frontend
and image preflight also exercises their existing read-only SSM read and ECR
login operations. The other checks validate OIDC trust and role selection only;
Terraform policy review and a controlled live smoke validate their write
authorization. Stop the rollout on any unexpected account, role name, denied
assumption, or missing variable. Do not fall back to the legacy role.
There is no post-merge live deploy to approve: image publication and production
deployment are intentionally separate. Review the preflight identities before
dispatching `prod-deploy.yml`. The CLI workflow is path-filtered to
`cli/**`, so merging an IAM-only workflow change does not itself publish a CLI
release; use its explicit preflight dispatch during this cutover.

These preflights are intentionally separate from the live workflows below:

- A normal image release pushes immutable and `main` ECR tags. It cannot deploy
  production; use the separate manual production workflow after review.
- A normal CLI/plugin release writes versioned objects and replaces its
  `latest/manifest.json` pointer.
- BigQuery/catalog `dry_run=true` still uploads run assets and executes an SSM
  command on the production EC2 instance; it suppresses database commits only.
- Feishu `dry_run=true` does not write AWS after role assumption, but it still
  uploads a GitHub Actions artifact.
- Production deploy writes/synchronizes S3 deploy assets and runs migrations,
  restart, health checks, and possible rollback on the production host.

Treat mutating workflows as controlled live smoke tests, not identity canaries.
Confirm a named operator, health dashboard and rollback owner before dispatch.
Stop after the first failed role assumption, unexpected AWS identity, failed
image pull, migration error, unhealthy readiness check, or manifest mismatch.

## Permission boundary notes

### EC2 instance-profile Parameter Store boundary

- The image publisher follows AWS's documented private-ECR push set. Only
  `ecr:GetAuthorizationToken` uses `Resource="*"`; every layer/manifest action
  is limited to the three configured Helix repository ARNs.
- Artifact publication can put only `cli-dist/*` / `plugin-dist/*` and can read
  only `plugin-dist/latest/manifest.json` for the plugin version gate. It cannot
  list or delete the bucket.
- DAX can read only `FEISHU_APP_ID` / `FEISHU_APP_SECRET`, write only the DAX
  approval-code parameter, upload only `deploy-assets/dax-*`, and send only the
  AWS-managed `AWS-RunShellScript` document to the exact Helix EC2 ARN.
- Production deploy can list/write/delete only `deploy-assets/*` and has the
  same exact document/instance SSM boundary. `ssm:GetCommandInvocation` is the
  only remaining SSM wildcard because AWS defines no resource type for it.
- Browser CORS verification is an unauthenticated OPTIONS probe; no upload-bucket
  permission belongs in the deploy role.
- Migrations, image pulls, health checks and rollback run on EC2 after SSM. The
  instance profile can pull only configured ECR repositories, read only the
  current account/region's `${project}/${environment}` SSM namespace, and
  Get/List only `deploy-assets/*`. The host has no Secrets Manager permission
  because the deployment and runtime contain no Secrets Manager consumer. This
  is sufficient for `deploy.sh` → migration → restart → healthcheck → rollback
  without giving the GitHub role application-secret or ECR-pull access.
  `AmazonSSMManagedInstanceCore` also grants only `GetParameter` and
  `GetParameters` on `*`; the Helix inline policy separately includes
  `GetParametersByPath`. The EC2 instance-profile inline policy therefore uses
  an explicit `NotResource` deny outside the exact application namespace. A
  scoped allow alone cannot narrow the managed allow. Before rollout, inventory
  State Manager, Maintenance Window, Patch/Distributor/UpdateSSMAgent and SSM
  document-parameter usage; the boundary intentionally denies public
  `/aws/service/*` parameters unless a separately reviewed exception is added.

## Production adoption (guarded workflow only)

The original OIDC rollout is complete. Its bootstrap sequence is historical
and must not be replayed. In particular, do not run direct targeted, replanned,
or ordinary Terraform applies against production. Those paths bypass the
reviewed-plan hash, merged evidence chain, exact state marker, and action
allowlist required by the current adoption contract.

All remaining production adoption work must follow the guarded workflow in
[`infra/aws/environments/prod/adoption/README.md`](../../infra/aws/environments/prod/adoption/README.md):

1. Run `stage01_guard.py plan` for the next contract batch and retain its
   private saved plan and JSON artifact.
2. Review and merge the generated `reviewed` evidence before any apply.
3. From that merged commit, run `stage01_guard.py apply
   --execute-reviewed-plan` against the exact saved-plan hashes. The guard must
   revalidate the production state marker and previous applied evidence.
4. Review and merge the resulting `applied` evidence only after state and
   production health checks pass.

Batch `04-iam` adopts exactly the 14 OIDC/role/policy/profile addresses assigned
to it by the contract. The DAX-deploy and production-deploy inline policies
remain in `05-post-compute-iam`, after the EC2 instance has been adopted. Never
move those policies into batch 04 or apply them outside the guarded sequence.

The CI contract in
`server/tests/test_deploy/test_oidc_least_privilege_contract.py` pins the trust
subjects, workflow-to-role mapping, and negative permission matrix.
