Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions infra/live/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
151 changes: 151 additions & 0 deletions infra/live/terraform-ci.tf
Original file line number Diff line number Diff line change
@@ -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
}
Loading