diff --git a/.github/workflows/scaleway-plan.yml b/.github/workflows/scaleway-plan.yml new file mode 100644 index 0000000..d6ade5b --- /dev/null +++ b/.github/workflows/scaleway-plan.yml @@ -0,0 +1,92 @@ +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: + - 'cluster/scaleway/**' + - 's3-lister-role/**' + - '.github/workflows/scaleway-plan.yml' + workflow_dispatch: + +permissions: + contents: read + +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: + # Select the existing remote workspace without `workspace new` (a write the + # read-only role can't perform). The default workspace would collide with + # the state bucket's own state at the bucket root. + TF_WORKSPACE: infrastructure + # 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 valid machine-identity creds just to configure. With + # bootstrap_argocd=false no secret is actually read. + TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }} + TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + 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 (see s3-lister-role/README.md)." + 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 github-ci/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 + + - name: Terraform plan (read-only role -> no lock, no apply) + run: | + terraform -chdir=cluster/scaleway init -input=false + terraform -chdir=cluster/scaleway plan \ + -lock=false -input=false -var bootstrap_argocd=false diff --git a/s3-lister-role/main.tf b/s3-lister-role/main.tf index c6ab29c..d63fade 100644 --- a/s3-lister-role/main.tf +++ b/s3-lister-role/main.tf @@ -7,19 +7,36 @@ # 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. +# Trust: two doors, both org-scoped, that the module ORs together. +# 1. AWS principals in our AWS Organization — aws:PrincipalOrgID pins the org, +# so any caller whose credentials already belong to `var.org_id` may assume +# it (e.g. an SSO session, or a role assumed elsewhere in the org). +# 2. GitHub Actions in our GitHub org, DIRECTLY via OIDC web identity — no +# routing through the bootstrap CI role. `enable_github_oidc` adds an +# sts:AssumeRoleWithWebIdentity statement; `oidc_wildcard_subjects` scopes +# the token `sub` to `repo:/*` (the module prepends `repo:`). This is +# the GitHub-org analogue of aws:PrincipalOrgID: any repo in the org, any +# branch, can assume the role keylessly. +# +# `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" { 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." + name = var.role_name + use_name_prefix = false + 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 + # Door 2: keyless GitHub-OIDC, org-wide. `var.github_oidc_subjects` are + # org/repo globs; the module prepends `repo:` and matches the token `sub`. + enable_github_oidc = true + oidc_wildcard_subjects = var.github_oidc_subjects + + # Door 1: AWS principals in our AWS Organization. trust_policy_permissions = { OrgWideAssume = { actions = ["sts:AssumeRole"] diff --git a/s3-lister-role/variables.tf b/s3-lister-role/variables.tf index 67ec5c6..d23ca96 100644 --- a/s3-lister-role/variables.tf +++ b/s3-lister-role/variables.tf @@ -38,3 +38,14 @@ variable "org_id" { type = string default = "o-f9lb1e5es9" } + +# GitHub OIDC subjects allowed to assume the role via web identity. Values are +# org/repo globs; the iam-role module prepends `repo:` and matches the token +# `sub` claim with StringLike. The org-wide default (`/*`) lets ANY repo on +# ANY branch in the GitHub org assume the role keylessly — the GitHub-org +# analogue of the aws:PrincipalOrgID trust. Tighten to specific repos if needed. +variable "github_oidc_subjects" { + description = "GitHub OIDC sub globs (org/repo, module prepends `repo:`) allowed to assume the role." + type = list(string) + default = ["IntegratedDynamic/*"] +} diff --git a/s3-lister-role/workspace/infrastructure.tfvars b/s3-lister-role/workspace/infrastructure.tfvars index ac060f8..139f287 100644 --- a/s3-lister-role/workspace/infrastructure.tfvars +++ b/s3-lister-role/workspace/infrastructure.tfvars @@ -15,3 +15,6 @@ permissions_boundary_arn = "arn:aws:iam::503577850357:policy/tf-managed-boundary # Anyone in this AWS Organization may assume the role. org_id = "o-f9lb1e5es9" + +# Any repo in this GitHub org may assume the role directly via OIDC web identity. +github_oidc_subjects = ["IntegratedDynamic/*"]