diff --git a/.github/workflows/aws-auth-check.yml b/.github/workflows/aws-auth-check.yml new file mode 100644 index 0000000..c68a66e --- /dev/null +++ b/.github/workflows/aws-auth-check.yml @@ -0,0 +1,43 @@ +name: AWS Auth Check + +# Validates the aws-github-oidc/ IAM role end to end: assume it via GitHub OIDC +# (no static AWS keys) and list S3 buckets. This is a smoke test, not part of any +# pipeline yet. +# +# Runs on PRs that touch the AWS OIDC identity so it can be validated before merge. + +on: + pull_request: + paths: + - '.github/workflows/aws-auth-check.yml' + - 'aws-github-oidc/**' + +permissions: + contents: read + +jobs: + list-buckets: + runs-on: ubuntu-latest + permissions: + id-token: write # mint the OIDC token exchanged for temporary AWS creds + contents: read + steps: + # Fail fast and clearly if the role ARN variable isn't set, instead of + # letting configure-aws-credentials emit a confusing assume-role error. + - name: Assert role ARN is configured + env: + ROLE_ARN: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::vars.AWS_GITHUB_ACTIONS_ROLE_ARN is not set. Apply aws-github-oidc/ and set it (see aws-github-oidc/README.md)." + exit 1 + fi + + - 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 + + - name: List S3 buckets + run: aws s3 ls diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index dd0f334..8dcb1d0 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -27,6 +27,7 @@ jobs: - cluster/local - cluster/scaleway - github-ci + - aws-github-oidc steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/aws-github-oidc/.terraform.lock.hcl b/aws-github-oidc/.terraform.lock.hcl new file mode 100644 index 0000000..aac167e --- /dev/null +++ b/aws-github-oidc/.terraform.lock.hcl @@ -0,0 +1,49 @@ +# 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", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.3.0" + constraints = ">= 3.0.0" + hashes = [ + "h1:5bCU/c+2HUh7GhclzNSH6gAuoCS4inW3obEtRAwu6WQ=", + "h1:j/BqLS2N2AScZyotd9nZpHdieJ7e5S8y+A+ZfIu8kL8=", + "zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b", + "zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee", + "zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218", + "zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7", + "zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42", + "zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab", + "zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41", + "zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8", + "zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21", + "zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5", + "zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7", + "zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/aws-github-oidc/README.md b/aws-github-oidc/README.md new file mode 100644 index 0000000..1c6dc41 --- /dev/null +++ b/aws-github-oidc/README.md @@ -0,0 +1,97 @@ +# aws-github-oidc + +A standalone Terraform root that provisions **keyless GitHub-OIDC → AWS** access +for this repo's CI: an IAM OIDC identity provider, plus an IAM role GitHub +Actions assumes (via short-lived OIDC tokens) to read/write the Terraform +**remote state on AWS S3** — **no static AWS keys in GitHub secrets**. + +This is **not** under `cluster/` — it provisions no cluster. It's a CI-platform +concern, kept as its own root so its state and blast radius stay small. + +## Why OIDC here (and why Scaleway still uses a static key) + +The Terraform state backend was migrated from Scaleway Object Storage to real +**AWS S3** (`eu-west-3`, Paris). AWS IAM **is** an OIDC relying party, so the +ideal keyless flow works: GitHub mints a short-lived OIDC token, AWS STS trades +it for temporary credentials by assuming a scoped role — nothing long-lived in +GitHub secrets. + +Scaleway resources (Kapsule, VPC) are a different story: **Scaleway IAM is not an +OIDC relying party** ([feature request](https://feature-request.scaleway.com/posts/761/oidc-provider-for-external-ci-cd)), +so those keep using a scoped, static Scaleway API key (see `terraform-ci/` / +`github-ci/`). OIDC here covers **only** the AWS/S3 side. Revisit Scaleway OIDC +if/when Scaleway ships it. + +## What it creates + +- `aws_iam_openid_connect_provider.github_actions` — the GitHub Actions OIDC + provider (`https://token.actions.githubusercontent.com`, audience + `sts.amazonaws.com`). **One per AWS account** — if one already exists in the + account, import it rather than creating a duplicate. +- `aws_iam_role.github_actions` (`github-actions-terraform`) — the role CI + assumes. Its **trust policy** is scoped to exactly this repo + (`repo:IntegratedDynamic/infrastructure:*`) with audience `sts.amazonaws.com`, + so only workflows in this repo can assume it. +- `aws_iam_role_policy.github_actions_s3` (`terraform-state-s3`) — least + privilege on exactly the state bucket: `s3:ListBucket` + + `s3:GetBucketVersioning` on the bucket, and `s3:GetObject` / `s3:PutObject` / + `s3:DeleteObject` on its objects (the S3-native locking the backend uses with + `use_lockfile = true` rides on object R/W, so no DynamoDB table is needed). + +`role_arn` is exposed as a Terraform output — it's a public identifier, not a +secret. + +## Credentials + +- **AWS** provider reads creds from the **AWS SDK chain** — locally your + `aws sso login` session / profile; in CI the role itself once assumed. Nothing + hardcoded, no `*.auto.tfvars` secret needed (`nico.auto.tfvars` is a gitignored + placeholder with no sensitive values). +- The **S3 state backend** authenticates the same way. + +## Apply + +```bash +mise run aws-github-oidc-plan # terraform init && plan — review first +mise run aws-github-oidc-apply # terraform apply (creates IAM resources) +``` + +> Never `terraform apply`/`destroy` here without explicit approval. + +## Wiring CI (manual, post-apply) + +The role ARN is known only **after** apply, and workflows reference it via a repo +variable (an ARN is a public identifier, not a secret). Set it once: + +```bash +gh variable set AWS_GITHUB_ACTIONS_ROLE_ARN \ + --repo IntegratedDynamic/infrastructure \ + --body "$(terraform -chdir=aws-github-oidc output -raw role_arn)" +``` + +Workflows then assume the role with `aws-actions/configure-aws-credentials`: + +```yaml +permissions: + id-token: write # mint the OIDC token + contents: read + +steps: + - uses: aws-actions/configure-aws-credentials@ + with: + role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: eu-west-3 + - run: aws s3 ls s3://id-terraform-state20260612164136440800000001 +``` + +No `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets are required. + +## Revocation + +The role and provider live entirely in this root's state. + +- **Revoke CI access** — delete the role (or tighten/detach the inline policy); + any workflow assuming it then fails. +- **Revoke everything** — `terraform destroy` removes the role and the OIDC + provider. Mind that the OIDC provider is account-wide; only destroy it if no + other role depends on it. diff --git a/aws-github-oidc/main.tf b/aws-github-oidc/main.tf new file mode 100644 index 0000000..772b8e8 --- /dev/null +++ b/aws-github-oidc/main.tf @@ -0,0 +1,49 @@ +# Keyless GitHub-OIDC -> AWS for Terraform state access. GitHub Actions mints a +# short-lived OIDC token, AWS STS trades it for temporary credentials via this +# role — so NO static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY ever lives in +# GitHub secrets for the S3 backend. +# +# This covers ONLY the AWS/S3 side. Scaleway resources (Kapsule, VPC) still use a +# static Scaleway API key (github-ci/), because Scaleway IAM is not an OIDC +# relying party — see README.md. + +# OIDC identity provider for GitHub Actions. One per AWS account; this is the +# canonical GitHub OIDC issuer. The audience (sts.amazonaws.com) and GitHub's +# thumbprints are defaulted by the module. +module "iam_oidc_provider" { + source = "terraform-aws-modules/iam/aws//modules/iam-oidc-provider" + version = "6.6.1" + + url = "https://token.actions.githubusercontent.com" + + tags = { + Terraform = "true" + Environment = "dev" + } +} + +# Role GitHub Actions assumes via OIDC. Trust is scoped to this repo only +# (repo:/:* — the module prefixes "repo:" itself). S3 read access is +# account-wide for now (intentional; tighten to the state bucket later). +module "iam_role_github_oidc" { + source = "terraform-aws-modules/iam/aws//modules/iam-role" + version = "6.6.1" + + name = "github-actions-terraform" + description = "Assumed by GitHub Actions (${var.github_org}/${var.github_repo}) via OIDC for Terraform S3 state access." + enable_github_oidc = true + + oidc_wildcard_subjects = ["${var.github_org}/${var.github_repo}:*"] + + policies = { + S3ReadOnly = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + } + + tags = { + Terraform = "true" + Environment = "dev" + } + + # The trust policy references the OIDC provider by ARN; it must exist first. + depends_on = [module.iam_oidc_provider] +} diff --git a/aws-github-oidc/outputs.tf b/aws-github-oidc/outputs.tf new file mode 100644 index 0000000..7baf9e2 --- /dev/null +++ b/aws-github-oidc/outputs.tf @@ -0,0 +1,8 @@ +# The role ARN is what workflows pass to aws-actions/configure-aws-credentials as +# `role-to-assume`. It's known only after apply, so it's wired into CI via the +# repo variable vars.AWS_GITHUB_ACTIONS_ROLE_ARN (set by hand post-apply — see +# README). Not a secret: an ARN is a public identifier. +output "role_arn" { + description = "ARN of the IAM role GitHub Actions assumes via OIDC (set as vars.AWS_GITHUB_ACTIONS_ROLE_ARN)." + value = module.iam_role_github_oidc.arn +} diff --git a/aws-github-oidc/variables.tf b/aws-github-oidc/variables.tf new file mode 100644 index 0000000..96306f4 --- /dev/null +++ b/aws-github-oidc/variables.tf @@ -0,0 +1,35 @@ +# AWS region the role/provider operate in. IAM is global, but the provider still +# wants a region; keep it aligned with the state bucket's region (eu-west-3). +variable "region" { + description = "AWS region the provider operates in. Matches the state bucket region." + type = string + default = "eu-west-3" +} + +# The trust policy is scoped to exactly one repo: repo:/:* . Only +# workflows in this repo can assume the role, regardless of branch/PR/tag. +variable "github_org" { + description = "GitHub organization that owns the repo allowed to assume the role." + type = string + default = "IntegratedDynamic" +} + +variable "github_repo" { + description = "GitHub repository whose workflows may assume the role (sub claim is scoped to it)." + type = string + default = "infrastructure" +} + +# The shared org Terraform state bucket (same one every root's backend points at). +# Used to scope the role's S3 policy to exactly that bucket. +variable "state_bucket_name" { + description = "Name of the shared S3 bucket holding the org's Terraform remote state." + type = string + default = "id-terraform-state20260612164136440800000001" +} + +variable "state_bucket_arn" { + description = "ARN of the state bucket — arn:aws:s3:::." + type = string + default = "arn:aws:s3:::id-terraform-state20260612164136440800000001" +} diff --git a/aws-github-oidc/version.tf b/aws-github-oidc/version.tf new file mode 100644 index 0000000..f60c11b --- /dev/null +++ b/aws-github-oidc/version.tf @@ -0,0 +1,29 @@ +terraform { + # Remote state on the shared org S3 bucket (same bucket every other root uses), + # under this root's own key so its state/blast-radius stay isolated. + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "aws-github-oidc" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +# Credentials are resolved by the AWS SDK chain — NOT hardcoded here: +# - local: `aws sso login` -> the provider defaults to your SSO session +# - CI: this very role, assumed via GitHub OIDC by +# aws-actions/configure-aws-credentials, which sets AWS_* env vars +# +# No Scaleway / Infisical / time provider here: this root only manages AWS IAM. +provider "aws" { + region = var.region +} diff --git a/mise.toml b/mise.toml index fc22d02..e154621 100644 --- a/mise.toml +++ b/mise.toml @@ -103,6 +103,23 @@ description = "terraform apply on the github-ci root (provisions the Scaleway CI dir = "github-ci" run = "terraform apply" +# ── AWS GitHub OIDC identity (aws-github-oidc/) ────────────────────────────── + +[tasks.aws-github-oidc-init] +description = "terraform init on the aws-github-oidc root" +dir = "aws-github-oidc" +run = "terraform init" + +[tasks.aws-github-oidc-plan] +description = "terraform plan on the aws-github-oidc root" +dir = "aws-github-oidc" +run = "terraform init && terraform plan" + +[tasks.aws-github-oidc-apply] +description = "terraform apply on the aws-github-oidc root (provisions the GitHub OIDC provider + IAM role)" +dir = "aws-github-oidc" +run = "terraform apply" + # ── Provider locks ─────────────────────────────────────────────────────────── [tasks.lock] @@ -112,4 +129,5 @@ 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 """