diff --git a/.github/workflows/tf-apply.yml b/.github/workflows/tf-apply.yml new file mode 100644 index 000000000..9d713797d --- /dev/null +++ b/.github/workflows/tf-apply.yml @@ -0,0 +1,87 @@ +name: Terraform Apply + +on: + push: + branches: [main] + paths: + - 'infra/terraform/website/**' + - '.github/workflows/tf-apply.yml' + workflow_dispatch: + inputs: + ref: + description: 'Git ref to apply (default: current default branch)' + required: false + type: string + +concurrency: + group: tf-apply-website + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + apply: + name: terraform apply (infra/terraform/website) + runs-on: ubuntu-latest + environment: iii-website-prod-tf-apply + timeout-minutes: 15 + + env: + AWS_REGION: us-east-1 + TF_IN_AUTOMATION: 'true' + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: '1.9.8' + terraform_wrapper: false + + - name: Configure AWS credentials (GitHub OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_TF_APPLY_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Terraform init + working-directory: infra/terraform/website + run: terraform init -input=false + + - name: Terraform apply + id: apply + working-directory: infra/terraform/website + env: + TF_VAR_alarm_email: ${{ secrets.ALARM_EMAIL }} + run: | + set -o pipefail + terraform apply -input=false -auto-approve -no-color 2>&1 | tee apply.txt + { + echo 'apply<> "$GITHUB_OUTPUT" + + - name: Job summary + if: always() + env: + APPLY: ${{ steps.apply.outputs.apply }} + run: | + { + echo "## terraform apply — \`infra/terraform/website\`" + echo + echo "- Commit: \`${{ github.sha }}\`" + echo "- Ref: \`${{ inputs.ref || github.ref }}\`" + echo + echo '
Apply output' + echo + echo '```' + echo "${APPLY:-(no apply output captured)}" + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/terraform/website/iam_github_oidc.tf b/infra/terraform/website/iam_github_oidc.tf index d504f9d42..43bfa64df 100644 --- a/infra/terraform/website/iam_github_oidc.tf +++ b/infra/terraform/website/iam_github_oidc.tf @@ -87,8 +87,44 @@ resource "aws_iam_role_policy_attachment" "github_deploy_website" { policy_arn = aws_iam_policy.github_deploy_website.arn } -# Read-only role for `tf-plan.yml`. Separate from the deploy role so a malicious -# PR can't accidentally run `terraform apply` with write permissions. +data "aws_iam_policy_document" "github_tf_apply_trust" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = [ + "repo:${var.github_repo}:environment:${var.github_tf_apply_environment}", + ] + } + } +} + +resource "aws_iam_role" "github_tf_apply" { + name = "iii-website-prod-github-tf-apply" + description = "Assumed by GitHub Actions from the ${var.github_tf_apply_environment} environment to run `terraform apply` against infra/terraform/website. Configure that environment with required reviewers in repo settings to gate applies." + assume_role_policy = data.aws_iam_policy_document.github_tf_apply_trust.json + max_session_duration = 3600 +} + +resource "aws_iam_role_policy_attachment" "github_tf_apply_admin" { + role = aws_iam_role.github_tf_apply.name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + data "aws_iam_policy_document" "github_tf_plan_trust" { statement { effect = "Allow" diff --git a/infra/terraform/website/outputs.tf b/infra/terraform/website/outputs.tf index 0e3a8a1fe..131ce2708 100644 --- a/infra/terraform/website/outputs.tf +++ b/infra/terraform/website/outputs.tf @@ -38,6 +38,11 @@ output "github_tf_plan_role_arn" { value = aws_iam_role.github_tf_plan.arn } +output "github_tf_apply_role_arn" { + description = "Admin IAM role ARN assumed by the tf-apply GitHub Actions workflow on push to main — set as repo-level GitHub secret AWS_TF_APPLY_ROLE_ARN" + value = aws_iam_role.github_tf_apply.arn +} + output "sns_alarms_topic_arn" { description = "SNS topic ARN that receives production alarms" value = aws_sns_topic.alarms.arn diff --git a/infra/terraform/website/variables.tf b/infra/terraform/website/variables.tf index 510515143..7ab6ba226 100644 --- a/infra/terraform/website/variables.tf +++ b/infra/terraform/website/variables.tf @@ -58,6 +58,14 @@ variable "github_environment" { default = "iii-website-prod" } +variable "github_tf_apply_environment" { + # Distinct from github_environment so the apply env can be configured with + # required reviewers without gating routine S3 deploys. + description = "GitHub environment scoping the tf-apply role. Configure required reviewers on this env in repo settings to gate applies." + type = string + default = "iii-website-prod-tf-apply" +} + variable "csp_report_only" { description = "Send CSP as report-only instead of enforcing." type = bool @@ -65,20 +73,13 @@ variable "csp_report_only" { } variable "manage_apex_records" { - # Leave false until Phase 4 cutover. When false, the apex records are not managed - # by this module (they already exist — manually created in Route53, not - # External-DNS-owned). Flip to true after importing the existing records. description = "Whether Terraform manages the iii.dev apex A/AAAA Route53 records." type = bool - default = false + default = true } variable "manage_www_records" { - # Leave false until www.iii.dev has been released by External-DNS (argocd PR - # removing the iii-dev-www Ingress has merged + synced). Flip to true after - # importing the existing records. Decoupled from apex so the two can be - # cut over independently to limit blast radius. description = "Whether Terraform manages the www.iii.dev A/AAAA Route53 records." type = bool - default = false + default = true }