From eacf2c7c460d2af73dd6b804f2033a4c4a1226b1 Mon Sep 17 00:00:00 2001 From: Rafael Fernandes Date: Fri, 14 Aug 2026 16:48:05 -0300 Subject: [PATCH] feat(infra): run terraform plan/apply for infra/live from GitHub Actions Adds a second, separately-scoped OIDC role (rafer-dev-terraform-ci) so infra changes can be applied by CI instead of only from a local machine, matching the existing OIDC pattern used for the app deploy role. infra/bootstrap stays manual/local-only (one-time, chicken-and-egg w.r.t. the state backend it creates). This role can modify its own trust/permissions policy, since Terraform manages the very IAM role it runs as - IAM scoping limits the blast radius to just this role, the app-deploy role, and the OIDC provider, but the real mitigation is requiring PR review on main before merge, documented in infra/README.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/terraform.yml | 48 ++++++++++ infra/README.md | 20 +++++ infra/live/outputs.tf | 4 + infra/live/terraform-ci.tf | 151 ++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 .github/workflows/terraform.yml create mode 100644 infra/live/terraform-ci.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..f0cfd52 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,48 @@ +name: Terraform Infra + +concurrency: + group: terraform-live + cancel-in-progress: false + +on: + push: + branches: + - main + paths: + - 'infra/live/**' + workflow_dispatch: {} + +jobs: + terraform: + name: Plan & Apply + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + defaults: + run: + working-directory: infra/live + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: '1.10.5' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_TERRAFORM_CI_ROLE_ARN }} + aws-region: us-east-1 + + - name: Terraform Init + run: terraform init + + - name: Terraform Plan + run: terraform plan -out=tfplan + + - name: Terraform Apply + run: terraform apply -auto-approve tfplan diff --git a/infra/README.md b/infra/README.md index 41ff05b..6c7c199 100644 --- a/infra/README.md +++ b/infra/README.md @@ -36,10 +36,30 @@ Terraform outputs: - `LANDING_DISTRIBUTION_ID` ← `landing_distribution_id` - `STORYBOOK_BUCKET` ← `storybook_bucket_name` - `STORYBOOK_DISTRIBUTION_ID` ← `storybook_distribution_id` +- `AWS_TERRAFORM_CI_ROLE_ARN` ← `terraform_ci_role_arn` None of these are secret (they're resource identifiers, not credentials) — repo variables are the right place for them, not secrets. +## CI-driven Terraform (`live/` only) + +`.github/workflows/terraform.yml` runs `terraform plan` + `apply` on every push +to `main` that touches `infra/live/**` (or via manual `workflow_dispatch`), using +the `terraform_ci` OIDC role (`infra/live/terraform-ci.tf`) - separate from, and +more privileged than, the app-deploy role. + +**This role can modify its own trust policy and permissions** (it manages the +very IAM role it runs as, plus the GitHub OIDC provider). IAM scoping limits it +to touching only its own role, the app-deploy role, and that one OIDC provider - +it can't create unrelated IAM principals - but it can still grant *those two +roles* more power on a bad `apply`. The only real mitigation is **process, not +IAM**: turn on branch protection on `main` requiring PR review before merge, so +no infra change reaches `terraform apply` unreviewed. + +`infra/bootstrap/` is deliberately **not** wired into CI - it's a one-time, +low-frequency, chicken-and-egg config (it creates the state backend `live/` +depends on), so it stays a manual, local `terraform apply` only. + ## Notes / things intentionally out of scope - `raferdev.com` → `rafer.dev` redirect (currently handled by nginx) isn't covered diff --git a/infra/live/outputs.tf b/infra/live/outputs.tf index be1ca70..f3eac2f 100644 --- a/infra/live/outputs.tf +++ b/infra/live/outputs.tf @@ -17,3 +17,7 @@ output "storybook_distribution_id" { output "github_actions_deploy_role_arn" { value = aws_iam_role.github_actions_deploy.arn } + +output "terraform_ci_role_arn" { + value = aws_iam_role.terraform_ci.arn +} diff --git a/infra/live/terraform-ci.tf b/infra/live/terraform-ci.tf new file mode 100644 index 0000000..fb09a4c --- /dev/null +++ b/infra/live/terraform-ci.tf @@ -0,0 +1,151 @@ +# A second, more privileged GitHub Actions role, used only to run +# `terraform apply` against this config from CI. Kept separate from +# aws_iam_role.github_actions_deploy (github-oidc.tf), which stays locked to +# just S3 sync + CloudFront invalidation for the app-file deploy. +# +# IMPORTANT: this role can modify its own trust policy and inline policy +# (iam:UpdateAssumeRolePolicy / iam:PutRolePolicy scoped to its own ARN below), +# because Terraform manages the very role that runs Terraform. That's an +# inherent self-escalation risk of "apply from CI" for a config that manages +# its own IAM - the practical mitigation is requiring PR review (branch +# protection on main) before anything merges, not IAM scoping alone. + +data "aws_caller_identity" "current" {} + +resource "aws_iam_role" "terraform_ci" { + name = "rafer-dev-terraform-ci" + assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json +} + +data "aws_iam_policy_document" "terraform_ci" { + statement { + sid = "TerraformStateBackend" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket", + ] + resources = [ + "arn:aws:s3:::rafer-dev-tfstate", + "arn:aws:s3:::rafer-dev-tfstate/*", + ] + } + + statement { + sid = "TerraformStateLock" + effect = "Allow" + actions = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ] + resources = [ + "arn:aws:dynamodb:${var.region}:${data.aws_caller_identity.current.account_id}:table/rafer-dev-tfstate-lock", + ] + } + + statement { + sid = "ManageSiteBuckets" + effect = "Allow" + actions = ["s3:*"] + resources = [ + module.landing.bucket_arn, + "${module.landing.bucket_arn}/*", + module.storybook.bucket_arn, + "${module.storybook.bucket_arn}/*", + ] + } + + # CloudFront/ACM largely don't support resource-level IAM restriction for + # create actions (the resource ID doesn't exist yet) - full wildcard is the + # realistic option here, not a deliberate broadening. + statement { + sid = "ManageCloudFront" + effect = "Allow" + actions = ["cloudfront:*"] + resources = ["*"] + } + + statement { + sid = "ManageACM" + effect = "Allow" + actions = ["acm:*"] + resources = ["*"] + } + + statement { + sid = "ManageRoute53Records" + effect = "Allow" + actions = [ + "route53:ChangeResourceRecordSets", + "route53:ListResourceRecordSets", + "route53:GetHostedZone", + ] + resources = ["arn:aws:route53:::hostedzone/${data.aws_route53_zone.this.zone_id}"] + } + + statement { + sid = "Route53AccountWideLookups" + effect = "Allow" + actions = [ + "route53:GetChange", + "route53:ListHostedZones", + "route53:ListHostedZonesByName", + ] + resources = ["*"] + } + + # Scoped to only the two roles this config manages - not iam:* / resource "*". + statement { + sid = "ManageOwnDeployRoles" + effect = "Allow" + actions = [ + "iam:CreateRole", + "iam:GetRole", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:UpdateAssumeRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:PutRolePolicy", + "iam:GetRolePolicy", + "iam:DeleteRolePolicy", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + ] + resources = [ + aws_iam_role.github_actions_deploy.arn, + aws_iam_role.terraform_ci.arn, + ] + } + + statement { + sid = "ManageOIDCProvider" + effect = "Allow" + actions = [ + "iam:GetOpenIDConnectProvider", + "iam:CreateOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "iam:UpdateOpenIDConnectProviderThumbprint", + "iam:AddClientIDToOpenIDConnectProvider", + "iam:RemoveClientIDFromOpenIDConnectProvider", + ] + resources = [local.github_oidc_provider_arn] + } + + statement { + sid = "OIDCProviderListing" + effect = "Allow" + actions = ["iam:ListOpenIDConnectProviders"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "terraform_ci" { + name = "terraform-apply" + role = aws_iam_role.terraform_ci.id + policy = data.aws_iam_policy_document.terraform_ci.json +}