diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml index 4087eaf..a2669b9 100644 --- a/.github/actions/terraform/action.yml +++ b/.github/actions/terraform/action.yml @@ -1,33 +1,92 @@ -name: Terraform (workspace-driven) +name: Terraform 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). + Run Terraform for a workspace-driven root: set up Terraform, mint an Infisical + OIDC token, assume an AWS role via OIDC (for remote state access) and retrieve Scaleway Machine Identity credentials, then init → workspace select → + plan/apply/destroy. The repository must already be checked out. + + This action takes NO secret inputs. Provider credentials are read from the + calling job's environment (set them there — the reusable terraform.yml workflow + does, sourcing SCW_ACCESS_KEY/SCW_SECRET_KEY from its `secrets:` block): + - SCW_ACCESS_KEY / SCW_SECRET_KEY (Scaleway API key) + - SCW_DEFAULT_ORGANIZATION_ID / _PROJECT_ID + - INFISICAL_MACHINE_IDENTITY_ID (for the minted INFISICAL_AUTH_JWT) inputs: root: - description: Path to the terraform root (passed to -chdir). + description: Path to the Terraform root (passed to -chdir). + required: true + tfvars-file: + description: > + Filename of the .tfvars under env/ (e.g. 02-cluster-staging.tfvars). + Workspace name is derived by stripping the .tfvars extension. required: true - apply: - description: 'When "true", apply; otherwise plan.' + command: + description: 'Terraform command to run: plan | apply | destroy.' + required: false + default: plan + aws-role-arn: + description: ARN of the IAM role to assume via OIDC (Terraform-state R/W). + required: true + aws-region: + description: AWS region for the assumed role. + required: false + default: eu-west-3 + infisical-oidc-audience: + description: > + OIDC audience for the Infisical machine identity. When set (the default), + mint a GitHub OIDC JWT into INFISICAL_AUTH_JWT. Set to "" to skip it for + roots that don't read Infisical. required: false - default: "false" + default: https://github.com/IntegratedDynamic runs: using: composite steps: - - shell: bash + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + # Keep in sync with mise.toml [tools] terraform version. + terraform_version: "1.14" + + # Keyless Infisical auth: mint a GitHub OIDC JWT into INFISICAL_AUTH_JWT (the + # provider's default token env var). The audience must match the identity's + # boundAudiences. getIDToken masks the value automatically. + - name: Mint Infisical OIDC token + if: inputs.infisical-oidc-audience != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + AUDIENCE: ${{ inputs.infisical-oidc-audience }} + with: + script: | + const jwt = await core.getIDToken(process.env.AUDIENCE) + core.exportVariable('INFISICAL_AUTH_JWT', jwt) + + - name: Assume AWS role via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Terraform ${{ inputs.command }} + shell: bash env: ROOT: ${{ inputs.root }} - APPLY: ${{ inputs.apply }} + TFVARS: ${{ inputs.tfvars-file }} + COMMAND: ${{ inputs.command }} + # SCW_* and INFISICAL_MACHINE_IDENTITY_ID are inherited from the job env. run: | set -euo pipefail - ws="$(basename "$ROOT"/workspace/*.tfvars .tfvars)" + ws="${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" + # apply may target a brand-new env whose workspace doesn't exist yet; + # read paths (plan/destroy) select an existing one. + if [ "$COMMAND" = "apply" ]; then + terraform -chdir="$ROOT" workspace select -or-create "$ws" else - terraform -chdir="$ROOT" plan -input=false -var-file="workspace/$ws.tfvars" + terraform -chdir="$ROOT" workspace select "$ws" fi + case "$COMMAND" in + plan) terraform -chdir="$ROOT" plan -input=false -var-file="env/${TFVARS}" ;; + apply) terraform -chdir="$ROOT" apply -input=false -auto-approve -var-file="env/${TFVARS}" ;; + destroy) terraform -chdir="$ROOT" destroy -input=false -auto-approve -var-file="env/${TFVARS}" ;; + *) echo "::error::unknown command '$COMMAND' (expected plan|apply|destroy)"; exit 1 ;; + esac diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml index 7681912..0ec0eed 100644 --- a/.github/workflows/iam_terraform-backend-role.yml +++ b/.github/workflows/iam_terraform-backend-role.yml @@ -1,75 +1,57 @@ name: Apply Terraform Backend Role -# Terraform GitOps for the 00-remote_state/1-iam/ root: the org-wide S3 state -# access role is created/synchronised from remote. Plans on PR, applies on push -# to main. Credentials come from the identity/00-ci-trust CI role assumed via -# OIDC — no static AWS keys. +# Terraform GitOps for the 00-remote_state/1-iam/ root: the org-wide state-access +# role is created/synchronised from remote via the .github/actions/terraform +# composite action. Plans on PR, applies on push to main. Credentials come from +# the identity/00-ci-trust CI role assumed via OIDC — no static AWS keys. on: push: branches: [main] paths: - '00-remote_state/1-iam/**' + - '.github/actions/terraform/**' - '.github/workflows/iam_terraform-backend-role.yml' pull_request: paths: - '00-remote_state/1-iam/**' + - '.github/actions/terraform/**' - '.github/workflows/iam_terraform-backend-role.yml' +concurrency: + group: tf-backend-role-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: terraform: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: id-token: write # mint the OIDC token exchanged for temporary AWS creds contents: read steps: - # Fail fast and clearly if creds aren't set, instead of letting the - # tooling emit a confusing assume-role error. - - name: Assert credentials are present + - name: Assert state role ARN is present 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. Set it to the github-actions-terraform role ARN (provisioned by identity/00-ci-trust/)." + echo "::error::vars.AWS_GITHUB_ACTIONS_ROLE_ARN is not set (provisioned by identity/00-ci-trust/)." exit 1 fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + fetch-depth: 1 - - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - with: - # Keep in sync with mise.toml [tools] terraform version. - terraform_version: "1.14" - - - name: Assume the CI role via OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + - uses: ./.github/actions/terraform with: - role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - aws-region: eu-west-3 - - - name: Terraform plan - if: github.event_name == 'pull_request' - env: - TF_CHDIR: "00-remote_state/1-iam" - TF_ENVIRONMENT: "00-remote-state-iam.tfvars" - run: | - set -euo pipefail - terraform -chdir="${TF_CHDIR}" init -input=false - terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" - terraform -chdir="${TF_CHDIR}" plan -input=false -var-file="env/${TF_ENVIRONMENT}" - - - name: Terraform apply - if: github.event_name == 'push' - env: - TF_CHDIR: "00-remote_state/1-iam" - TF_ENVIRONMENT: "00-remote-state-iam.tfvars" - run: | - set -euo pipefail - terraform -chdir="${TF_CHDIR}" init -input=false - terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" - terraform -chdir="${TF_CHDIR}" apply -auto-approve -input=false -var-file="env/${TF_ENVIRONMENT}" + root: 00-remote_state/1-iam + tfvars-file: 00-remote-state-iam.tfvars + aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + command: ${{ github.event_name == 'push' && 'apply' || 'plan' }} + # This root touches neither Infisical nor Scaleway — skip the OIDC mint. + infisical-oidc-audience: "" diff --git a/.github/workflows/scaleway-plan.yml b/.github/workflows/scaleway-plan.yml deleted file mode 100644 index daec1e9..0000000 --- a/.github/workflows/scaleway-plan.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Scaleway Plan (via s3-lister role) - -# Plans cluster/scaleway using the org-wide s3-lister IAM role, assumed DIRECTLY -# via GitHub OIDC — no static AWS keys, and no routing through the bootstrap CI -# role (that role exists only to create/update roles, not to power pipelines). -# -# The s3-lister role is READ-ONLY on S3, which shapes the whole job: -# - state is read but never written -> plan only, never apply -# - the S3-native lock can't be written -> -lock=false -# - the workspace already exists in S3 -> select it via TF_WORKSPACE; -# never `terraform workspace new` (a write the read-only role can't do) -# - bootstrap_argocd=false -> skip the Infisical data source -# and the in-cluster helm/kubernetes providers (no cluster, no secrets in CI) -# -# Scaleway API access for the refresh still uses the static SCW key from secrets -# (Scaleway is not an OIDC relying party) — same creds as scaleway-auth-check.yml. - -on: - pull_request: - paths: - - '02-cluster/scaleway/**' - - '01-iam/aws/**' - - '.github/workflows/scaleway-plan.yml' - workflow_dispatch: - -permissions: - contents: read - id-token: write # This is required for requesting the JWT - -jobs: - plan: - runs-on: ubuntu-latest - # Scope SCW secret usage to a dedicated environment (matches - # scaleway-auth-check.yml; satisfies zizmor's secrets-outside-env audit). - environment: scaleway - permissions: - id-token: write # mint the OIDC token exchanged for temporary AWS creds - contents: read - env: - INFISICAL_MACHINE_IDENTITY_ID: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} - - # Scaleway provider creds for the refresh. Keys are secrets; the public - # org/project IDs are repo variables (not secrets). - SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} - SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} - SCW_DEFAULT_ORGANIZATION_ID: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} - SCW_DEFAULT_PROJECT_ID: ${{ vars.SCW_DEFAULT_PROJECT_ID }} - # The infisical provider authenticates eagerly even with bootstrap_argocd - # off (the gated data source still pulls the provider into the graph), so - # it needs to configure successfully — here via keyless GitHub-OIDC (see - # the "Mint Infisical OIDC token" step). With bootstrap_argocd=false no - # secret is actually read. - - steps: - # Fail fast and clearly if creds/vars aren't set, instead of letting the - # tooling emit a confusing assume-role or provider auth error. - - name: Assert credentials are present - env: - ROLE_ARN: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} - run: | - if [ -z "$ROLE_ARN" ]; then - echo "::error::vars.AWS_S3_LISTER_ROLE_ARN is not set. Set it to the s3-lister role ARN (provisioned by state/10-access)." - exit 1 - fi - if [ -z "$SCW_ACCESS_KEY" ] || [ -z "$SCW_SECRET_KEY" ]; then - echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set (see ci/10-scaleway/README.md)." - exit 1 - fi - - - 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" - - # Direct OIDC -> s3-lister. Works because the role's trust now includes a - # GitHub-OIDC statement scoped org-wide (repo:IntegratedDynamic/*). - - name: Assume the s3-lister role via OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - role-to-assume: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} - aws-region: eu-west-3 - - # Mint a GitHub OIDC JWT for the Infisical machine identity and stash it in - # $INFISICAL_AUTH_JWT (the provider's default token env var). audience must - # match the identity's boundAudiences. This is the keyless counterpart to - # the universal-auth client_id/secret used locally — no static Infisical - # secret in CI. core.getIDToken is the official @actions/core way to mint - # the token (it masks the value automatically). - - name: Mint Infisical OIDC token - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const jwt = await core.getIDToken('https://github.com/IntegratedDynamic') - core.exportVariable('INFISICAL_AUTH_JWT', jwt) - - - name: Terraform plan (read-only role -> no lock, no apply) - env: - TF_CHDIR: "02-cluster/scaleway/" - TF_ENVIRONMENT: "02-cluster-staging.tfvars" - run: | - set -euo pipefail - terraform -chdir="${TF_CHDIR}" init -input=false - terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" - terraform -chdir="${TF_CHDIR}" destroy -auto-approve -input=false -var-file="env/${TF_ENVIRONMENT}" diff --git a/.github/workflows/scaleway.yml b/.github/workflows/scaleway.yml new file mode 100644 index 0000000..1cd407c --- /dev/null +++ b/.github/workflows/scaleway.yml @@ -0,0 +1,102 @@ +name: Terraform — Scaleway cluster + +# Drives 02-cluster/scaleway through the .github/actions/terraform composite action: +# - pull_request → plan +# - push to main → apply +# - schedule (daily 18h) → destroy (cost-control teardown of the homelab) +# - workflow_dispatch → up/down on demand (plan | apply | destroy) +# +# State R/W + lock uses the org state role (vars.AWS_TF_STATE_ROLE_ARN — +# AmazonS3FullAccess, see 00-remote_state/1-iam). Scaleway cluster lifecycle uses +# the CI API key from the `scaleway` environment (Kubernetes/VPC/PrivateNetwork +# FullAccess, see 01-iam/scaleway). Provider creds are set as job env (read by the +# action) — never passed as plain action inputs. No static AWS keys. + +on: + pull_request: + paths: + - '02-cluster/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/scaleway.yml' + push: + branches: [main] + paths: + - '02-cluster/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/scaleway.yml' + schedule: + # 16:00 UTC = 18:00 Europe/Paris in summer (CEST). GitHub cron is UTC and does + # NOT follow DST, so in winter (CET) this fires at 17:00 Paris. + - cron: '0 16 * * *' + workflow_dispatch: + inputs: + command: + description: 'Terraform command (apply = up, destroy = down).' + type: choice + options: [plan, apply, destroy] + default: plan + +concurrency: + # Serialize everything that writes this root's state (apply/destroy never cancel + # mid-flight); only cancel superseded PR plans. schedule/push share ref=main. + group: scaleway-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + timeout-minutes: 20 + # The `scaleway` environment carries SCW_ACCESS_KEY / SCW_SECRET_KEY (and + # satisfies zizmor's secrets-in-environment audit). It currently has no + # protection rule, so the daily scheduled destroy runs unattended — keep it + # that way (a required-reviewer rule would block the cron). + environment: scaleway + permissions: + id-token: write # mint OIDC tokens for AWS + Infisical + contents: read + # Provider creds exposed to the composite action via the job env (not inputs). + env: + SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} + SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} + SCW_DEFAULT_ORGANIZATION_ID: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} + SCW_DEFAULT_PROJECT_ID: ${{ vars.SCW_DEFAULT_PROJECT_ID }} + INFISICAL_MACHINE_IDENTITY_ID: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} + steps: + - name: Assert state role ARN is present + env: + ROLE_ARN: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::vars.AWS_TF_STATE_ROLE_ARN is not set (provisioned by 00-remote_state/1-iam)." + exit 1 + fi + + - name: Resolve terraform command + id: cmd + env: + EVENT: ${{ github.event_name }} + DISPATCH_CMD: ${{ github.event.inputs.command }} + run: | + case "$EVENT" in + schedule) cmd=destroy ;; + push) cmd=apply ;; + workflow_dispatch) cmd="${DISPATCH_CMD:-plan}" ;; + *) cmd=plan ;; + esac + echo "command=$cmd" >> "$GITHUB_OUTPUT" + echo "Resolved terraform command: $cmd (event: $EVENT)" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - uses: ./.github/actions/terraform + with: + root: 02-cluster/scaleway + tfvars-file: 02-cluster-staging.tfvars + command: ${{ steps.cmd.outputs.command }} + aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} diff --git a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars index d3d9450..fb0bf20 100644 --- a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars +++ b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars @@ -6,7 +6,7 @@ # .github/actions/terraform — see that action and CLAUDE.md. region = "eu-west-3" -role_name = "s3-lister" +role_name = "tf-state-access" # Repo-scoped path + boundary required by the CI grant (identity/00-ci-trust/). Must # match the grant's pins exactly or the apply is denied. diff --git a/00-remote_state/1-iam/main.tf b/00-remote_state/1-iam/main.tf index 51041b2..bbe9359 100644 --- a/00-remote_state/1-iam/main.tf +++ b/00-remote_state/1-iam/main.tf @@ -1,6 +1,8 @@ -# 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 +# An org-wide Terraform-state access IAM role (read/write + state lock, via +# AmazonS3FullAccess) — the first role created BY the CI rather than by a human. +# Every state-touching workflow assumes it for plan AND apply/destroy alike. +# It is created/updated when a push to main touches this root (see +# .github/workflows/iam_terraform-backend-role.yml): GitHub Actions assumes the # identity/00-ci-trust CI role and runs `terraform apply`. # # Two things are mandatory for the CI role to be ALLOWED to create this (see @@ -20,7 +22,7 @@ # # `use_name_prefix = false` keeps the role name EXACTLY `var.role_name` (no random # suffix) so its ARN is stable and a workflow can name it in configure-aws-credentials. -module "s3_lister" { +module "tf_state_access" { source = "terraform-aws-modules/iam/aws//modules/iam-role" version = "6.6.1" @@ -51,7 +53,7 @@ module "s3_lister" { }] } } - + policies = { S3FullAccess = "arn:aws:iam::aws:policy/AmazonS3FullAccess" } diff --git a/00-remote_state/1-iam/outputs.tf b/00-remote_state/1-iam/outputs.tf index 8bb9d54..8e9f392 100644 --- a/00-remote_state/1-iam/outputs.tf +++ b/00-remote_state/1-iam/outputs.tf @@ -1,4 +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 + description = "ARN of the org-wide Terraform-state access role (R/W + lock; named `tf-state-access`). Anyone in the org assumes it with `aws sts assume-role`." + value = module.tf_state_access.arn } diff --git a/00-remote_state/1-iam/variables.tf b/00-remote_state/1-iam/variables.tf index 23b0668..f35cba7 100644 --- a/00-remote_state/1-iam/variables.tf +++ b/00-remote_state/1-iam/variables.tf @@ -5,9 +5,9 @@ variable "region" { } variable "role_name" { - description = "Name of the org-wide S3-lister role." + description = "Name of the org-wide Terraform-state access role (R/W + lock). Renaming it changes the ARN — keep vars.AWS_TF_STATE_ROLE_ARN in sync." type = string - default = "s3-lister" + default = "tf-state-access" } # IAM path every CI-managed role must sit under. The CI grant (identity/00-ci-trust/) diff --git a/01-iam/scaleway/main.tf b/01-iam/scaleway/main.tf index c878efc..d78075f 100644 --- a/01-iam/scaleway/main.tf +++ b/01-iam/scaleway/main.tf @@ -3,10 +3,11 @@ resource "scaleway_iam_application" "this" { description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." } -# Dummy permission for the IAM Scaleway User. TBD +# Lets CI manage the Kapsule cluster end-to-end (create/destroy): the K8s cluster +# itself plus its VPC + private network + IPAM lookups. Project-scoped. resource "scaleway_iam_policy" "this" { - name = "github-ci-object-storage-ro" - description = "Read-only Object Storage for the GitHub Actions CI application, project-scoped." + name = "github-ci-cluster-management" + description = "Kubernetes/VPC/PrivateNetwork management for the GitHub Actions CI application, project-scoped." application_id = scaleway_iam_application.this.id rule { diff --git a/CLAUDE.md b/CLAUDE.md index 684d372..aba490e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,17 +94,17 @@ The shared org S3 bucket holding **every** root's remote state (built on `terraf ### `state/10-access/` -Org-wide, read-only S3-lister IAM **role created BY the CI** (the first role minted by `identity/00-ci-trust`'s role-creator rather than by a human). Assumable org-wide via two trust doors: AWS principals in the org (`aws:PrincipalOrgID`) and GitHub Actions in the org via OIDC (`repo:IntegratedDynamic/*`). Its true responsibility is **read access to the Terraform state** — used e.g. by the `scaleway-plan` workflow. Applied by CI (`s3-lister-role.yml`). +Org-wide Terraform-state **access** IAM **role created BY the CI** (the first role minted by `identity/00-ci-trust`'s role-creator rather than by a human). Named `tf-state-access`, it grants `AmazonS3FullAccess` — **read/write on the state bucket plus the state lock** — so every state-touching workflow assumes it for `plan` AND `apply`/`destroy` alike (e.g. the `scaleway` workflow), wired via `vars.AWS_TF_STATE_ROLE_ARN`. Assumable org-wide via two trust doors: AWS principals in the org (`aws:PrincipalOrgID`) and GitHub Actions in the org via OIDC (`repo:IntegratedDynamic/*`). Applied by CI (`iam_terraform-backend-role.yml`). ### `ci/10-scaleway/` -Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `ci/10-scaleway/README.md`. +Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a project-scoped policy (`Kubernetes`/`VPC`/`PrivateNetworks` FullAccess + `IPAMReadOnly`, enough for CI to create/destroy the Kapsule cluster) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `ci/10-scaleway/README.md`. ### `identity/00-ci-trust/` The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** 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** — i.e. it is the role that **creates other CI roles** (e.g. `state/10-access`). Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `identity/00-ci-trust/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. 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 `identity/00-ci-trust/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 `env/.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 `identity/00-ci-trust/iam-ci.tf`. ## Conventions @@ -114,4 +114,4 @@ The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** acc **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). `state/10-access/` is the reference example. +**Terraform workspaces**: a root that runs through CI declares its workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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`** takes `root` + `tfvars-file` + `command` (`plan`/`apply`/`destroy`) + `aws-role-arn` (all non-secret inputs) and runs `workspace select ` + the command `-var-file=env/.tfvars`, after minting an Infisical OIDC token (skippable) and assuming the AWS role via OIDC. The action takes **no secret inputs**: provider credentials (`SCW_*`, `INFISICAL_MACHINE_IDENTITY_ID`) are read from the job env. The calling **workflow** owns the trigger→command mapping (push → apply, schedule → destroy, else plan), the `concurrency` guard, and the `environment` that scopes credentials — Scaleway keys live in the `scaleway` environment and are exposed to the action as job `env:` (never as plain inputs). The repo must be checked out before calling the action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key).