Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/actions/terraform/action.yml
Original file line number Diff line number Diff line change
@@ -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/<name>.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
50 changes: 50 additions & 0 deletions .github/workflows/s3-lister-role.yml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .github/workflows/terraform-lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
- cluster/scaleway
- github-ci
- aws-github-oidc
- s3-lister-role
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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/<org>/<repo>/`), 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/<org>/<repo>/`), or the apply is rejected by the CI grant's conditions. Set both via the root's `workspace/<name>.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

Expand All @@ -92,3 +94,5 @@ Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI a
**Commits**: Conventional Commits — `<type>[scope]: <description>`. 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: `<type>: 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/<name>.tfvars` per workspace. The **filename is the terraform workspace name** (so state lands at `<workspace_key_prefix>/<name>/<key>`, 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 <name>` + `plan`/`apply -var-file=workspace/<name>.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.
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
27 changes: 27 additions & 0 deletions s3-lister-role/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions s3-lister-role/main.tf
Original file line number Diff line number Diff line change
@@ -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"
}
}
4 changes: 4 additions & 0 deletions s3-lister-role/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions s3-lister-role/variables.tf
Original file line number Diff line number Diff line change
@@ -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"
}
24 changes: 24 additions & 0 deletions s3-lister-role/version.tf
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions s3-lister-role/workspace/infrastructure.tfvars
Original file line number Diff line number Diff line change
@@ -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"
Loading