diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml new file mode 100644 index 0000000..4087eaf --- /dev/null +++ b/.github/actions/terraform/action.yml @@ -0,0 +1,33 @@ +name: Terraform (workspace-driven) +description: > + Run terraform for a root. The workspace and its variables come from the root's + workspace/.tfvars: the filename is the terraform workspace, the contents + are its variables. Keeps workspace selection declarative and version-controlled + (not a local, gitignored .terraform/environment). + +inputs: + root: + description: Path to the terraform root (passed to -chdir). + required: true + apply: + description: 'When "true", apply; otherwise plan.' + required: false + default: "false" + +runs: + using: composite + steps: + - shell: bash + env: + ROOT: ${{ inputs.root }} + APPLY: ${{ inputs.apply }} + run: | + set -euo pipefail + ws="$(basename "$ROOT"/workspace/*.tfvars .tfvars)" + terraform -chdir="$ROOT" init -input=false + terraform -chdir="$ROOT" workspace select -or-create "$ws" + if [ "$APPLY" = "true" ]; then + terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="workspace/$ws.tfvars" + else + terraform -chdir="$ROOT" plan -input=false -var-file="workspace/$ws.tfvars" + fi diff --git a/.github/workflows/s3-lister-role.yml b/.github/workflows/s3-lister-role.yml new file mode 100644 index 0000000..d057c80 --- /dev/null +++ b/.github/workflows/s3-lister-role.yml @@ -0,0 +1,50 @@ +name: Apply s3-lister-role + +# Terraform GitOps for the s3-lister-role/ root: the role is created/synchronised +# from the remote. Runs `apply` on every trigger (PRs included) so the CI role +# actually creates the role via OIDC. Credentials come from the aws-github-oidc +# CI role assumed via OIDC — no static AWS keys. + +on: + push: + branches: [main] + paths: + - 's3-lister-role/**' + - '.github/workflows/s3-lister-role.yml' + pull_request: + paths: + - 's3-lister-role/**' + - '.github/workflows/s3-lister-role.yml' + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + permissions: + id-token: write # mint the OIDC token exchanged for temporary AWS creds + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + # Keep in sync with mise.toml [tools] terraform version. + terraform_version: "1.14" + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: eu-west-3 + + # Workspace + variables are derived from s3-lister-role/workspace/*.tfvars. + # apply on every run (PRs included) — the point of this PR is to watch the + # CI role actually create the role via OIDC, not just plan it. + - uses: ./.github/actions/terraform + with: + root: s3-lister-role + apply: "true" diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index 8dcb1d0..435069e 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -28,6 +28,7 @@ jobs: - cluster/scaleway - github-ci - aws-github-oidc + - s3-lister-role steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/CLAUDE.md b/CLAUDE.md index 09396a5..faf3896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ mise run dev # Full local env: start minikube + terraform init + app mise run reset # Destroy minikube cluster # Provider lock files -mise run lock # Re-generate all 4 roots' .terraform.lock.hcl for darwin_arm64 + linux_amd64 +mise run lock # Re-generate every root's .terraform.lock.hcl for darwin_arm64 + linux_amd64 # Linting actionlint .github/workflows/*.yml # Lint GitHub Actions workflows (also runs as pre-push hook) @@ -38,6 +38,8 @@ terraform -chdir=terraform-state-bucket providers lock -platform=darwin_arm64 -p terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 ``` Commit the updated lock files alongside the version change. @@ -83,7 +85,7 @@ Standalone root (not under `cluster/` — provisions no cluster) that stands up Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI access, built on the `terraform-aws-modules/iam` modules: an OIDC provider + a role (`github-actions-terraform`) GitHub Actions assumes via short-lived tokens (trust scoped to `repo:IntegratedDynamic/infrastructure:*`). The role grant (`tf-managed-ci`) gives Terraform-state R/W on the state bucket **plus privilege-escalation-safe IAM role management**. Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `aws-github-oidc/README.md`. -**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. In CI, feed the path via `TF_VAR_role_path=/tf-managed/${{ github.repository }}/`. The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. +**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. Set both via the root's `workspace/.tfvars` (see the Terraform workspaces convention below). The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. ## Conventions @@ -92,3 +94,5 @@ Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI a **Commits**: Conventional Commits — `[scope]: `. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`. **PRs**: After each commit + push on a branch, create a draft PR if none exists. Title: `: description`. Body: context, changes, linked issues (`Closes #123`), test instructions. Use [Conventional Comments](https://conventionalcomments.org/) in reviews (`praise`, `nitpick`, `suggestion`, `issue`, `todo`, `question`, `thought`). + +**Terraform workspaces**: a root that runs through CI declares its workspaces declaratively in a `workspace/` folder — one `workspace/.tfvars` per workspace. The **filename is the terraform workspace name** (so state lands at `//`, isolated per root) and the **file contents are that workspace's variable values**. The reusable composite action `.github/actions/terraform` loops over these files, doing `workspace select -or-create ` + `plan`/`apply -var-file=workspace/.tfvars`. This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). `s3-lister-role/` is the reference example. diff --git a/mise.toml b/mise.toml index e154621..766904e 100644 --- a/mise.toml +++ b/mise.toml @@ -130,4 +130,5 @@ terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -p terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/s3-lister-role/.terraform.lock.hcl b/s3-lister-role/.terraform.lock.hcl new file mode 100644 index 0000000..a6f94df --- /dev/null +++ b/s3-lister-role/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.50.0" + constraints = "~> 6.0, >= 6.28.0" + hashes = [ + "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", + "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", + "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", + "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", + "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", + "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", + "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", + "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", + "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", + "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", + "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", + "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", + "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", + "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", + "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", + "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + ] +} diff --git a/s3-lister-role/main.tf b/s3-lister-role/main.tf new file mode 100644 index 0000000..c6ab29c --- /dev/null +++ b/s3-lister-role/main.tf @@ -0,0 +1,49 @@ +# An org-wide, read-only S3-lister IAM role — the first role created BY the CI +# rather than by a human. It is created/updated when a push to main touches this +# root (see .github/workflows/s3-lister-role.yml): GitHub Actions assumes the +# aws-github-oidc CI role and runs `terraform apply`. +# +# Two things are mandatory for the CI role to be ALLOWED to create this (see +# aws-github-oidc/iam-ci.tf): the role must sit under the managed `path` and +# carry the `permissions_boundary`. Omit either and the apply is denied. +# +# Trust: assumable by ANY principal in our AWS Organization. We don't list +# account IDs — instead the trust condition pins aws:PrincipalOrgID, so every +# (and only) caller whose credentials belong to org `var.org_id` may assume it. +module "s3_lister" { + source = "terraform-aws-modules/iam/aws//modules/iam-role" + version = "6.6.1" + + name = var.role_name + path = var.role_path + description = "Org-wide read-only S3 lister. Created by CI; capped by the tf-managed permissions boundary." + + permissions_boundary = var.permissions_boundary_arn + + trust_policy_permissions = { + OrgWideAssume = { + actions = ["sts:AssumeRole"] + principals = [{ + type = "AWS" + identifiers = ["*"] + }] + condition = [{ + test = "StringEquals" + variable = "aws:PrincipalOrgID" + values = [var.org_id] + }] + } + } + + # Same access the original GitHub Action had: list/read S3. The boundary clamps + # it anyway, but S3 read is well within the ceiling. + policies = { + S3ReadOnly = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + } + + tags = { + Terraform = "true" + Environment = "dev" + ManagedBy = "ci" + } +} diff --git a/s3-lister-role/outputs.tf b/s3-lister-role/outputs.tf new file mode 100644 index 0000000..8bb9d54 --- /dev/null +++ b/s3-lister-role/outputs.tf @@ -0,0 +1,4 @@ +output "role_arn" { + description = "ARN of the org-wide S3-lister role. Anyone in the org assumes it with `aws sts assume-role`." + value = module.s3_lister.arn +} diff --git a/s3-lister-role/variables.tf b/s3-lister-role/variables.tf new file mode 100644 index 0000000..67ec5c6 --- /dev/null +++ b/s3-lister-role/variables.tf @@ -0,0 +1,40 @@ +variable "region" { + description = "AWS region the provider operates in. Matches the state bucket region." + type = string + default = "eu-west-3" +} + +variable "role_name" { + description = "Name of the org-wide S3-lister role." + type = string + default = "s3-lister" +} + +# IAM path every CI-managed role must sit under. The CI grant (aws-github-oidc/) +# only allows role creation under this path WITH the boundary below — so both +# must be set or the apply is rejected. In CI this is fed automatically from the +# repo slug: TF_VAR_role_path=/tf-managed/${{ github.repository }}/ (see the +# workflow). The default lets `plan` work locally. IAM paths are case-sensitive. +variable "role_path" { + description = "IAM path prefix for the role (must match the CI grant's managed path)." + type = string + default = "/tf-managed/IntegratedDynamic/infrastructure/" +} + +# The permissions boundary that caps this role. Value is the +# `permissions_boundary_arn` output of the aws-github-oidc/ root. Required by the +# CI grant's conditions. +variable "permissions_boundary_arn" { + description = "ARN of the permissions boundary to attach (aws-github-oidc output)." + type = string + default = "arn:aws:iam::503577850357:policy/tf-managed-boundary" +} + +# AWS Organizations ID. Anyone whose credentials belong to this org may assume +# the role (aws:PrincipalOrgID trust condition). `aws organizations +# describe-organization --query Organization.Id`. +variable "org_id" { + description = "AWS Organizations ID allowed to assume the role (aws:PrincipalOrgID)." + type = string + default = "o-f9lb1e5es9" +} diff --git a/s3-lister-role/version.tf b/s3-lister-role/version.tf new file mode 100644 index 0000000..7cd85b6 --- /dev/null +++ b/s3-lister-role/version.tf @@ -0,0 +1,24 @@ +terraform { + # Remote state on the shared org S3 bucket, under this root's own key prefix. + # In CI the GitHub OIDC role (aws-github-oidc/) provides the credentials; it is + # granted R/W on this bucket, so `init`/`plan`/`apply` work without static keys. + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "s3-lister-role" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.region +} diff --git a/s3-lister-role/workspace/infrastructure.tfvars b/s3-lister-role/workspace/infrastructure.tfvars new file mode 100644 index 0000000..ac060f8 --- /dev/null +++ b/s3-lister-role/workspace/infrastructure.tfvars @@ -0,0 +1,17 @@ +# Workspace "infrastructure". +# +# The FILENAME names the terraform workspace (so state lands at +# s3-lister-role/infrastructure/terraform.tfstate, isolated from other roots). +# The CONTENTS are this workspace's variable values. Both are consumed by +# .github/actions/terraform — see that action and CLAUDE.md. + +region = "eu-west-3" +role_name = "s3-lister" + +# Repo-scoped path + boundary required by the CI grant (aws-github-oidc/). Must +# match the grant's pins exactly or the apply is denied. +role_path = "/tf-managed/IntegratedDynamic/infrastructure/" +permissions_boundary_arn = "arn:aws:iam::503577850357:policy/tf-managed-boundary" + +# Anyone in this AWS Organization may assume the role. +org_id = "o-f9lb1e5es9"