diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml deleted file mode 100644 index f757ed4..0000000 --- a/.github/workflows/iam_terraform-backend-role.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Apply Terraform Backend Role - -# Terraform GitOps for the 01-iam/ci-managed/aws-state-access/ 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 01-iam/bootstrap/aws CI role assumed via OIDC — no -# static AWS keys. - -on: - push: - branches: [main] - paths: - - '01-iam/ci-managed/aws-state-access/**' - - '.github/actions/terraform/**' - - '.github/workflows/iam_terraform-backend-role.yml' - pull_request: - paths: - - '01-iam/ci-managed/aws-state-access/**' - - '.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: - - 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 (provisioned by 01-iam/bootstrap/aws/)." - exit 1 - fi - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - fetch-depth: 1 - - - uses: ./.github/actions/terraform - with: - root: 01-iam/ci-managed/aws-state-access - 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.yml b/.github/workflows/scaleway.yml index 2be2b9b..a01fd7e 100644 --- a/.github/workflows/scaleway.yml +++ b/.github/workflows/scaleway.yml @@ -1,27 +1,28 @@ name: Terraform — Scaleway cluster -# Drives 02-cluster/scaleway through the .github/actions/terraform composite action: +# Drives 10-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 01-iam/ci-managed/aws-state-access). Scaleway cluster -# lifecycle uses the CI API key from the `scaleway` environment (Kubernetes/VPC/ +# State R/W + lock uses the org's single Terraform AWS role +# (vars.AWS_TERRAFORM_ROLE_ARN — S3 list/get/put/delete on the state bucket +# only, see 00-foundation/aws/ci-role.tf). Scaleway cluster lifecycle uses +# the CI API key from the `scaleway` environment (Kubernetes/VPC/ # PrivateNetwork FullAccess, see 01-iam/bootstrap/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/**' + - '10-cluster/scaleway/**' - '.github/actions/terraform/**' - '.github/workflows/scaleway.yml' push: branches: [main] paths: - - '02-cluster/scaleway/**' + - '10-cluster/scaleway/**' - '.github/actions/terraform/**' - '.github/workflows/scaleway.yml' schedule: @@ -67,10 +68,10 @@ jobs: steps: - name: Assert state role ARN is present env: - ROLE_ARN: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + ROLE_ARN: ${{ vars.AWS_TERRAFORM_ROLE_ARN }} run: | if [ -z "$ROLE_ARN" ]; then - echo "::error::vars.AWS_TF_STATE_ROLE_ARN is not set (provisioned by 01-iam/ci-managed/aws-state-access)." + echo "::error::vars.AWS_TERRAFORM_ROLE_ARN is not set (provisioned by 00-foundation/aws)." exit 1 fi @@ -96,7 +97,7 @@ jobs: - uses: ./.github/actions/terraform with: - root: 02-cluster/scaleway + root: 10-cluster/scaleway tfvars-file: 02-cluster-staging.tfvars command: ${{ steps.cmd.outputs.command }} - aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + aws-role-arn: ${{ vars.AWS_TERRAFORM_ROLE_ARN }} diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index ee7c5d0..9fac5c6 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -23,11 +23,13 @@ jobs: fail-fast: false matrix: root: - - 00-remote_state + - 00-foundation/aws - 01-iam/bootstrap/aws - 01-iam/bootstrap/scaleway - - 01-iam/ci-managed/aws-state-access - - 02-cluster/scaleway + - 01-iam/workload/scaleway + - 02-encryption/aws + - 03-storage/scaleway + - 10-cluster/scaleway steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/00-foundation/README.md b/00-foundation/README.md new file mode 100644 index 0000000..1fd8cfd --- /dev/null +++ b/00-foundation/README.md @@ -0,0 +1,70 @@ +# 00-foundation — what this domain is for + +This root module is the foundation for all the infrastructure managed by +this repository. Concretely, it requires exactly two things: + +- A remote state solution (e.g. S3) +- Whatever setup lets Terraform, running from your CI/CD, access and operate + that state + +That state is the foundation for everything else your infrastructure +manages — every resource you've deployed, your backups, your encryption +keys, all of it lives there. + +## Why this domain matters more than its size suggests + +Not just "holds a Terraform state bucket." This repo bootstraps its entire +infrastructure — including the cluster and ArgoCD, which then takes over +everything downstream — via Terraform. That choice makes remote state the +only trusted source of truth for what's actually deployed. It's also why +this domain has to be the first thing applied: every other root's backend +points at the bucket this one creates, so nothing else can even exist yet +until this does. + +## The contract + +A `00-foundation/` root must provide exactly these things, and +nothing that isn't required to provide them: + +1. **A durable, versioned store for Terraform state**, reachable by every + other root's backend configuration. It must survive the loss of any single + developer's machine or CI runner, support recovering a corrupted/truncated + write (versioning or equivalent), and deny non-encrypted-in-transit access. +2. **Protection against concurrent writes** to the same root's state, so two + `plan`/`apply` runs racing each other can't corrupt it. A lock is the + common shape this takes; other [locking strategies](https://developer.hashicorp.com/terraform/language/state/locking) + exist. +3. **One CI identity**, trusted keylessly if the provider supports it (OIDC + or equivalent — a strong machine-to-machine auth pattern, preferred over + long-lived static credentials wherever the provider supports it), scoped + to **read/write access on the state store from step 1, and nothing else**. + In particular: no general credential- or identity-management capability. A + domain that needs its own CI identity for provider-specific reasons beyond + state R/W (e.g. `02-encryption/aws` needing KMS/IAM rights) gets its + **own**, separately and narrowly scoped identity elsewhere — + `00-foundation` is not the place that mints capability for other domains. + + **Known limitation:** this identity is scoped to the whole state bucket, + not per-root. The AWS implementation trusts OIDC from exactly this repo, + on the assumption that every Terraform root in the org lives here — but + within the repo, there is no isolation between roots at the state layer. + A workflow authorized to touch one root's state can, at the + IAM-permission level, touch any other root's state too. Deliberate + trade-off (per-root scoping is more machinery than a homelab-scale org + needs), not an oversight. + +4. **Must be executable locally.** This root creates the very store its own + state ends up living in — a chicken-and-egg every implementation has to + solve explicitly, and the state bucket obviously can't be relied on yet + to do it. What matters is that it can be run once, by an admin, on their + own machine, to get past that bootstrap — and ideally never needs to be + touched again after. + +## Adding a new provider + +A domain with only one provider still nests under it (`00-foundation/aws/`), +so a second provider (e.g. `00-foundation/scaleway/` if a state backend ever +needs to exist there) can be added later without restructuring existing +roots. Each provider's root is independent — there's no requirement that they +share a state store; each just has to satisfy the contract above for its own +provider. diff --git a/00-foundation/aws/.terraform.lock.hcl b/00-foundation/aws/.terraform.lock.hcl new file mode 100644 index 0000000..032f69f --- /dev/null +++ b/00-foundation/aws/.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.57.1" + constraints = "~> 6.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "h1:WXndu9uKvbnmspexcbki89ZuGLt2SUyAfZ5GgQUm+QU=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.3.0" + constraints = ">= 3.0.0, ~> 4.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/00-remote_state/README.md b/00-foundation/aws/README.md similarity index 59% rename from 00-remote_state/README.md rename to 00-foundation/aws/README.md index b2582a6..e4576dc 100644 --- a/00-remote_state/README.md +++ b/00-foundation/aws/README.md @@ -1,16 +1,28 @@ -# 00-remote_state — Terraform state bucket +# 00-foundation/aws — Terraform state bucket + CI's AWS access -A standalone Terraform root that provisions a single **AWS S3 bucket** to hold -the Terraform remote state for the **whole org**. Every other root -(`02-cluster/local/`, `02-cluster/scaleway/`, `01-iam/bootstrap/scaleway/`, and -future ones) points its `backend "s3"` at this bucket. +The AWS implementation of the `00-foundation` contract — see +[`../README.md`](../README.md) for what that contract is and why this domain +is named after its role, not "remote_state" or "aws". Read that first; this +file is the how, not the why. -It is the shared substrate every other root depends on — the `00-remote_state` -domain (a single-root domain, so it is flattened: the domain folder *is* the -root), applied by an admin. +It provisions: + +1. A single **AWS S3 bucket** holding the Terraform remote state for the + **whole org**. Every other root points its `backend "s3"` at this bucket. +2. The **GitHub OIDC provider** + the **one AWS IAM role** + (`terraform-state-access`) every GitHub Actions workflow in this repo + assumes to read/write that bucket — see [CI's AWS access](#cis-aws-access). + +Merged from three former roots (`00-remote_state`, `01-iam/bootstrap/aws`, +`01-iam/ci-managed/aws-state-access`) that were all fundamentally the same +foundation concern living in separate places for historical reasons. Applied +by an admin (this root creates the very identity CI would otherwise need to +apply it). ## What it creates +### The state bucket + A single S3 bucket (default name `id-terraform-state`, override with `-var bucket_name=...`) via the community [`terraform-aws-modules/s3-bucket`](https://registry.terraform.io/modules/terraform-aws-modules/s3-bucket/aws/latest) @@ -31,6 +43,29 @@ State **locking** uses Terraform's native S3 lockfile (`use_lockfile`, GA since Terraform 1.10) — a `.tflock` object written next to the state. No DynamoDB lock table is needed. +### CI's AWS access + +`ci-role.tf` creates the GitHub OIDC provider and one role, +`terraform-state-access` — trusted via OIDC scoped to +`repo:IntegratedDynamic/infrastructure:*` only, with an inline policy granting +**exactly** `s3:ListBucket`/`GetBucketVersioning`/`GetBucketLocation` on the +bucket and `s3:GetObject`/`PutObject`/`DeleteObject` on its contents. Nothing +else — no IAM management capability, no ability to create or modify any other +role or policy. + +This replaces two former roots that together built a much larger "CI can +safely mint further IAM roles" system: a permissions boundary ("admin minus a +hardened deny-list") plus a policy letting the CI role create/attach other +roles under a managed path, specifically so it could mint the one role that +actually did the state R/W. That entire guardrail had exactly one consumer. +Once the role's own job is narrowed to "read/write this bucket," there's no +IAM-management capability left to guard against escalating in the first +place, so the guardrail system is gone along with it. + +Every workflow in `.github/workflows/` assumes this one role (via +`vars.AWS_TERRAFORM_ROLE_ARN`) for every root's `plan`/`apply`/`destroy` — see +the composite action in `.github/actions/terraform/`. + ## Credentials The AWS provider **and** the S3 backend resolve credentials through the standard @@ -58,13 +93,13 @@ This root creates the very bucket it then stores its state in. Bootstrap order: 2. Apply once with **local state** — temporarily comment out the `backend "s3"` block in `version.tf` so the bucket gets created: ```bash - terraform -chdir=00-remote_state init - terraform -chdir=00-remote_state apply # creates the bucket (billable) + terraform -chdir=00-foundation/aws init + terraform -chdir=00-foundation/aws apply # creates the bucket (billable) ``` 3. Re-add the `backend "s3"` block and migrate the local state into the bucket it now manages: ```bash - terraform -chdir=00-remote_state init -migrate-state + terraform -chdir=00-foundation/aws init -migrate-state ``` After that, this root's own state lives at `state-backend/terraform.tfstate` diff --git a/00-remote_state/env/00-remote-state-backend.tfvars b/00-foundation/aws/env/00-remote-state-backend.tfvars similarity index 100% rename from 00-remote_state/env/00-remote-state-backend.tfvars rename to 00-foundation/aws/env/00-remote-state-backend.tfvars diff --git a/00-foundation/aws/main.tf b/00-foundation/aws/main.tf new file mode 100644 index 0000000..ed3421e --- /dev/null +++ b/00-foundation/aws/main.tf @@ -0,0 +1,147 @@ +# Holds the Terraform remote state for the whole org (see README.md). +# Uses the community terraform-aws-modules/s3-bucket module. +module "tfstate_bucket" { + source = "terraform-aws-modules/s3-bucket/aws" + version = "~> 5.0" + + bucket_prefix = var.bucket_prefix + + # Recover a corrupt/truncated state push by rolling back to a prior version. + versioning = { + enabled = true + } + + # Encrypt every object at rest (SSE-S3, AES256 — no KMS key to manage). + server_side_encryption_configuration = { + rule = { + apply_server_side_encryption_by_default = { + sse_algorithm = "AES256" + } + } + } + + # ACLs disabled, bucket owner owns everything — the modern replacement for a + # `private` ACL. Combined with the public-access block below, the bucket and + # its objects are unreachable anonymously (state can hold sensitive values). + control_object_ownership = true + object_ownership = "BucketOwnerEnforced" + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + + # Reject any non-TLS request to the state — defence in depth for secrets. + attach_deny_insecure_transport_policy = true + + # Guard against accidentally deleting everyone's state: force_destroy stays + # off, so `terraform destroy` fails while objects remain. + force_destroy = false + + lifecycle_rule = [ + { + id = "expire-noncurrent-state-versions" + status = "Enabled" + + # Empty filter == applies to the whole bucket. + filter = {} + + # Only noncurrent (superseded) versions expire; the current state is kept. + noncurrent_version_expiration = { + days = var.noncurrent_version_expiration_days + } + } + ] + + tags = { + purpose = "terraform-remote-state" + terraform = "true" + } +} + +# ============================================================================= +# CI's AWS access — keyless GitHub-OIDC, scoped to exactly this repo, with a +# policy that grants only S3 read/write on the state bucket above. Replaces +# the former 01-iam/bootstrap/aws + 01-iam/ci-managed/aws-state-access roots' +# much larger "CI can safely mint further IAM roles" system (a permissions +# boundary + a policy letting the CI role create/attach other roles under a +# managed path) — that system's only actual consumer was minting the one +# role that does state R/W. Once the role's own job is narrowed to exactly +# that, there's no IAM-management capability left to guard against +# escalating, so the guardrail system is gone along with it. +# ============================================================================= + +# 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/dynamically-fetched 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" + } +} + +module "terraform_state_access_policy" { + source = "terraform-aws-modules/iam/aws//modules/iam-policy" + version = "6.6.1" + + name = "terraform-state-access" + path = "/" + description = "S3 list/get/put/delete on the Terraform state bucket. Nothing else." + policy = data.aws_iam_policy_document.terraform_state_access.json + + tags = { + Terraform = "true" + Environment = "dev" + } +} + +data "aws_iam_policy_document" "terraform_state_access" { + statement { + sid = "TerraformStateBucket" + effect = "Allow" + actions = ["s3:ListBucket", "s3:GetBucketVersioning", "s3:GetBucketLocation"] + resources = [module.tfstate_bucket.s3_bucket_arn] + } + statement { + sid = "TerraformStateObjects" + effect = "Allow" + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${module.tfstate_bucket.s3_bucket_arn}/*"] + } +} + +# Role GitHub Actions assumes via OIDC. Trust is scoped to this repo only +# (repo:/:* — the module prefixes "repo:" itself). +# +# use_name_prefix = false keeps the role name EXACTLY "terraform-state-access" +# (no random suffix) so its ARN is stable and a workflow can name it in +# configure-aws-credentials. +module "terraform_state_access_role" { + source = "terraform-aws-modules/iam/aws//modules/iam-role" + version = "6.6.1" + + name = "terraform-state-access" + use_name_prefix = false + description = "Assumed by GitHub Actions (${var.github_org}/${var.github_repo}) via OIDC: Terraform state bucket R/W only." + enable_github_oidc = true + + oidc_wildcard_subjects = ["${var.github_org}/${var.github_repo}:*"] + + policies = { + state-access = module.terraform_state_access_policy.arn + } + + 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/00-foundation/aws/outputs.tf b/00-foundation/aws/outputs.tf new file mode 100644 index 0000000..5dc02f2 --- /dev/null +++ b/00-foundation/aws/outputs.tf @@ -0,0 +1,24 @@ +output "bucket_name" { + description = "State bucket name." + value = module.tfstate_bucket.s3_bucket_id +} + +output "bucket_arn" { + description = "State bucket ARN." + value = module.tfstate_bucket.s3_bucket_arn +} + +output "region" { + description = "State bucket region." + value = var.region +} + +output "terraform_state_access_role_arn" { + description = "ARN of the role every GitHub Actions workflow in this repo assumes via OIDC for Terraform state R/W (set as vars.AWS_TERRAFORM_ROLE_ARN)." + value = module.terraform_state_access_role.arn +} + +output "terraform_state_access_policy_arn" { + description = "ARN of the state-bucket R/W policy attached to terraform-state-access. Other roles that also need to read/write this bucket (e.g. for their own Terraform backend) attach this same policy instead of duplicating its statements — see 01-iam/bootstrap/aws." + value = module.terraform_state_access_policy.arn +} diff --git a/00-remote_state/variables.tf b/00-foundation/aws/variables.tf similarity index 54% rename from 00-remote_state/variables.tf rename to 00-foundation/aws/variables.tf index 87280d1..b35426e 100644 --- a/00-remote_state/variables.tf +++ b/00-foundation/aws/variables.tf @@ -15,3 +15,17 @@ variable "noncurrent_version_expiration_days" { type = number default = 10 } + +# 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 Terraform state access role." + type = string + default = "IntegratedDynamic" +} + +variable "github_repo" { + description = "GitHub repository whose workflows may assume the Terraform state access role (sub claim is scoped to it)." + type = string + default = "infrastructure" +} diff --git a/00-remote_state/version.tf b/00-foundation/aws/version.tf similarity index 83% rename from 00-remote_state/version.tf rename to 00-foundation/aws/version.tf index c54997f..c829e93 100644 --- a/00-remote_state/version.tf +++ b/00-foundation/aws/version.tf @@ -18,6 +18,12 @@ terraform { source = "hashicorp/aws" version = "~> 6.0" } + # Pulled in transitively by module.iam_oidc_provider (fetches GitHub's + # OIDC thumbprint dynamically instead of a hardcoded value). + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } } } diff --git a/00-remote_state/.terraform.lock.hcl b/00-remote_state/.terraform.lock.hcl deleted file mode 100644 index 247235c..0000000 --- a/00-remote_state/.terraform.lock.hcl +++ /dev/null @@ -1,27 +0,0 @@ -# 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.42.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/00-remote_state/main.tf b/00-remote_state/main.tf deleted file mode 100644 index 7416255..0000000 --- a/00-remote_state/main.tf +++ /dev/null @@ -1,60 +0,0 @@ -# Holds the Terraform remote state for the whole org (see README.md). -# Uses the community terraform-aws-modules/s3-bucket module. -module "tfstate_bucket" { - source = "terraform-aws-modules/s3-bucket/aws" - version = "~> 5.0" - - bucket_prefix = var.bucket_prefix - - # Recover a corrupt/truncated state push by rolling back to a prior version. - versioning = { - enabled = true - } - - # Encrypt every object at rest (SSE-S3, AES256 — no KMS key to manage). - server_side_encryption_configuration = { - rule = { - apply_server_side_encryption_by_default = { - sse_algorithm = "AES256" - } - } - } - - # ACLs disabled, bucket owner owns everything — the modern replacement for a - # `private` ACL. Combined with the public-access block below, the bucket and - # its objects are unreachable anonymously (state can hold sensitive values). - control_object_ownership = true - object_ownership = "BucketOwnerEnforced" - - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true - - # Reject any non-TLS request to the state — defence in depth for secrets. - attach_deny_insecure_transport_policy = true - - # Guard against accidentally deleting everyone's state: force_destroy stays - # off, so `terraform destroy` fails while objects remain. - force_destroy = false - - lifecycle_rule = [ - { - id = "expire-noncurrent-state-versions" - status = "Enabled" - - # Empty filter == applies to the whole bucket. - filter = {} - - # Only noncurrent (superseded) versions expire; the current state is kept. - noncurrent_version_expiration = { - days = var.noncurrent_version_expiration_days - } - } - ] - - tags = { - purpose = "terraform-remote-state" - terraform = "true" - } -} diff --git a/00-remote_state/outputs.tf b/00-remote_state/outputs.tf deleted file mode 100644 index 5d48210..0000000 --- a/00-remote_state/outputs.tf +++ /dev/null @@ -1,14 +0,0 @@ -output "bucket_name" { - description = "State bucket name." - value = module.tfstate_bucket.s3_bucket_id -} - -output "bucket_arn" { - description = "State bucket ARN." - value = module.tfstate_bucket.s3_bucket_arn -} - -output "region" { - description = "State bucket region." - value = var.region -} diff --git a/01-iam/README.md b/01-iam/README.md new file mode 100644 index 0000000..c8f6573 --- /dev/null +++ b/01-iam/README.md @@ -0,0 +1,45 @@ +# 01-iam — what this domain is for + +Identities that let something outside this repo's control (CI, an +in-cluster workload) authenticate to an external system — one identity per +purpose, scoped to exactly what that purpose needs. Not `00-foundation`'s +job: the state-bucket-access role lives there because every root needs it. +This domain is for everything else. + +## The two sub-splits + +- **`bootstrap/`** — trust anchors and CI identities, applied once by a + human admin. Its key job is to provide the identities that let CI/CD + actually run every other root module: **the repo-wide default is that + every root except `00-foundation` and a `/bootstrap/*` root is + meant to run through CI/CD, not be applied by hand.** `bootstrap/` roots + (like `00-foundation`) are the deliberate exception — run once, by a + human, ideally never touched again. That the identity applying a + `bootstrap/` root is itself admin-or-near-admin is expected; +- **`workload/`** — the fallback for a scoped identity that doesn't have + infrastructure of its own to attach to. A domain that owns real + infrastructure mints its identity as part of that domain (see + `02-encryption`, `03-storage`) — `workload/` exists for the identities + that don't have a piece of infrastructure to justify becoming their own + numbered domain. + +## The contract + +- **One identity, one purpose, least privilege.** No identity here grants + capability beyond what its one consumer needs — least-privilege scoping is + the point of splitting these out instead of sharing a single broad key. +- **No identity here mints capability for another domain.** A domain that + needs a new external credential gets its own entry in `bootstrap/` or + `workload/`, not a widened grant on an existing one — the retired + role-creates-roles system (see `00-foundation/README.md`) is what this + rule is reacting to. +- **Keyless (OIDC or equivalent) wherever the provider supports it.** + Fall back to a static key only when it's genuinely not possible (documented + per-identity where that's the case, e.g. `bootstrap/scaleway/README.md`). + +## What deliberately doesn't belong here + +- The state-bucket-access role — that's `00-foundation`. +- An identity for infrastructure that owns its own domain — that identity + gets minted as part of that domain instead (see `workload/`'s entry + above). diff --git a/01-iam/bootstrap/aws/.terraform.lock.hcl b/01-iam/bootstrap/aws/.terraform.lock.hcl index aac167e..0b25476 100644 --- a/01-iam/bootstrap/aws/.terraform.lock.hcl +++ b/01-iam/bootstrap/aws/.terraform.lock.hcl @@ -2,48 +2,26 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" + version = "6.57.1" 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", + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "h1:WXndu9uKvbnmspexcbki89ZuGLt2SUyAfZ5GgQUm+QU=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", "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", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", ] } diff --git a/01-iam/bootstrap/aws/README.md b/01-iam/bootstrap/aws/README.md deleted file mode 100644 index c782449..0000000 --- a/01-iam/bootstrap/aws/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# 01-iam/bootstrap/aws — CI identity & governance - -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 `02-cluster/` — it provisions no cluster. It's the trust -anchor of the `01-iam` domain (`bootstrap/` = human-applied), 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 `01-iam/bootstrap/scaleway/`). -OIDC here covers **only** the AWS/S3 side. Revisit Scaleway OIDC -if/when Scaleway ships it. - -All resources are built from the [`terraform-aws-modules/iam`](https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest) -modules (`iam-oidc-provider`, `iam-role`, `iam-policy`). - -## What it creates - -- **OIDC provider** (`iam-oidc-provider`) — the GitHub Actions OIDC issuer - (`https://token.actions.githubusercontent.com`, audience `sts.amazonaws.com`). - **One per AWS account** — if one already exists, import it rather than - duplicating. -- **CI role** `github-actions-terraform` (`iam-role`, `enable_github_oidc`) — the - role CI assumes. Its **trust policy** is scoped to exactly this repo - (`repo:IntegratedDynamic/infrastructure:*`), so only this repo's workflows can - assume it. Permissions come from the CI grant below. -- **CI grant** `tf-managed-ci` (`iam-policy`) — a tight policy: Terraform state - R/W on the state bucket only, plus **privilege-escalation-safe** IAM role - management (see next section). The S3-native lock (`use_lockfile = true`) rides - on object R/W, so no DynamoDB table is needed. -- **Permissions boundary** `tf-managed-boundary` (`iam-policy`) — the ceiling - attached to every role the CI creates. - -Outputs `role_arn`, `permissions_boundary_arn`, and `managed_path` are all public -identifiers, not secrets. - -## Creating IAM roles from CI — the permissions-boundary contract - -The CI role can run `terraform apply` that **creates IAM roles**, without being -able to escalate its own privileges. This rests on two mechanisms (full rationale -inline in [`iam-ci.tf`](./iam-ci.tf)): - -1. **Permissions boundary** (`tf-managed-boundary`) caps every CI-created role: - effective perms = `intersection(attached policies, boundary)`. Even - `AdministratorAccess` on a child role is clamped. The boundary is - "admin minus a hardened deny-list" (no IAM users, no boundary tampering, no - org/account actions, can't edit itself). -2. **A repo-scoped path** `/tf-managed/IntegratedDynamic/infrastructure/`. The CI - grant only allows `iam:CreateRole` / `Attach*` / `Put*` **when the request - stamps our boundary**, and only on ARNs under this path. `iam:PassRole` is - likewise path-scoped. A global backstop Deny refuses to touch any role whose - boundary isn't ours. - -> ⚠️ **Contract for every root that creates roles:** each `aws_iam_role` the CI -> applies **must** set `permissions_boundary = ` and -> `path = `, or the apply is rejected. With the `iam-role` module -> those are the `permissions_boundary` and `path` inputs. In CI, feed the path -> automatically: -> -> ```yaml -> env: -> TF_VAR_role_path: /tf-managed/${{ github.repository }}/ -> ``` -> -> `${{ github.repository }}` resolves to `IntegratedDynamic/infrastructure`, -> matching the pin exactly. IAM paths are **case-sensitive**. - -Verify the guardrails with the IAM policy simulator, e.g.: - -```bash -ROLE=$(terraform -chdir=01-iam/bootstrap/aws output -raw role_arn) -B=$(terraform -chdir=01-iam/bootstrap/aws output -raw permissions_boundary_arn) -# CreateRole without our boundary -> explicitDeny -aws iam simulate-principal-policy --policy-source-arn "$ROLE" \ - --action-names iam:CreateRole \ - --resource-arns "arn:aws:iam::503577850357:role/tf-managed/IntegratedDynamic/infrastructure/x" \ - --query 'EvaluationResults[0].EvalDecision' --output text -``` - -## 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 -terraform -chdir=01-iam/bootstrap/aws init -terraform -chdir=01-iam/bootstrap/aws plan # review first -terraform -chdir=01-iam/bootstrap/aws 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=01-iam/bootstrap/aws 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 detach the `tf-managed-ci` 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/01-iam/bootstrap/aws/env/01-iam-aws.tfvars b/01-iam/bootstrap/aws/env/01-iam-aws.tfvars new file mode 100644 index 0000000..434e088 --- /dev/null +++ b/01-iam/bootstrap/aws/env/01-iam-aws.tfvars @@ -0,0 +1 @@ +region = "eu-west-3" diff --git a/01-iam/bootstrap/aws/iam-ci.tf b/01-iam/bootstrap/aws/iam-ci.tf deleted file mode 100644 index 7586dc2..0000000 --- a/01-iam/bootstrap/aws/iam-ci.tf +++ /dev/null @@ -1,269 +0,0 @@ -# ============================================================================= -# CI role that can run `terraform apply` to CREATE IAM ROLES — without being -# able to escalate its own privileges. -# -# A role that holds iam:CreateRole + iam:AttachRolePolicy is effectively admin: -# nothing stops it from minting a role with AdministratorAccess and using it. -# We close the three classic escalation vectors: -# -# 1. Create a role more powerful than yourself -> a PERMISSIONS BOUNDARY caps -# every role the CI creates, so effective perms = intersection(attached -# policies, boundary). Even AdministratorAccess on a child role is clamped. -# 2. iam:PassRole a powerful role to a service you control -> PassRole is -# scoped to the managed path only (where every role carries the boundary). -# 3. Weaken the guardrail itself (rewrite/detach the boundary) -> explicit -# Denies on the boundary policy ARN and on DeleteRolePermissionsBoundary. -# -# Design decisions (see also README.md / CLAUDE.md): -# - Boundary philosophy: "admin minus a hardened deny-list" (pragmatic for a -# homelab — workloads run freely, only escalation is blocked). -# - CI grant: tight — S3 state R/W + bounded IAM under one path only. -# - The CI role does NOT carry the boundary itself: the boundary Denies -# PutRolePermissionsBoundary, but the CI role legitimately needs that action -# to stamp the boundary onto its children. The CI role is constrained by its -# own (tight) policy instead. -# - All CI-managed roles/policies live under a repo-scoped path -# (/tf-managed///) so the CI's IAM actions can be ARN-scoped to -# exactly that subtree — never your SSO roles, the boundary, or its own role. -# ============================================================================= - -data "aws_caller_identity" "current" {} -data "aws_partition" "current" {} - -locals { - # Repo-scoped path for every role/policy the CI manages. Mirrors - # `${{ github.repository }}` (= "IntegratedDynamic/infrastructure"), so the CI - # workflow can feed `TF_VAR_role_path=/tf-managed/${{ github.repository }}/` - # and each managed role derives its path automatically. IAM paths are - # case-sensitive and must match this pin exactly, or the boundary condition - # rejects the apply. - managed_path = "/tf-managed/${var.github_org}/${var.github_repo}/" - - # The boundary ARN is built from a *fixed* name (not a resource reference) on - # purpose: the boundary policy denies edits to itself, which would otherwise - # create a self-referential cycle in the graph. - boundary_arn = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy/${var.boundary_name}" - - # ARN globs the CI may operate on — the managed path subtree only. - managed_role_arns = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:role${local.managed_path}*" - managed_policy_arns = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy${local.managed_path}*" -} - -# ----------------------------------------------------------------------------- -# Permissions boundary — the CEILING for every role the CI creates. -# "Admin minus deny-list": allow everything, then carve out the actions that -# would let a bounded role escalate or escape the boundary. -# ----------------------------------------------------------------------------- -data "aws_iam_policy_document" "boundary" { - # Baseline: full access. The Denies below are what actually matter. - statement { - sid = "AdminBaseline" - effect = "Allow" - actions = ["*"] - resources = ["*"] - } - - # No IAM users / long-lived credentials, and no tampering with ANY permissions - # boundary. A child role carrying this boundary therefore cannot mint users, - # hand out access keys, or strip/replace boundaries to climb out. - statement { - sid = "DenyUsersAndBoundaryTampering" - effect = "Deny" - actions = [ - "iam:CreateUser", - "iam:CreateLoginProfile", - "iam:CreateAccessKey", - "iam:UpdateLoginProfile", - "iam:PutUserPolicy", - "iam:AttachUserPolicy", - "iam:PutRolePermissionsBoundary", - "iam:DeleteRolePermissionsBoundary", - "iam:PutUserPermissionsBoundary", - "iam:DeleteUserPermissionsBoundary", - ] - resources = ["*"] - } - - # Account-/org-level levers are off-limits to bounded workloads. - statement { - sid = "DenyAccountAndOrg" - effect = "Deny" - actions = [ - "organizations:*", - "account:*", - ] - resources = ["*"] - } - - # The ceiling must not be editable by anything wearing it. - statement { - sid = "DenyEditingTheBoundaryItself" - effect = "Deny" - actions = [ - "iam:CreatePolicyVersion", - "iam:SetDefaultPolicyVersion", - "iam:DeletePolicy", - "iam:DeletePolicyVersion", - ] - resources = [local.boundary_arn] - } -} - -module "boundary_policy" { - source = "terraform-aws-modules/iam/aws//modules/iam-policy" - version = "6.6.1" - - name = var.boundary_name - path = "/" - description = "Permissions boundary capping every role the CI creates under ${local.managed_path}. Prevents privilege escalation." - policy = data.aws_iam_policy_document.boundary.json - - tags = { - Terraform = "true" - Environment = "dev" - } -} - -# ----------------------------------------------------------------------------- -# CI grant — what the GitHub OIDC role itself may do. Deliberately tight: -# S3 state R/W + bounded IAM management under the managed path only. -# ----------------------------------------------------------------------------- -data "aws_iam_policy_document" "ci" { - # Terraform remote state: list + read/write/delete objects (the S3-native lock - # is just an object, so Put/Delete covers locking). Scoped to the state bucket. - statement { - sid = "TerraformStateBucket" - effect = "Allow" - actions = ["s3:ListBucket", "s3:GetBucketVersioning", "s3:GetBucketLocation"] - resources = [var.state_bucket_arn] - } - statement { - sid = "TerraformStateObjects" - effect = "Allow" - actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] - resources = ["${var.state_bucket_arn}/*"] - } - - # Read across IAM is needed for plan/refresh (reading current role/policy state) - # and is not an escalation vector on its own. - statement { - sid = "IamRead" - effect = "Allow" - actions = ["iam:Get*", "iam:List*"] - resources = ["*"] - } - - # Create/attach roles — but ONLY when the request stamps OUR boundary. This is - # the core guardrail: the CI cannot create or extend a role that isn't capped. - statement { - sid = "IamManageRolesRequireBoundary" - effect = "Allow" - actions = [ - "iam:CreateRole", - "iam:AttachRolePolicy", - "iam:PutRolePolicy", - "iam:PutRolePermissionsBoundary", - ] - resources = [local.managed_role_arns] - condition { - test = "StringEquals" - variable = "iam:PermissionsBoundary" - values = [local.boundary_arn] - } - } - - # Lifecycle actions that don't take the boundary condition key. Note the - # ABSENCE of iam:DeleteRolePermissionsBoundary: letting the CI strip a boundary - # off a child it had attached AdministratorAccess to would be an escalation. - statement { - sid = "IamManageRolesLifecycle" - effect = "Allow" - actions = [ - "iam:DeleteRole", - "iam:DeleteRolePolicy", - "iam:DetachRolePolicy", - "iam:TagRole", - "iam:UntagRole", - "iam:UpdateRole", - "iam:UpdateRoleDescription", - "iam:UpdateAssumeRolePolicy", - ] - resources = [local.managed_role_arns] - } - - # Customer-managed policies the CI creates also live under the managed path. - statement { - sid = "IamManagePolicies" - effect = "Allow" - actions = [ - "iam:CreatePolicy", - "iam:DeletePolicy", - "iam:CreatePolicyVersion", - "iam:DeletePolicyVersion", - "iam:SetDefaultPolicyVersion", - "iam:TagPolicy", - "iam:UntagPolicy", - ] - resources = [local.managed_policy_arns] - } - - # PassRole only for bounded roles under the managed path — so the CI can't pass - # a pre-existing powerful role to a service it controls. - statement { - sid = "PassManagedRolesOnly" - effect = "Allow" - actions = ["iam:PassRole"] - resources = [local.managed_role_arns] - } - - # Backstop Deny (defense in depth, independent of the path scoping above): - # never edit the boundary policy itself. - statement { - sid = "DenyTouchingBoundaryPolicy" - effect = "Deny" - actions = [ - "iam:CreatePolicyVersion", - "iam:SetDefaultPolicyVersion", - "iam:DeletePolicy", - "iam:DeletePolicyVersion", - ] - resources = [local.boundary_arn] - } - - # Backstop Deny: refuse to create/extend ANY role (even outside the path — - # e.g. your SSO roles or the CI role itself) whose boundary isn't ours. For - # CreateRole this checks the boundary in the request; for Attach/Put it checks - # the target's existing boundary. A missing key fails StringEquals, so the - # Deny fires and unboundaried principals stay untouchable. - statement { - sid = "DenyUnlessOurBoundary" - effect = "Deny" - actions = [ - "iam:CreateRole", - "iam:AttachRolePolicy", - "iam:PutRolePolicy", - "iam:PutRolePermissionsBoundary", - ] - resources = ["*"] - condition { - test = "StringNotEquals" - variable = "iam:PermissionsBoundary" - values = [local.boundary_arn] - } - } -} - -module "ci_policy" { - source = "terraform-aws-modules/iam/aws//modules/iam-policy" - version = "6.6.1" - - name = "tf-managed-ci" - path = "/" - description = "Grant for the GitHub Actions CI role: Terraform state R/W + Ability to create role under ${local.managed_path} + privilege-escalation-safe IAM management under ${local.managed_path}." - policy = data.aws_iam_policy_document.ci.json - - tags = { - Terraform = "true" - Environment = "dev" - } -} diff --git a/01-iam/bootstrap/aws/main.tf b/01-iam/bootstrap/aws/main.tf index feab64a..2cbb2a2 100644 --- a/01-iam/bootstrap/aws/main.tf +++ b/01-iam/bootstrap/aws/main.tf @@ -1,20 +1,85 @@ -# 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 (ci/10-scaleway/), 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" +# ============================================================================= +# CI identity for 02-encryption/aws — a role scoped to exactly the two +# things that root creates: a KMS key (+ alias) and a single-purpose IAM user +# (+ access key) under the /openbao/ path. Full CRUD (including destroy) on +# both, since that root needs to be able to tear down what it creates — but +# nothing broader: no general IAM role/policy management, no capability to +# touch any resource outside the /openbao/ path. Also attaches the same +# state-bucket policy terraform-state-access uses, so this role can read/write +# its own Terraform backend state without a second, duplicated policy. +# ============================================================================= + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +# 00-foundation/aws's own state — read to attach the SAME state-bucket +# policy its terraform-state-access role uses, rather than duplicating the +# policy document or hardcoding its ARN. +data "terraform_remote_state" "remote_state_aws" { + backend = "s3" + config = { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + key = "state-backend/00-remote-state-backend/terraform.tfstate" + } +} + +data "aws_iam_policy_document" "openbao_unseal_management" { + statement { + sid = "KmsKeyAndAliasCrud" + effect = "Allow" + actions = [ + "kms:CreateKey", + "kms:DescribeKey", + "kms:PutKeyPolicy", + "kms:GetKeyPolicy", + "kms:TagResource", + "kms:UntagResource", + "kms:ListResourceTags", + "kms:EnableKeyRotation", + "kms:DisableKeyRotation", + "kms:GetKeyRotationStatus", + "kms:UpdateKeyDescription", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion", + "kms:CreateAlias", + "kms:DeleteAlias", + "kms:UpdateAlias", + "kms:ListAliases", + ] + # KMS key IDs are randomly generated at creation time and can't be + # resource-scoped in advance (CreateKey in particular has no resource-level + # permission support) — scoped by capability (this is a KMS-management + # role, nothing else) rather than by ARN. + resources = ["*"] + } + + statement { + sid = "IamUserAndAccessKeyCrudUnderOpenbaoPath" + effect = "Allow" + actions = [ + "iam:CreateUser", + "iam:GetUser", + "iam:DeleteUser", + "iam:TagUser", + "iam:UntagUser", + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:ListAccessKeys", + "iam:UpdateAccessKey", + ] + resources = ["arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:user/openbao/*"] + } +} + +module "openbao_unseal_policy" { + source = "terraform-aws-modules/iam/aws//modules/iam-policy" version = "6.6.1" - url = "https://token.actions.githubusercontent.com" + name = "openbao-unseal-ci" + path = "/" + description = "Full CRUD on the KMS key/alias + IAM user/access-key that 02-encryption/aws manages. Nothing else." + policy = data.aws_iam_policy_document.openbao_unseal_management.json tags = { Terraform = "true" @@ -22,34 +87,27 @@ module "iam_oidc_provider" { } } -# Role GitHub Actions assumes via OIDC. Trust is scoped to this repo only -# (repo:/:* — the module prefixes "repo:" itself). Its permissions -# come from the tight, escalation-safe CI grant (see iam-ci.tf): Terraform state -# R/W + IAM role management bounded under the managed path. -# -# Note: this role deliberately carries NO permissions boundary. The boundary -# Denies PutRolePermissionsBoundary, which the CI role itself needs in order to -# stamp the boundary onto the roles it creates. The CI role is constrained by -# module.ci_policy instead. See iam-ci.tf for the full rationale. -module "iam_role_github_oidc" { +# Role GitHub Actions assumes via OIDC to apply 02-encryption/aws (and its +# own state). use_name_prefix = false keeps the name stable (no random +# suffix). +module "openbao_unseal_role" { 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: Terraform state + bounded IAM role creation." + name = "openbao-unseal-ci" + use_name_prefix = false + description = "Assumed by GitHub Actions (${var.github_org}/${var.github_repo}) via OIDC: manages the KMS key + IAM user for OpenBao's auto-unseal, plus its own Terraform state." enable_github_oidc = true oidc_wildcard_subjects = ["${var.github_org}/${var.github_repo}:*"] policies = { - tf-managed-ci = module.ci_policy.arn + openbao-unseal = module.openbao_unseal_policy.arn + terraform-state-access = data.terraform_remote_state.remote_state_aws.outputs.terraform_state_access_policy_arn } 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/01-iam/bootstrap/aws/outputs.tf b/01-iam/bootstrap/aws/outputs.tf index ec88246..40fbbf9 100644 --- a/01-iam/bootstrap/aws/outputs.tf +++ b/01-iam/bootstrap/aws/outputs.tf @@ -1,24 +1,4 @@ -# 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 -} - -# Every role the CI creates MUST carry this boundary, or the apply is rejected by -# the CI grant's conditions. Roots that create roles should attach it via the -# iam-role module's `permissions_boundary` input. -output "permissions_boundary_arn" { - description = "ARN of the permissions boundary to attach to every CI-managed role." - value = local.boundary_arn -} - -# The repo-scoped path every CI-managed role/policy must sit under. In CI, feed -# this to Terraform as TF_VAR_role_path=/tf-managed/${{ github.repository }}/ — -# it resolves to exactly this value. IAM paths are case-sensitive. -output "managed_path" { - description = "IAM path prefix all CI-managed roles and policies must use." - value = local.managed_path + description = "ARN of the openbao-unseal-ci role — set as the aws-role-arn input for any workflow that applies 02-encryption/aws." + value = module.openbao_unseal_role.arn } diff --git a/01-iam/bootstrap/aws/variables.tf b/01-iam/bootstrap/aws/variables.tf index dd08b36..a667394 100644 --- a/01-iam/bootstrap/aws/variables.tf +++ b/01-iam/bootstrap/aws/variables.tf @@ -1,13 +1,9 @@ -# 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." + description = "AWS region the provider operates in." 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 @@ -19,27 +15,3 @@ variable "github_repo" { 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" -} - -# Name of the permissions-boundary managed policy. Kept as a stable, fixed name -# (not a generated one) so its ARN can be constructed deterministically — the -# boundary references its own ARN in a Deny, and the CI grant references it in -# every guardrail condition. See iam-ci.tf. -variable "boundary_name" { - description = "Name of the permissions-boundary policy capping all CI-created roles." - type = string - default = "tf-managed-boundary" -} diff --git a/01-iam/bootstrap/aws/version.tf b/01-iam/bootstrap/aws/version.tf index 8434a9b..50fdb96 100644 --- a/01-iam/bootstrap/aws/version.tf +++ b/01-iam/bootstrap/aws/version.tf @@ -1,13 +1,8 @@ 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" - # Prefix kept as "aws-github-oidc" (≠ this root's path identity/00-ci-trust/) - # on purpose: the state key is decoupled from the directory, so the repo - # restructure was a pure move with zero state migration. - workspace_key_prefix = "aws-github-oidc" + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "01-iam/bootstrap/aws" key = "terraform.tfstate" encrypt = true use_lockfile = true @@ -22,11 +17,8 @@ terraform { } # 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. +# - local: `aws sso login` -> the provider defaults to your SSO admin session +# - CI: GitHub OIDC -> aws-actions/configure-aws-credentials sets env vars provider "aws" { region = var.region } diff --git a/01-iam/bootstrap/infisical/.terraform.lock.hcl b/01-iam/bootstrap/infisical/.terraform.lock.hcl deleted file mode 100644 index 38031e0..0000000 --- a/01-iam/bootstrap/infisical/.terraform.lock.hcl +++ /dev/null @@ -1,26 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/infisical/infisical" { - version = "0.16.30" - constraints = "~> 0.16" - hashes = [ - "h1:6XTllezN1ZbgFyxQfLumAwonXMyQl4KdgQLQx+vdWOc=", - "h1:MxCBGCIQUhX/FTSuJsxAwFR5ybakkSxORv259Fbl5Wc=", - "zh:07924c8210300cf3e58c044ca820188644c648c9777d0f3e5ebdf87ae3529aa1", - "zh:0d728a71c0b47358815b5cf13e6070f512ebda61268c6d6e89906d6c169cab53", - "zh:1f8a327f819382d2d0d59f6d4c24fb1a87038efab9f62934519ac3474633b65a", - "zh:397e937a685a68a72779d1ba123f5b291bea5aa61a0fe60adbf56ce3ef4c387c", - "zh:405ee9b531ef8b3402fea060970bea1c31c274d1ff0e1da075721466ca0999fd", - "zh:4ca7f0182b0f7ac08f91803bdb7dd3996e98c2e46a1f14caef8c02c4f54fdf66", - "zh:6c3ed4b716899ca321fdf0a687df24519f5872343cb651af234c2ccfc0a26be8", - "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a04c0a7a3f2daf2e940d3c53f15fc5d923ae9a9e916d0cdfb30c7c4e810d1df0", - "zh:aed0de407ef288a606552c719f517296292c9044d8e314c5b162cb31baf744d9", - "zh:b559fbb532566b9baa3ac06c7061405d274e6d4708cf18180d0b66cee0e79179", - "zh:cd284e2b0e037d124d25ed2f4a5d57279fa39864314a35b06e36888ef78d5511", - "zh:e3b549abcbb2f6d5a03f8244826facce647a67d8ce0cb6a65dd19f7b6cf2a227", - "zh:e9258e0fb4ae3ad7fab80609ba3cf46d9a890ecf1619f54eaa661ebb4f61ffdd", - "zh:ff5e17795828ff8f5c32eec0ae7c9746b26b51e803e8fb28f7131a98cc1fc301", - ] -} diff --git a/01-iam/bootstrap/infisical/README.md b/01-iam/bootstrap/infisical/README.md deleted file mode 100644 index 01f7496..0000000 --- a/01-iam/bootstrap/infisical/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# 01-iam/bootstrap/infisical — GitHub Actions → Infisical (OIDC) - -A standalone Terraform root that sets up **keyless GitHub-OIDC → Infisical**: a -dedicated Infisical machine identity that GitHub Actions authenticates as by -presenting its short-lived OIDC token — no long-lived Infisical secret stored in -CI. - -This is the keyless counterpart to [`01-iam/bootstrap/scaleway`](../scaleway) (which has to -use a static API key because Scaleway IAM isn't an OIDC relying party). Infisical -**is**, so we get the real keyless flow here. - -## How the flow works - -1. A workflow run requests an OIDC token from GitHub (`permissions: id-token: write`). -2. The workflow logs in to Infisical with that token + the identity ID. -3. Infisical verifies the token against GitHub's JWKS (`oidc_discovery_url`), - checks `iss` (`bound_issuer`), `aud` (`bound_audiences`) and `sub` - (`bound_claims.sub` = `repo:IntegratedDynamic/infrastructure:*`), then returns - a short-lived Infisical access token (`access_token_ttl`, default 600s). -4. With that token the workflow reads secrets from the **Platform** project (the - identity is granted the read-only `viewer` project role). - -## What it creates - -- `infisical_identity.github_actions` — the CI identity (org-level role - `no-access`; real permissions come from the project membership, not org-wide). -- `infisical_identity_oidc_auth.github_actions` — the OIDC trust: GitHub issuer + - discovery URL, bound audience, and `sub` scoped to this one repo (any - branch/PR/tag). -- `infisical_project_identity.github_actions` — grants the identity the `viewer` - role on the Platform project so CI can read its secrets. - -## The bootstrap chicken-and-egg - -Creating an OIDC identity still requires an authenticated provider, so this root -authenticates with a **universal-auth machine identity** (`client_id` / -`client_secret` in `default.auto.tfvars`, gitignored, per-developer). That -bootstrap identity is the seed; once this OIDC identity exists, CI authenticates -with it instead of any static secret. - -## Credentials - -- **Infisical** provider — universal-auth machine identity from - `*.auto.tfvars`. Host defaults to `https://app.infisical.com`. -- **S3 state backend** — AWS-style env vars (injected by `mise.toml`'s `[env]` - block like the other roots). - -## Apply - -```bash -terraform -chdir=01-iam/bootstrap/infisical init -terraform -chdir=01-iam/bootstrap/infisical plan -var-file=env/01-iam-infisical.tfvars -terraform -chdir=01-iam/bootstrap/infisical apply -var-file=env/01-iam-infisical.tfvars -``` - -> Never `apply`/`destroy` here without explicit approval. - -After apply, `terraform output identity_id` is the principal GitHub Actions logs -in as — wire it into the workflow (e.g. as a repo variable). - -## Wiring a workflow (example) - -```yaml -permissions: - id-token: write - contents: read -steps: - - uses: Infisical/secrets-action@v1 - with: - method: oidc - identity-id: ${{ vars.INFISICAL_IDENTITY_ID }} # = output identity_id - project-slug: platform-p-qc1 - env-slug: staging - secret-path: /ci -``` - -The OIDC token's default `aud` is `https://github.com/IntegratedDynamic` -(matching `github_oidc_audience`); if the workflow requests a custom audience, -update that variable to match or login is rejected. - -## Trust scope / revocation - -- Trust is pinned to `repo:IntegratedDynamic/infrastructure:*` — only workflows - in this repo can authenticate. Narrow it further (e.g. a single branch) by - tightening `bound_claims.sub`. -- To revoke access, destroy the identity (or remove the project membership to - drop project access while keeping the identity). diff --git a/01-iam/bootstrap/infisical/env/01-iam-infisical.tfvars b/01-iam/bootstrap/infisical/env/01-iam-infisical.tfvars deleted file mode 100644 index bece347..0000000 --- a/01-iam/bootstrap/infisical/env/01-iam-infisical.tfvars +++ /dev/null @@ -1,8 +0,0 @@ -org_id="73133541-f1c4-40d5-93f5-a5e073ab0264" -project_id="7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" -project_role_slug="viewer" - -github_org="IntegratedDynamic" -github_repo="infrastructure" -github_oidc_audience="https://github.com/IntegratedDynamic" -access_token_ttl=600 diff --git a/01-iam/bootstrap/infisical/main.tf b/01-iam/bootstrap/infisical/main.tf deleted file mode 100644 index 4455286..0000000 --- a/01-iam/bootstrap/infisical/main.tf +++ /dev/null @@ -1,56 +0,0 @@ -# GitHub Actions → Infisical, keyless via OIDC. -# -# A workflow run gets a short-lived OIDC token from GitHub; Infisical verifies it -# against GitHub's JWKS and trades it for an Infisical access token — so CI holds -# no long-lived Infisical credential. This is the keyless counterpart to -# 01-iam/scaleway's static API key. -# -# Bootstrap note: creating this identity still needs the universal-auth machine -# identity wired into the provider (default.auto.tfvars). That bootstrap identity -# is the chicken-and-egg seed; once this OIDC identity exists, CI uses it instead. - -resource "infisical_identity" "github_actions" { - name = "github-actions-oidc" - org_id = var.org_id - - # Org-level role is no-access on purpose: this identity draws its actual - # permissions from the project membership below, never org-wide. - role = "no-access" -} - -resource "infisical_identity_oidc_auth" "github_actions" { - identity_id = infisical_identity.github_actions.id - - # GitHub's OIDC provider. The discovery URL serves the JWKS Infisical uses to - # verify the token signature; bound_issuer must equal the token's `iss` claim. - oidc_discovery_url = "https://token.actions.githubusercontent.com" - bound_issuer = "https://token.actions.githubusercontent.com" - - # The `aud` GitHub stamps on the token. With no audience requested in the - # workflow, GitHub defaults it to the repository-owner URL - # (https://github.com/); set var.github_oidc_audience to match whatever - # the workflow requests. - bound_audiences = [var.github_oidc_audience] - - # Scope trust to any workflow in this one repo (any branch / PR / tag), - # mirroring the AWS root's repo:/:* trust. bound_claims values may be - # glob patterns — bound_subject is an exact match, which we don't want here. - bound_claims = { - sub = "repo:${var.github_org}/${var.github_repo}:*" - } - - # Short-lived CI token — long enough for a job, no longer. - access_token_ttl = var.access_token_ttl -} - -# Grant the identity access to the Platform project so CI can read its secrets. -resource "infisical_project_identity" "github_actions" { - project_id = var.project_id - identity_id = infisical_identity.github_actions.id - - roles = [ - { - role_slug = var.project_role_slug - }, - ] -} diff --git a/01-iam/bootstrap/infisical/outputs.tf b/01-iam/bootstrap/infisical/outputs.tf deleted file mode 100644 index a8be3b0..0000000 --- a/01-iam/bootstrap/infisical/outputs.tf +++ /dev/null @@ -1,11 +0,0 @@ -# The identity ID is the principal GitHub Actions logs in as (the OIDC login call -# takes identityId). Public identifier — safe to surface and wire into CI. -output "identity_id" { - description = "Infisical identity ID GitHub Actions authenticates as via OIDC." - value = infisical_identity.github_actions.id -} - -output "oidc_auth_id" { - description = "ID of the OIDC auth configuration attached to the identity." - value = infisical_identity_oidc_auth.github_actions.id -} diff --git a/01-iam/bootstrap/infisical/variables.tf b/01-iam/bootstrap/infisical/variables.tf deleted file mode 100644 index ecc5762..0000000 --- a/01-iam/bootstrap/infisical/variables.tf +++ /dev/null @@ -1,43 +0,0 @@ -variable "org_id" { - description = "Infisical organization ID the GitHub Actions identity is created in." - type = string - default = "73133541-f1c4-40d5-93f5-a5e073ab0264" -} - -variable "project_id" { - description = "Infisical project (workspace) ID the identity is granted access to. Defaults to the Platform project." - type = string - default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" -} - -variable "project_role_slug" { - description = "Project role granted to the GitHub Actions identity. 'viewer' = read-only secret access." - type = string - default = "viewer" -} - -# Trust is scoped to exactly one repo: repo:/:* . Only workflows in -# this repo can present an OIDC token Infisical will accept. -variable "github_org" { - description = "GitHub organization that owns the repo allowed to authenticate." - type = string - default = "IntegratedDynamic" -} - -variable "github_repo" { - description = "GitHub repository whose workflows may authenticate (sub claim is scoped to it)." - type = string - default = "infrastructure" -} - -variable "github_oidc_audience" { - description = "Expected `aud` claim on the GitHub OIDC token. Defaults to GitHub's repository-owner default audience." - type = string - default = "https://github.com/IntegratedDynamic" -} - -variable "access_token_ttl" { - description = "Lifetime (seconds) of the Infisical access token CI receives after OIDC login." - type = number - default = 600 -} diff --git a/01-iam/bootstrap/infisical/version.tf b/01-iam/bootstrap/infisical/version.tf deleted file mode 100644 index d105afd..0000000 --- a/01-iam/bootstrap/infisical/version.tf +++ /dev/null @@ -1,26 +0,0 @@ -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. Fresh - # prefix for a brand-new root — no state to migrate. - backend "s3" { - bucket = "id-terraform-state20260612164136440800000001" - region = "eu-west-3" - workspace_key_prefix = "infisical-github-oidc" - key = "terraform.tfstate" - encrypt = true - use_lockfile = true - } - - required_providers { - infisical = { - source = "infisical/infisical" - version = "~> 0.16" - } - } -} - -# The provider authenticates with a universal-auth machine identity (the -# bootstrap identity). Its client_id / client_secret come from *.auto.tfvars -# (per-developer, gitignored — see default.auto.tfvars). Host defaults to -# https://app.infisical.com. -provider "infisical" {} diff --git a/01-iam/bootstrap/scaleway/.terraform.lock.hcl b/01-iam/bootstrap/scaleway/.terraform.lock.hcl index 2effc6a..8974a95 100644 --- a/01-iam/bootstrap/scaleway/.terraform.lock.hcl +++ b/01-iam/bootstrap/scaleway/.terraform.lock.hcl @@ -1,6 +1,29 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} + provider "registry.terraform.io/hashicorp/time" { version = "0.14.0" constraints = "~> 0.12" @@ -23,48 +46,24 @@ provider "registry.terraform.io/hashicorp/time" { ] } -provider "registry.terraform.io/infisical/infisical" { - version = "0.16.28" - constraints = "~> 0.16" - hashes = [ - "h1:3u5WxYFLl+DUSqoma4DEY/DYbN7fMt6yTDcrkpFQz5Q=", - "h1:BvcG6jgReLptymYOXetIEpaZBLA2rsbexEl7THzENM0=", - "zh:09d25451a3ebbb1e9ba5a73f29f6c9dfd2f890c3966ec66af401969164b42a67", - "zh:2e1eac9f42920336694baaa83e1e0ae252d4fded21d3c4ce874831c8ca9575b4", - "zh:306b370bdfb18ffb0d819c613fb2bc3377037a9be47a70ecdd6cc2e83bdeff14", - "zh:63d6291c6a81fe9d1469ff86d4dc2b6e8fbf93e255d0f3d58f98cfdc6817fc98", - "zh:66f01a8234b079cb3e9e80eefa2ee2d107191632d03db84fa5da42ad8e925261", - "zh:75794f043a2320a67706fe545f488d4cbaaccae844622b2a80967198f39bf226", - "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a66be07dd80c836b7bd44cc1818b35156665b50011209711ce264e4320c6ef9d", - "zh:ae895218e7616f439cc03ce98d1d9179741df89abb72520d70773fd427adf320", - "zh:bbb50efbfb6d1dd29013329fd38ab72e5b7b4694b840aae84e550f1991cee1a1", - "zh:c25cfe7ecf55201bd2433dc5d4dfe3bf608c75f3c22a3ec3f146636fef75a9dd", - "zh:d54fc418ff11ffd11fce7b02c77735686ee4efcb2222c9f62dd85d5a275cda4b", - "zh:e38e93b6f72204f37475b692c3c9878b62ebdd5ee2eabac90b9368f6651c3f88", - "zh:ed9a4625835728b0d6d8fa82be0cf6d71224654666223e0e74f49471ba12e90e", - "zh:f85e74fae43fb35182c99b2a1cc57cf40aa831218d359fe48f66e66b89ec5f9b", - ] -} - provider "registry.terraform.io/scaleway/scaleway" { - version = "2.76.0" + version = "2.79.0" constraints = "~> 2.0" hashes = [ - "h1:3RImo3Jf88dXcIqmBRuO/A85jAxrAQsvYa4zhVqW1tI=", - "h1:ktRogBsJlCf3JTOTYPm9hKaPUzaps0aLwxsNfP155ns=", - "zh:13b6790dca2c91c7478d6e9cd03d84713e0b0ec001c9923d3c93bd12a1f4152f", - "zh:219582ef77ec6f27d2928684b95603b5a921001631f9eec68c14819fdbc1efea", - "zh:26bc09c7aadc49fe83a9d6af9c1d0951f93de129f90d04e5e65e1d82c8ac1914", - "zh:4015a970be3669ee009344afb269a09eaf9fe1363a12a10d3311326ba769463a", - "zh:4c1c5997ef4182e49e46ff6563581a90b5cfa30f0ec8d7d919b3dc0603ed3043", - "zh:58d91e08a8fe38ddda2c03013d1ba77ff4e21a91fef0a425575b5af7bf7f4ebc", - "zh:644a61f963483c8eba7f8427650c4956e1d8c59710a11bb2691ad8996ee14a9c", - "zh:6745d5d80006c375b104a005cfc007f90e2cb547cf9e96c886d453be3307a399", - "zh:8dcac392ca182af40b823c6721833de1325c279b1e5bdcddf1a1cf2789f3558a", - "zh:9eb284d3e9a14d4d64e2a7a865be45ec0eee32ec16c51bcc1722c0449bfb9fab", - "zh:a4eb4973cc1d9ac4911733d62f5b0e53fbc7b90112efa312918c0320966e276e", - "zh:ce58ae8014b2981bbc875bc7ad4cdeb93957370bfa4308eeb193155ee988fbec", - "zh:d3f0f3bec0a6f4759948749cdb0eb64e4415507a82c79659f1ede894f96f4976", + "h1:LcxR9sNfLg0lMcPGni34/aFAIL0A3x06mZ8Lhg9LYtk=", + "h1:ppfv4S+zXnjoESuhtHx/wE3GZO1sIn+d7c54uouZQrs=", + "zh:01ec419b6692bd0ee6c1b64c6a7a9823bc2b757ea6d37958ebf7bf0057ef8083", + "zh:025a88b258bd3283439c3380e27edefb4529af875a281e111e43bff9ecc28244", + "zh:1ce6ab69fbf08ae529dc7978ccaad00a2b13752f086372f4916d063cf65f5ae8", + "zh:2268334e310d0b20d138d32c179d136acc4092c4d5567340074f260f785c2a37", + "zh:4ad399c3331a1411839411574f956f08f85617e0c4b66430375e1fe45898182b", + "zh:4ed10f0369526d35de7c4d7a178830e6214660f29d6ee88d7beaf31008ee0408", + "zh:646b9c6cfb9d8b73e3fa7db856e8792569d78aa8eb54ef1e742ba6ce9783c7dc", + "zh:7f7e42809dd20511fa60c16ad36b04510f6b95b3b5570ab0c5405d3374cb5421", + "zh:8c143b87770d36736caf381f4c96f724ab7b5f74c720a068f328d4fe558a7c10", + "zh:9620776be5cf216efcaff00a592c5d30770230d311b6398b3b7cf533c8b318ee", + "zh:99ccd4a8ff73f0670e9f5d9f57ab32c37ee71ded27ea650a62953fa66953826c", + "zh:a2dd8abd76c9ebd75cd52f0993d5ee06ce7e1b55d5a215f9b156dc1062898782", + "zh:b12575d192032eae656ab4ccce4c92b1047838d77a0c35255e7595a97e72feab", ] } diff --git a/01-iam/bootstrap/scaleway/README.md b/01-iam/bootstrap/scaleway/README.md index 47b9a40..329cf6a 100644 --- a/01-iam/bootstrap/scaleway/README.md +++ b/01-iam/bootstrap/scaleway/README.md @@ -5,7 +5,7 @@ Actions uses to authenticate to Scaleway**. First real consumer: a smoke-test workflow that lists Object Storage buckets; the Terraform CI/CD pipeline itself is a separate, later concern. -This is **not** under `02-cluster/` — it provisions no cluster. It's a +This is **not** under `10-cluster/` — it provisions no cluster. It's a `01-iam/bootstrap/` trust anchor (human-applied), kept as its own root so its state and blast radius stay small. @@ -30,19 +30,24 @@ Revisit OIDC if/when Scaleway ships it (see the link above). ## What it creates -- `scaleway_iam_application.github_ci` — the CI identity. -- `scaleway_iam_policy.github_ci` — `permission_set_names = ["ObjectStorageReadOnly"]`, - scoped to `var.project_id` (and **no** broader set). -- `scaleway_iam_api_key.github_ci` — the API key for that application, with - `default_project_id` baked in so `scw object bucket list` resolves the right - scope without the workflow passing a project ID. The org enforces an expiry on - every key, so `time_rotating.api_key` drives `expires_at` (default 365 days, - `var.api_key_rotation_days`) and rotates the key on the next apply after it - lapses — see [Rotation / revocation](#rotation--revocation). -- `infisical_secret.scw_access_key` / `infisical_secret.scw_secret_key` — the key - written into Infisical (env `staging`, folder `/ci` by default). The secret half - is Terraform-`sensitive`; it's never printed or committed (state-only, per the - repo's bootstrap model). +Via `module "ci_identity"` ([modules/scaleway-machine-identity](../../../modules/scaleway-machine-identity)): + +- One `scaleway_iam_application` — the CI identity. +- Two `scaleway_iam_policy` objects on it: `cluster_management` (`VPCFullAccess`, + `KubernetesFullAccess`, `PrivateNetworksFullAccess`, `IPAMReadOnly`, project-scoped — + lets CI create/destroy the Kapsule cluster) and `backup_management` (Object + Storage bucket/object management, project-scoped, plus `IAMApplicationManager`/ + `IAMPolicyManager`, org-scoped — lets CI provision the storage domain's buckets + and their scoped workload identities in `03-storage/scaleway/`). +- One `scaleway_iam_api_key` for that application, with `default_project_id` baked + in so `scw object bucket list` resolves the right scope without the workflow + passing a project ID. The org enforces an expiry on every key, so + `time_rotating` drives `expires_at` (default 365 days, `var.api_key_rotation_days`) + and rotates the key on the next apply after it lapses — see + [Rotation / revocation](#rotation--revocation). + +Nothing is pushed to Infisical (retired — see repo root). The secret key stays +state-only; distribute it by hand (see below). ## Credentials @@ -50,9 +55,6 @@ Same as the other roots: - **Scaleway** provider reads creds + default region/project from the **scw CLI config** (`~/.config/scw/config.yaml`). -- **Infisical** provider authenticates via a **universal-auth machine identity**; - its `client_id` / `client_secret` come from `*.auto.tfvars` (per-developer, - gitignored — see `nico.auto.tfvars`). - The **S3 state backend** authenticates with AWS-style env vars derived from the scw config; `mise.toml`'s `[env]` block injects them automatically under mise. @@ -66,14 +68,13 @@ terraform -chdir=01-iam/bootstrap/scaleway apply # billable: creates an IAM ke > Never `terraform apply`/`destroy` here without explicit approval. -After apply, the access key is an output and both halves are in Infisical. +After apply, the access key is an output; the secret key is state-only. ## Wiring the GitHub secrets (manual) -Automating the Infisical → GitHub push is deferred (it'd mean adding a GitHub -token to this bootstrap). For now, set the two repo secrets by hand. Read the -values straight out of the Terraform state/output and Infisical — **don't paste -them into your shell history or echo them**: +There's no automated push (Infisical, which used to carry this, is retired) — set +the two repo secrets by hand. Read the values straight out of the Terraform +state/output — **don't paste them into your shell history or echo them**: ```bash # SCW_ACCESS_KEY is a public identifier, exposed as a Terraform output: @@ -84,14 +85,10 @@ gh secret set SCW_ACCESS_KEY \ # SCW_SECRET_KEY is sensitive — pipe it from the API key resource without printing: gh secret set SCW_SECRET_KEY \ --repo IntegratedDynamic/infrastructure \ - --body "$(terraform -chdir=01-iam/bootstrap/scaleway state show -no-color scaleway_iam_api_key.github_ci \ + --body "$(terraform -chdir=01-iam/bootstrap/scaleway state show -no-color 'module.ci_identity.scaleway_iam_api_key.this' \ | awk '/secret_key/ {print $3; exit}' | tr -d '\"')" ``` -(Or copy the secret from Infisical → `staging` → `/ci` → `SCW_SECRET_KEY` and -`gh secret set SCW_SECRET_KEY --repo IntegratedDynamic/infrastructure` reading -from stdin.) - ## Verify end to end The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs @@ -124,11 +121,11 @@ The API key lives entirely in this root's state. - **On demand** — force it early with: ```bash - terraform -chdir=01-iam/bootstrap/scaleway apply -replace=scaleway_iam_api_key.github_ci + terraform -chdir=01-iam/bootstrap/scaleway apply -replace='module.ci_identity.scaleway_iam_api_key.this' ``` Either way the key material changes, so **re-run the `gh secret set` steps above** -afterwards (the Infisical copies update automatically; the GitHub secrets don't). +afterwards. To kill access entirely, destroy the application (revokes the key) — but mind that any workflow depending on it will start failing. diff --git a/01-iam/bootstrap/scaleway/env/01-iam-scaleway.tfvars b/01-iam/bootstrap/scaleway/env/01-iam-scaleway.tfvars index 654e22e..5fcd318 100644 --- a/01-iam/bootstrap/scaleway/env/01-iam-scaleway.tfvars +++ b/01-iam/bootstrap/scaleway/env/01-iam-scaleway.tfvars @@ -1,6 +1,2 @@ -infisical_workspace_id="7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" -infisical_env_slug="staging" -infisical_folder_path="/ci" - project_id="6283c05b-a4c7-4f83-a75f-83adad236d54" api_key_rotation_days=365 diff --git a/01-iam/bootstrap/scaleway/main.tf b/01-iam/bootstrap/scaleway/main.tf index c367af7..f6d514c 100644 --- a/01-iam/bootstrap/scaleway/main.tf +++ b/01-iam/bootstrap/scaleway/main.tf @@ -1,100 +1,58 @@ -resource "scaleway_iam_application" "this" { - name = "github-ci" - description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." -} - -# 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-cluster-management" - description = "Kubernetes/VPC/PrivateNetwork management for the GitHub Actions CI application, project-scoped." - application_id = scaleway_iam_application.this.id - - rule { - project_ids = [var.project_id] - permission_set_names = ["VPCFullAccess", "KubernetesFullAccess", "PrivateNetworksFullAccess", "IPAMReadOnly"] +# GitHub Actions CI identity for the IntegratedDynamic/infrastructure repo. +# Two policies on one application: cluster-management (Kubernetes/VPC/ +# PrivateNetwork, to create/destroy the Kapsule cluster) and backup-management +# (Object Storage + IAM application/policy management, so the CI can also +# provision the storage domain's buckets and their scoped workload +# identities). +module "ci_identity" { + source = "../../../modules/scaleway-machine-identity" + + application_name = "github-ci" + application_description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: 01-iam/bootstrap/scaleway/)." + + policies = { + cluster_management = { + name = "github-ci-cluster-management" + description = "Kubernetes/VPC/PrivateNetwork management for the GitHub Actions CI application, project-scoped." + rules = [ + { + project_ids = [var.project_id] + permission_set_names = ["VPCFullAccess", "KubernetesFullAccess", "PrivateNetworksFullAccess", "IPAMReadOnly"] + } + ] + } + + # Object Storage bucket + IAM workload identity management for the + # storage domain CI workflow (03-storage/scaleway/). Bucket deletion is + # blocked via prevent_destroy + absence of a destroy trigger in the CI + # workflow (Scaleway bucket policies do not support s3:DeleteBucket). + backup_management = { + name = "github-ci-backup-management" + description = "Object Storage bucket + IAM workload identity management for the storage domain CI workflow (03-storage/scaleway/)." + rules = [ + { + project_ids = [var.project_id] + permission_set_names = [ + "ObjectStorageBucketsRead", + "ObjectStorageBucketsWrite", + "ObjectStorageObjectsRead", + "ObjectStorageObjectsWrite", + ] + }, + # IAM permission sets are organization-scoped — they cannot be + # combined with project_ids in the same rule. + { + organization_id = var.organization_id + permission_set_names = [ + "IAMApplicationManager", + "IAMPolicyManager", + ] + } + ] + } } -} - -# Added for 03-backup/scaleway: the backup CI workflow runs under the same -# github-ci identity and needs Object Storage management + IAM application/policy/ -# API key management to provision the bucket and scoped workload credentials. -# Bucket deletion is blocked via prevent_destroy + absence of a destroy trigger -# in the CI workflow (Scaleway bucket policies do not support s3:DeleteBucket). -resource "scaleway_iam_policy" "backup_ci" { - name = "github-ci-backup-management" - description = "Object Storage bucket + IAM workload identity management for the backup domain CI workflow (03-backup/scaleway/)." - application_id = scaleway_iam_application.this.id - - rule { - project_ids = [var.project_id] - permission_set_names = [ - "ObjectStorageBucketsRead", - "ObjectStorageBucketsWrite", - "ObjectStorageObjectsRead", - "ObjectStorageObjectsWrite", - ] - } - - # IAM permission sets are organization-scoped — they cannot be combined - # with project_ids in the same rule. - rule { - organization_id = var.organization_id - permission_set_names = [ - # Required to create/manage the scoped workload IAM application, policy, - # and API key in 03-backup/scaleway/iam.tf. - "IAMApplicationManager", - "IAMPolicyManager", - ] - } -} - -# The org enforces an expiry on every API key, and `expires_at` is ForceNew, so -# the key inherently rotates when the expiry moves. time_rotating makes that -# concrete and self-renewing: the timestamp holds steady until the window -# elapses, then the next apply pushes it forward and rotates the key (re-run -# `gh secret set` afterwards — see README). -resource "time_rotating" "api_key" { - rotation_days = var.api_key_rotation_days -} - -resource "scaleway_iam_api_key" "this" { - application_id = scaleway_iam_application.this.id - description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." - - # Bakes the project into the key so `scw object bucket list` resolves the right - # scope without the workflow passing a project ID. - default_project_id = var.project_id - - expires_at = time_rotating.api_key.rotation_rfc3339 -} - -# ── Write the key into Infisical ──────────────────────────────────────────── -# GitHub secrets themselves are still set manually via `gh secret set` (see -# README) — automating that push is deferred to avoid a GitHub token here. - -# infisical_secret does not create missing folders, so the CI folder must exist -# first. var.infisical_folder_path is "/"; create that name under root. -resource "infisical_secret_folder" "ci" { - project_id = var.infisical_workspace_id - environment_slug = var.infisical_env_slug - folder_path = "/" - name = trimprefix(var.infisical_folder_path, "/") - description = "CI secrets for GitHub Actions (managed by terraform: github-ci/)." -} - -resource "infisical_secret" "scw_access_key" { - name = "SCW_ACCESS_KEY" - value = scaleway_iam_api_key.this.access_key - env_slug = var.infisical_env_slug - workspace_id = var.infisical_workspace_id - folder_path = infisical_secret_folder.ci.path -} -resource "infisical_secret" "scw_secret_key" { - name = "SCW_SECRET_KEY" - value = scaleway_iam_api_key.this.secret_key - env_slug = var.infisical_env_slug - workspace_id = var.infisical_workspace_id - folder_path = infisical_secret_folder.ci.path + project_id = var.project_id + api_key_description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." + api_key_rotation_days = var.api_key_rotation_days } diff --git a/01-iam/bootstrap/scaleway/outputs.tf b/01-iam/bootstrap/scaleway/outputs.tf index aa51c35..0b76bbf 100644 --- a/01-iam/bootstrap/scaleway/outputs.tf +++ b/01-iam/bootstrap/scaleway/outputs.tf @@ -1,11 +1,12 @@ output "application_id" { description = "IAM application ID backing the GitHub Actions CI identity." - value = scaleway_iam_application.this.id + value = module.ci_identity.application_id } # The access key is a public identifier (like an AWS access key ID), so it's safe -# to surface. The secret half is never output — read it from Infisical or state. +# to surface. The secret half is never output — set it in GitHub Actions secrets +# by hand (see README). output "access_key" { description = "SCW_ACCESS_KEY for the CI identity (public identifier)." - value = scaleway_iam_api_key.this.access_key + value = module.ci_identity.access_key } diff --git a/01-iam/bootstrap/scaleway/variables.tf b/01-iam/bootstrap/scaleway/variables.tf index 204631a..3c61ba2 100644 --- a/01-iam/bootstrap/scaleway/variables.tf +++ b/01-iam/bootstrap/scaleway/variables.tf @@ -1,21 +1,3 @@ -variable "infisical_workspace_id" { - description = "Infisical project (workspace) ID the CI secrets are written to." - type = string - default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" -} - -variable "infisical_env_slug" { - description = "Infisical environment slug the CI secrets live in." - type = string - default = "staging" -} - -variable "infisical_folder_path" { - description = "Infisical folder the CI secrets are written to (kept separate from the cluster bootstrap secrets)." - type = string - default = "/ci" -} - # The default project shares the organization's UUID on Scaleway. The buckets the # CI identity must list live here, so we scope the policy and the API key to it. variable "project_id" { diff --git a/01-iam/bootstrap/scaleway/version.tf b/01-iam/bootstrap/scaleway/version.tf index 8b19c08..a190405 100644 --- a/01-iam/bootstrap/scaleway/version.tf +++ b/01-iam/bootstrap/scaleway/version.tf @@ -2,26 +2,20 @@ terraform { backend "s3" { bucket = "id-terraform-state20260612164136440800000001" region = "eu-west-3" - # Prefix kept as "github-ci" (≠ this root's path ci/10-scaleway/) on purpose: - # the state key is decoupled from the directory, so the repo restructure was - # a pure move with zero state migration. + # Prefix kept as "github-ci" (≠ this root's path 01-iam/bootstrap/scaleway/) + # on purpose: the state key is decoupled from the directory, so the repo + # restructure was a pure move with zero state migration. workspace_key_prefix = "github-ci" key = "terraform.tfstate" encrypt = true use_lockfile = true } - - required_providers { scaleway = { source = "scaleway/scaleway" version = "~> 2.0" } - infisical = { - source = "infisical/infisical" - version = "~> 0.16" - } time = { source = "hashicorp/time" version = "~> 0.12" @@ -31,12 +25,3 @@ terraform { # Creds, region and project_id come from the scw CLI config (like the other roots). provider "scaleway" {} -# Without `auth.oidc`, infisical will not try to consume OIDC environment variables, even if present, and will only look for generic auth environment variables. -provider "infisical" { - auth = { - ## Uncomment `universal` and comment `oidc` when running terraform locally, . - ## By default, even with `INFISICAL_UNIVERSAL_AUTH_CLIENT_XXXX` environment variable, due to `auth.oidc` being present, infisical provider expect OIDC configuration, and nothing else. - # universal = {} - oidc = {} - } -} diff --git a/01-iam/ci-managed/aws-state-access/.terraform.lock.hcl b/01-iam/ci-managed/aws-state-access/.terraform.lock.hcl deleted file mode 100644 index a6f94df..0000000 --- a/01-iam/ci-managed/aws-state-access/.terraform.lock.hcl +++ /dev/null @@ -1,27 +0,0 @@ -# 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/01-iam/ci-managed/aws-state-access/env/00-remote-state-iam.tfvars b/01-iam/ci-managed/aws-state-access/env/00-remote-state-iam.tfvars deleted file mode 100644 index fb0bf20..0000000 --- a/01-iam/ci-managed/aws-state-access/env/00-remote-state-iam.tfvars +++ /dev/null @@ -1,20 +0,0 @@ -# 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 = "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. -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" - -# Any repo in this GitHub org may assume the role directly via OIDC web identity. -github_oidc_subjects = ["IntegratedDynamic/*"] diff --git a/01-iam/ci-managed/aws-state-access/main.tf b/01-iam/ci-managed/aws-state-access/main.tf deleted file mode 100644 index bbe9359..0000000 --- a/01-iam/ci-managed/aws-state-access/main.tf +++ /dev/null @@ -1,66 +0,0 @@ -# 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 -# identity/00-ci-trust/iam-ci.tf): the role must sit under the managed `path` and -# carry the `permissions_boundary`. Omit either and the apply is denied. -# -# 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 "tf_state_access" { - source = "terraform-aws-modules/iam/aws//modules/iam-role" - version = "6.6.1" - - name = var.role_name - use_name_prefix = false - path = var.role_path - description = "Org-wide Terraform-state S3 access (read/write + lock). 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"] - principals = [{ - type = "AWS" - identifiers = ["*"] - }] - condition = [{ - test = "StringEquals" - variable = "aws:PrincipalOrgID" - values = [var.org_id] - }] - } - } - - policies = { - S3FullAccess = "arn:aws:iam::aws:policy/AmazonS3FullAccess" - } - - tags = { - Terraform = "true" - Environment = "dev" - ManagedBy = "ci" - } -} diff --git a/01-iam/ci-managed/aws-state-access/outputs.tf b/01-iam/ci-managed/aws-state-access/outputs.tf deleted file mode 100644 index 8e9f392..0000000 --- a/01-iam/ci-managed/aws-state-access/outputs.tf +++ /dev/null @@ -1,4 +0,0 @@ -output "role_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/01-iam/ci-managed/aws-state-access/variables.tf b/01-iam/ci-managed/aws-state-access/variables.tf deleted file mode 100644 index f35cba7..0000000 --- a/01-iam/ci-managed/aws-state-access/variables.tf +++ /dev/null @@ -1,51 +0,0 @@ -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 Terraform-state access role (R/W + lock). Renaming it changes the ARN — keep vars.AWS_TF_STATE_ROLE_ARN in sync." - type = string - default = "tf-state-access" -} - -# IAM path every CI-managed role must sit under. The CI grant (identity/00-ci-trust/) -# 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 identity/00-ci-trust/ root. Required by -# the CI grant's conditions. -variable "permissions_boundary_arn" { - description = "ARN of the permissions boundary to attach (identity/00-ci-trust 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" -} - -# 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/01-iam/ci-managed/aws-state-access/version.tf b/01-iam/ci-managed/aws-state-access/version.tf deleted file mode 100644 index bf41b20..0000000 --- a/01-iam/ci-managed/aws-state-access/version.tf +++ /dev/null @@ -1,27 +0,0 @@ -terraform { - # Remote state on the shared org S3 bucket, under this root's own key prefix. - # In CI the GitHub OIDC role (identity/00-ci-trust/) 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" - # Prefix kept as "s3-lister-role" (≠ this root's path state/10-access/) on - # purpose: the state key is decoupled from the directory, so the repo - # restructure was a pure move with zero state migration. - 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/01-iam/workload/scaleway/.terraform.lock.hcl b/01-iam/workload/scaleway/.terraform.lock.hcl new file mode 100644 index 0000000..fad5703 --- /dev/null +++ b/01-iam/workload/scaleway/.terraform.lock.hcl @@ -0,0 +1,69 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.0" + constraints = "~> 0.12" + hashes = [ + "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", + "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", + "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", + "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", + "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", + "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", + "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", + "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", + "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", + "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", + "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", + "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", + "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", + ] +} + +provider "registry.terraform.io/scaleway/scaleway" { + version = "2.80.0" + constraints = "~> 2.0" + hashes = [ + "h1:0TaO/rvDAoAeRdaGuPF0JXfoy51vRTElY6TefEpyvYk=", + "h1:jez/tG7RtI3qdLm8RuWwoCCjGb6K9JfHfYiaK1KsmBo=", + "zh:190206eddd683045906734af64c1f851173634a6246ba9d93059fd21e7e8d110", + "zh:2af6f3bd53e744af40ed3adbee0e744a60cebbc799047b63f975fa868629c0b4", + "zh:417587f0db0b0069d144bfaf42af536e2a26d124eca4bcc302aa84bcf96860b9", + "zh:6bdac38a21cdf2969db613bc5e4e4ca3167b50993793319468b13188e5fb0caa", + "zh:7b41685018c3b81e5a54b14c2d311b93b71cfc25a0d5c2d961b1d20460f98b20", + "zh:840f04bfb4cb3b8fdd316726d77d9ebd3b92f0ec1fb19d63f8b1834fe3f50a2a", + "zh:870133a4129e09108ae99bde094185d9ef3b4b2af4148080c4a38e295f0887a2", + "zh:a96476ed9ba7b291a7866a6b58aeb8a9ccf944d4e2af487211ec2f1d0ff1b3f2", + "zh:be9580aa19b4df881ff246e68fcb1ba1344baba960f652dd7f9e19d08e919c52", + "zh:cb3d05c18f655853ea11087d3ac2253055cc221ed707dd98bbe8dc8fc7215b95", + "zh:d33f232ca0e23b18505f4d7c1acb65cdf2f8e60e3ab2ecfbbdf48cfc765a057f", + "zh:dd0c10d1c609f5fe880da4716d94d36ebac7fdee282a8c5a8ce0829bff4c4959", + "zh:dd331f3ff9359b83ac2a5b370771715d3aff2a9110128b7996000a8c911d31fc", + ] +} diff --git a/01-iam/workload/scaleway/env/04-dns-scaleway.tfvars b/01-iam/workload/scaleway/env/04-dns-scaleway.tfvars new file mode 100644 index 0000000..d2f2bee --- /dev/null +++ b/01-iam/workload/scaleway/env/04-dns-scaleway.tfvars @@ -0,0 +1,16 @@ +project_id = "6283c05b-a4c7-4f83-a75f-83adad236d54" + +identities = { + external-dns = { + purpose = "manages DNS zone records for domains bought through Scaleway" + policy_description = "DNS zone record read/write for the external-dns workload. No domain registration/transfer access." + rules = [ + { + project_ids = ["6283c05b-a4c7-4f83-a75f-83adad236d54"] + permission_set_names = ["DomainsDNSFullAccess"] + } + ] + } +} + +# api_key_rotation_days defaults to 365 diff --git a/01-iam/workload/scaleway/main.tf b/01-iam/workload/scaleway/main.tf new file mode 100644 index 0000000..cdf517a --- /dev/null +++ b/01-iam/workload/scaleway/main.tf @@ -0,0 +1,24 @@ +# Non-bucket Scaleway workload identities — one Scaleway machine identity per +# entry in var.identities (see variables.tf), no owned bucket, no CI trust +# anchor. external-dns today (DNS zone record management only, no DNS zone +# resource is Terraform-managed here). Add a future workload identity by +# adding a map entry, no new .tf resources. +module "identities" { + source = "../../../modules/scaleway-machine-identity" + for_each = var.identities + + application_name = "${each.key}-${terraform.workspace}" + application_description = "Kubernetes workload identity for ${each.key} (${terraform.workspace}) — ${each.value.purpose}." + + policies = { + default = { + name = "${each.key}-${terraform.workspace}" + description = each.value.policy_description + rules = each.value.rules + } + } + + project_id = var.project_id + api_key_description = "${each.key} workload credentials (${terraform.workspace})." + api_key_rotation_days = each.value.api_key_rotation_days +} diff --git a/01-iam/workload/scaleway/outputs.tf b/01-iam/workload/scaleway/outputs.tf new file mode 100644 index 0000000..1984e3f --- /dev/null +++ b/01-iam/workload/scaleway/outputs.tf @@ -0,0 +1,25 @@ +# Backward-compatible names — 05-secrets/openbao/managed's +# terraform_remote_state reads these two specifically for the external-dns +# identity. Don't repoint them at a different identity if you add one; use +# the generic maps below for new identities instead. +output "workload_access_key" { + description = "Public access key for the external-dns workload identity." + value = module.identities["external-dns"].access_key +} + +output "workload_secret_key" { + description = "Secret key for the external-dns workload identity. Not pushed anywhere yet (terraform output) — copy into OpenBao by hand at apps/external-dns/scaleway-dns-credentials (see gitops apps/external-dns-init)." + sensitive = true + value = module.identities["external-dns"].secret_key +} + +output "access_keys" { + description = "Map of identity key => public access key, for every identity in var.identities." + value = { for k, m in module.identities : k => m.access_key } +} + +output "secret_keys" { + description = "Map of identity key => secret access key, for every identity in var.identities." + sensitive = true + value = { for k, m in module.identities : k => m.secret_key } +} diff --git a/01-iam/workload/scaleway/variables.tf b/01-iam/workload/scaleway/variables.tf new file mode 100644 index 0000000..ece0298 --- /dev/null +++ b/01-iam/workload/scaleway/variables.tf @@ -0,0 +1,23 @@ +variable "project_id" { + description = "Scaleway project ID for IAM resource scoping." + type = string +} + +# One Scaleway machine identity per entry, via +# modules/scaleway-machine-identity. Single policy per identity — this domain +# is for simple scoped workload credentials (like external-dns), not CI trust +# anchors that might need several policies (see 01-iam/bootstrap/scaleway for +# that shape instead). +variable "identities" { + description = "Map of identity key => config." + type = map(object({ + purpose = string + policy_description = string + api_key_rotation_days = optional(number, 365) + rules = list(object({ + project_ids = optional(list(string)) + organization_id = optional(string) + permission_set_names = list(string) + })) + })) +} diff --git a/01-iam/workload/scaleway/version.tf b/01-iam/workload/scaleway/version.tf new file mode 100644 index 0000000..07f8180 --- /dev/null +++ b/01-iam/workload/scaleway/version.tf @@ -0,0 +1,29 @@ +terraform { + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + # Prefix kept as "dns/scaleway" (≠ this root's path 01-iam/workload/scaleway/, + # moved here from 04-dns/scaleway/ since this root owns no DNS resource — it + # only provisions the external-dns workload identity) on purpose: the state + # key is decoupled from the directory, so the move was a pure git mv with + # zero state migration. + workspace_key_prefix = "dns/scaleway" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} + +# Creds, region and project_id come from the scw CLI config (like the other roots). +provider "scaleway" {} diff --git a/02-encryption/README.md b/02-encryption/README.md new file mode 100644 index 0000000..7a2b887 --- /dev/null +++ b/02-encryption/README.md @@ -0,0 +1,32 @@ +# 02-encryption — what this domain is for + +Encryption exists to protect data from unauthorized access — and the most +critical data to protect is your secrets. **OpenBao** was picked to hold +those secrets (`05-secrets/openbao`) — open source, mature, and well +integrated with Kubernetes. + +To stay concise: OpenBao needs an encryption key — think of it as a master +key — to auto-unseal itself. Some cloud providers, AWS and GCP among them, +offer deep, OpenBao-compatible integrations that shrink that key's exposure +surface and make running OpenBao easier. That's what this domain provisions: +the key and the minimal credential OpenBao needs to reach it, on whichever +provider offers that integration (AWS today — see below for why not +Scaleway). + +Useful links: +- [Auto-unseal plugins](https://openbao.org/community/rfcs/auto-unseal-plugins/) +- [Security model](https://openbao.org/docs/internals/security/) +- [Seal/Unseal concepts](https://openbao.org/docs/concepts/seal/) + +Unlike the other domain READMEs in this repo, this one isn't +provider-agnostic on purpose: the actual driving constraint here isn't a +generic architectural choice, it's OpenBao's own auto-unseal plugin support — +which cloud a key can live in is dictated by which plugins OpenBao ships, not +by preference. That constraint is strong and specific enough that pretending +this domain is vendor-neutral would just be less honest. + +## The contract + +- **The key/credential material only**, scoped to what OpenBao's auto-unseal + plugin needs to authenticate — nothing broader, and no IAM here is meant + for CI or for anything outside OpenBao's own runtime. diff --git a/02-encryption/aws/.terraform.lock.hcl b/02-encryption/aws/.terraform.lock.hcl new file mode 100644 index 0000000..ea715c6 --- /dev/null +++ b/02-encryption/aws/.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.57.1" + constraints = "~> 6.0" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "h1:WXndu9uKvbnmspexcbki89ZuGLt2SUyAfZ5GgQUm+QU=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} diff --git a/02-encryption/aws/env/03-backup-dev-bucket.tfvars b/02-encryption/aws/env/03-backup-dev-bucket.tfvars new file mode 100644 index 0000000..07a948b --- /dev/null +++ b/02-encryption/aws/env/03-backup-dev-bucket.tfvars @@ -0,0 +1,6 @@ +# Filename kept identical to the workspace this root's resources had inside +# 03-backup/scaleway (now 03-storage/scaleway) — DO NOT rename this file. +# local.unseal_name in main.tf derives the live KMS alias + IAM user name from +# terraform.workspace, which comes from this filename. Renaming it would make +# Terraform want to rename/recreate the KMS alias and IAM user in place. +aws_region = "eu-west-3" diff --git a/03-backup/scaleway/kms.tf b/02-encryption/aws/main.tf similarity index 85% rename from 03-backup/scaleway/kms.tf rename to 02-encryption/aws/main.tf index a9f2de1..e4f7aa5 100644 --- a/03-backup/scaleway/kms.tf +++ b/02-encryption/aws/main.tf @@ -19,15 +19,16 @@ # kms_key_id = "" # } # -# APPLY PATH — admin/local only. See version.tf: the backup CI role is S3-only -# and the CI permissions boundary denies IAM users + access keys. Apply this -# locally with SSO admin credentials (`aws sso login`). +# APPLY PATH — local or CI, via the openbao-unseal-ci role (see version.tf). # ============================================================================= data "aws_caller_identity" "current" {} data "aws_partition" "current" {} locals { + # Kept identical to the value it had inside 03-backup/scaleway (this root's + # workspace name is deliberately unchanged — see env/ — so this doesn't + # rename/recreate the live KMS alias or IAM user). unseal_name = "openbao-unseal-${terraform.workspace}" } @@ -47,10 +48,11 @@ resource "aws_iam_user" "openbao_unseal" { } } -# Scaleway-side keys carry an expiry (see iam.tf); AWS access keys do not expire -# on their own. Rotation is a manual operator action: taint this key, re-apply, -# then update the OpenBao Secret. Kept static because OpenBao must read it at -# every pod start (including unattended restarts), with no human in the loop. +# Scaleway-side keys carry an expiry (see modules/scaleway-machine-identity); +# AWS access keys do not expire on their own. Rotation is a manual operator +# action: taint this key, re-apply, then update the OpenBao Secret. Kept +# static because OpenBao must read it at every pod start (including +# unattended restarts), with no human in the loop. resource "aws_iam_access_key" "openbao_unseal" { user = aws_iam_user.openbao_unseal.name } diff --git a/02-encryption/aws/outputs.tf b/02-encryption/aws/outputs.tf new file mode 100644 index 0000000..02bf07c --- /dev/null +++ b/02-encryption/aws/outputs.tf @@ -0,0 +1,25 @@ +output "openbao_unseal_kms_key_id" { + description = "KMS key id for OpenBao's `seal \"awskms\"` stanza (kms_key_id)." + value = aws_kms_key.openbao_unseal.key_id +} + +output "openbao_unseal_kms_key_arn" { + description = "KMS key ARN of the OpenBao auto-unseal key." + value = aws_kms_key.openbao_unseal.arn +} + +output "openbao_unseal_aws_region" { + description = "AWS region the unseal key lives in (OpenBao seal `region`)." + value = var.aws_region +} + +output "openbao_unseal_access_key_id" { + description = "AWS access key id OpenBao uses to reach the unseal key (AWS_ACCESS_KEY_ID)." + value = aws_iam_access_key.openbao_unseal.id +} + +output "openbao_unseal_secret_access_key" { + description = "AWS secret access key OpenBao uses to reach the unseal key (AWS_SECRET_ACCESS_KEY). Feed into the OpenBao Secret." + sensitive = true + value = aws_iam_access_key.openbao_unseal.secret +} diff --git a/02-encryption/aws/variables.tf b/02-encryption/aws/variables.tf new file mode 100644 index 0000000..e4b9eb2 --- /dev/null +++ b/02-encryption/aws/variables.tf @@ -0,0 +1,5 @@ +variable "aws_region" { + description = "AWS region for the OpenBao auto-unseal KMS key. Defaults to the state bucket region to keep all AWS resources colocated." + type = string + default = "eu-west-3" +} diff --git a/02-encryption/aws/version.tf b/02-encryption/aws/version.tf new file mode 100644 index 0000000..ee09a24 --- /dev/null +++ b/02-encryption/aws/version.tf @@ -0,0 +1,35 @@ +terraform { + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + # New backend key for this domain — moved out of 03-storage/scaleway + # (formerly 03-backup/scaleway) via a real cross-backend `terraform state + # mv`, not a directory rename. + workspace_key_prefix = "openbao-unseal/aws" + 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: +# - local: `aws sso login` -> the provider defaults to your SSO admin session +# - CI: the openbao-unseal-ci role (01-iam/bootstrap/aws), assumed via +# GitHub OIDC, scoped to exactly the KMS/IAM actions this root +# needs +# +# Meant to run through CI once a workflow exists for it (same as +# 03-storage/scaleway, which also has no workflow yet) — not an +# admin/local-only concern anymore. It used to be: before openbao-unseal-ci +# existed, the only CI role available (terraform-state-access) was +# S3-state-only and couldn't touch KMS/IAM at all. +provider "aws" { + region = var.aws_region +} diff --git a/03-backup/scaleway/.terraform.lock.hcl b/03-backup/scaleway/.terraform.lock.hcl deleted file mode 100644 index 501b695..0000000 --- a/03-backup/scaleway/.terraform.lock.hcl +++ /dev/null @@ -1,93 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.51.0" - hashes = [ - "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", - "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", - "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", - "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", - "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", - "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", - "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", - "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", - "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", - "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", - "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", - "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", - "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", - "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", - "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.14.0" - constraints = "~> 0.12" - hashes = [ - "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", - "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", - "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", - "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", - "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", - "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", - "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", - "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", - "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", - "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", - "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", - "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", - "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", - ] -} - -provider "registry.terraform.io/infisical/infisical" { - version = "0.16.32" - constraints = "~> 0.16" - hashes = [ - "h1:IYFOE05ITZi1HVGggR5sQM5Y96e606AWbN17am0mqdo=", - "h1:KLog2xD5jntJk7Yt9ePBex+KJ8rVkpsga4hiJrmU+9Y=", - "zh:05a2d64651d01cb0e99cc914e9795d6c20a671ea54c011ed1e9be57cebae037a", - "zh:3075619c6a3c298aa0d3e6c977afa0e6b18a7c23283c36af92ca3e2bfec0dc90", - "zh:3198d5219ce5c3d2fedcc0096a6b1889bd9caac6acaa8fcac29a6f45835b1e26", - "zh:335ad3f71ada2c43b3e64d37437d2163903815d8509689ae8d0ba66c86833913", - "zh:410a5cfa062a5c61f026a1419f8bdead0bfc46fc194c801f7d55e3fe45717e82", - "zh:534cbb32ff5cc329058666c49b2148834b5e57abaa4f360d518370554f1d0543", - "zh:68c0d3eca17f23df5e60578e480eb41e3d84cc3479b9ec6ea6cd5ef7df1d3d52", - "zh:867b1f91cf34685f55f69e847224f64dff5d101e77f888d9598c334688025233", - "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8a68a03db0484b4c4dd1a41abcb767a3d9e05508a13a1018b11b180e3ea68fc1", - "zh:8deb26470ae6db94df3b2198a04ce4432c5be3cac4cf51bcea57f0968ca46ca5", - "zh:cb6f956f777e56a205bf55ef84220cf11b11ae9bee17eb560f5a3661ac3b1a9f", - "zh:dc3881aa9ade9257b7893969b08a5b84c3360588c4061f5c4a20b3e0aef5202f", - "zh:e48ee43b6ff928a46123a83f5409588b8e98c5ad7a844987d7e5f3b768d8cc4e", - "zh:ebbaae992d0b2f0c33cb4e9dac2bb636a6d26389cc9ee07254098c10b6fb0f6f", - ] -} - -provider "registry.terraform.io/scaleway/scaleway" { - version = "2.76.0" - constraints = "~> 2.0" - hashes = [ - "h1:3RImo3Jf88dXcIqmBRuO/A85jAxrAQsvYa4zhVqW1tI=", - "h1:ktRogBsJlCf3JTOTYPm9hKaPUzaps0aLwxsNfP155ns=", - "zh:13b6790dca2c91c7478d6e9cd03d84713e0b0ec001c9923d3c93bd12a1f4152f", - "zh:219582ef77ec6f27d2928684b95603b5a921001631f9eec68c14819fdbc1efea", - "zh:26bc09c7aadc49fe83a9d6af9c1d0951f93de129f90d04e5e65e1d82c8ac1914", - "zh:4015a970be3669ee009344afb269a09eaf9fe1363a12a10d3311326ba769463a", - "zh:4c1c5997ef4182e49e46ff6563581a90b5cfa30f0ec8d7d919b3dc0603ed3043", - "zh:58d91e08a8fe38ddda2c03013d1ba77ff4e21a91fef0a425575b5af7bf7f4ebc", - "zh:644a61f963483c8eba7f8427650c4956e1d8c59710a11bb2691ad8996ee14a9c", - "zh:6745d5d80006c375b104a005cfc007f90e2cb547cf9e96c886d453be3307a399", - "zh:8dcac392ca182af40b823c6721833de1325c279b1e5bdcddf1a1cf2789f3558a", - "zh:9eb284d3e9a14d4d64e2a7a865be45ec0eee32ec16c51bcc1722c0449bfb9fab", - "zh:a4eb4973cc1d9ac4911733d62f5b0e53fbc7b90112efa312918c0320966e276e", - "zh:ce58ae8014b2981bbc875bc7ad4cdeb93957370bfa4308eeb193155ee988fbec", - "zh:d3f0f3bec0a6f4759948749cdb0eb64e4415507a82c79659f1ede894f96f4976", - ] -} diff --git a/03-backup/scaleway/env/03-backup-dev-bucket.tfvars b/03-backup/scaleway/env/03-backup-dev-bucket.tfvars deleted file mode 100644 index eea8447..0000000 --- a/03-backup/scaleway/env/03-backup-dev-bucket.tfvars +++ /dev/null @@ -1,12 +0,0 @@ -bucket_name = "backup-dev-id" -velero_bucket_name = "backup-velero-dev-id" -region = "fr-par" -project_id = "6283c05b-a4c7-4f83-a75f-83adad236d54" - -ci_application_id = "4d50bbbd-e8a2-4b51-80c5-6aa47de669f7" - -# Lifecycle defaults are accepted for dev: -# retention_days = 365 -# noncurrent_version_expiry_days = 30 -# cold_storage_enabled = true -# cold_storage_transition_days = 90 diff --git a/03-backup/scaleway/iam.tf b/03-backup/scaleway/iam.tf deleted file mode 100644 index 5b7a940..0000000 --- a/03-backup/scaleway/iam.tf +++ /dev/null @@ -1,60 +0,0 @@ -resource "scaleway_iam_application" "kubernetes" { - name = "backup-k8s-${terraform.workspace}" - description = "Kubernetes workload identity for ${terraform.workspace} — grants pods object read/write on the backup bucket." -} - -resource "scaleway_iam_policy" "kubernetes" { - name = "backup-k8s-objects-${terraform.workspace}" - description = "Object-level read/write on the backup project. No bucket-level permissions — cannot delete or reconfigure the bucket." - application_id = scaleway_iam_application.kubernetes.id - - rule { - project_ids = [var.project_id] - permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite", "ObjectStorageBucketsRead", "ObjectStorageObjectsDelete"] - } -} - -# Scaleway requires every API key to carry an expiry. time_rotating keeps -# the expiry self-renewing: once the window elapses, the next apply rotates -# the key. Update the Kubernetes Secret (via ESO re-sync) after each rotation. -resource "time_rotating" "kubernetes_key" { - rotation_days = 365 -} - -resource "scaleway_iam_api_key" "kubernetes" { - application_id = scaleway_iam_application.kubernetes.id - description = "Backup workload credentials for ${terraform.workspace}. Consumed via Infisical → ESO → Kubernetes Secret." - default_project_id = var.project_id - expires_at = time_rotating.kubernetes_key.rotation_rfc3339 -} - -# Velero's own workload identity — a separate key from the OpenBao one above, -# even though the permission set is identical (Scaleway's IAM can't scope -# Object Storage below project level, so there's no tighter policy to write). -# The isolation that actually matters is the separate bucket in main.tf. -resource "scaleway_iam_application" "velero" { - name = "backup-velero-k8s-${terraform.workspace}" - description = "Kubernetes workload identity for Velero on ${terraform.workspace} — grants pods object read/write on the Velero backup bucket." -} - -resource "scaleway_iam_policy" "velero" { - name = "backup-velero-objects-${terraform.workspace}" - description = "Object-level read/write on the backup project. No bucket-level permissions — cannot delete or reconfigure a bucket." - application_id = scaleway_iam_application.velero.id - - rule { - project_ids = [var.project_id] - permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite", "ObjectStorageBucketsRead", "ObjectStorageObjectsDelete"] - } -} - -resource "time_rotating" "velero_key" { - rotation_days = 365 -} - -resource "scaleway_iam_api_key" "velero" { - application_id = scaleway_iam_application.velero.id - description = "Velero backup workload credentials for ${terraform.workspace}. Consumed via OpenBao (kv/apps/velero/scaleway-s3-credentials) → ESO → Kubernetes Secret." - default_project_id = var.project_id - expires_at = time_rotating.velero_key.rotation_rfc3339 -} diff --git a/03-backup/scaleway/main.tf b/03-backup/scaleway/main.tf deleted file mode 100644 index 968fe7b..0000000 --- a/03-backup/scaleway/main.tf +++ /dev/null @@ -1,116 +0,0 @@ -resource "scaleway_object_bucket" "backup" { - name = var.bucket_name - region = var.region - - versioning { - enabled = var.versioning_enabled - } - - lifecycle_rule { - id = "backup-retention" - enabled = true - - expiration { - days = var.retention_days - } - - # FinOps safeguard: avoids paying for stale superseded versions. - # The true backup retention policy (frequency, tiers, RTO/RPO) will be - # defined at the backup-solution layer (e.g. Velero schedule), not here. - noncurrent_version_expiration { - noncurrent_days = var.noncurrent_version_expiry_days - } - - dynamic "transition" { - for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] - content { - days = transition.value - storage_class = "GLACIER" - } - } - } - - # Deletion intentionally NOT protected at the provider level — see spec FR-014. - # Bucket deletion is a manual-only, human-operator action with admin credentials. - # Scaleway bucket policies do not support s3:DeleteBucket as an action, so the - # protection relies on two layers: (1) prevent_destroy below blocks terraform destroy, - # (2) no destroy trigger in the backup CI workflow blocks automated deletion. - - lifecycle { - prevent_destroy = true - - precondition { - condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days - error_message = "cold_storage_transition_days (${var.cold_storage_transition_days}) must be strictly less than retention_days (${var.retention_days}) when cold_storage_enabled is true. See spec FR-016." - } - } -} - -resource "scaleway_object_bucket_server_side_encryption_configuration" "backup" { - bucket = scaleway_object_bucket.backup.name - region = var.region - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} - -# Separate bucket for Velero (gitops repo platform/scaleway/velero.yml), not a -# prefix inside the bucket above: confirmed live (2026-07-28) that OpenBao's -# snapshot script does a flat `s3cmd ls` on the bucket root for its own -# retention cleanup and chokes on any object/prefix it doesn't own — Velero -# writing into a `velero/` prefix inside this same bucket broke every -# subsequent OpenBao snapshot job. Scaleway's IAM can't scope Object Storage -# permissions below project level anyway (see iam.tf), so a separate bucket — -# not a separate IAM policy — is what actually isolates the two. -resource "scaleway_object_bucket" "velero" { - name = var.velero_bucket_name - region = var.region - - versioning { - enabled = var.versioning_enabled - } - - lifecycle_rule { - id = "velero-retention" - enabled = true - - expiration { - days = var.retention_days - } - - noncurrent_version_expiration { - noncurrent_days = var.noncurrent_version_expiry_days - } - - dynamic "transition" { - for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] - content { - days = transition.value - storage_class = "GLACIER" - } - } - } - - lifecycle { - prevent_destroy = true - - precondition { - condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days - error_message = "cold_storage_transition_days (${var.cold_storage_transition_days}) must be strictly less than retention_days (${var.retention_days}) when cold_storage_enabled is true. See spec FR-016." - } - } -} - -resource "scaleway_object_bucket_server_side_encryption_configuration" "velero" { - bucket = scaleway_object_bucket.velero.name - region = var.region - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} diff --git a/03-backup/scaleway/outputs.tf b/03-backup/scaleway/outputs.tf deleted file mode 100644 index 3625704..0000000 --- a/03-backup/scaleway/outputs.tf +++ /dev/null @@ -1,71 +0,0 @@ -output "bucket_name" { - description = "Provisioned backup bucket name." - value = scaleway_object_bucket.backup.name -} - -output "bucket_region" { - description = "Region the backup bucket was created in." - value = scaleway_object_bucket.backup.region -} - -output "bucket_endpoint" { - description = "S3-compatible endpoint URL for the backup bucket." - value = "https://s3.${var.region}.scw.cloud/${var.bucket_name}" -} - -output "workload_access_key" { - description = "Public access key for the scoped Kubernetes backup workload identity. The secret key is in Infisical only." - value = scaleway_iam_api_key.kubernetes.access_key -} - -output "workload_secret_key" { - sensitive = true - description = "Public access key for the scoped Kubernetes backup workload identity. The secret key is in Infisical only." - value = scaleway_iam_api_key.kubernetes.secret_key -} - -# ── Velero (separate bucket + identity, see main.tf / iam.tf) ─────────────── - -output "velero_bucket_name" { - description = "Provisioned Velero backup bucket name." - value = scaleway_object_bucket.velero.name -} - -output "velero_workload_access_key" { - description = "Public access key for the scoped Velero Kubernetes workload identity." - value = scaleway_iam_api_key.velero.access_key -} - -output "velero_workload_secret_key" { - sensitive = true - description = "Secret access key for the scoped Velero Kubernetes workload identity." - value = scaleway_iam_api_key.velero.secret_key -} - -# ── OpenBao auto-unseal (AWS KMS, kms.tf) ──────────────────────────────────── - -output "openbao_unseal_kms_key_id" { - description = "KMS key id for OpenBao's `seal \"awskms\"` stanza (kms_key_id)." - value = aws_kms_key.openbao_unseal.key_id -} - -output "openbao_unseal_kms_key_arn" { - description = "KMS key ARN of the OpenBao auto-unseal key." - value = aws_kms_key.openbao_unseal.arn -} - -output "openbao_unseal_aws_region" { - description = "AWS region the unseal key lives in (OpenBao seal `region`)." - value = var.aws_region -} - -output "openbao_unseal_access_key_id" { - description = "AWS access key id OpenBao uses to reach the unseal key (AWS_ACCESS_KEY_ID)." - value = aws_iam_access_key.openbao_unseal.id -} - -output "openbao_unseal_secret_access_key" { - description = "AWS secret access key OpenBao uses to reach the unseal key (AWS_SECRET_ACCESS_KEY). Feed into the OpenBao Secret." - sensitive = true - value = aws_iam_access_key.openbao_unseal.secret -} diff --git a/03-backup/scaleway/variables.tf b/03-backup/scaleway/variables.tf deleted file mode 100644 index d95e5b9..0000000 --- a/03-backup/scaleway/variables.tf +++ /dev/null @@ -1,99 +0,0 @@ -variable "bucket_name" { - description = "Backup bucket name. Must include the environment name (e.g. backup-dev-id). See spec FR-015." - type = string -} - -variable "velero_bucket_name" { - description = "Velero backup bucket name. Deliberately separate from bucket_name (OpenBao's own snapshots) — sharing a bucket broke OpenBao's s3cmd-based retention cleanup (see main.tf). Must include the environment name (e.g. backup-velero-dev-id)." - type = string -} - -variable "region" { - description = "Scaleway region for the bucket." - type = string - default = "fr-par" -} - -variable "aws_region" { - description = "AWS region for the OpenBao auto-unseal KMS key (kms.tf). Defaults to the state bucket region to keep all AWS resources colocated." - type = string - default = "eu-west-3" -} - -# ── Lifecycle ──────────────────────────────────────────────────────────────── - -variable "versioning_enabled" { - description = "Enable bucket versioning. Once enabled, can only be suspended, never disabled." - type = bool - default = true -} - -variable "retention_days" { - description = "Days before current-version objects expire." - type = number - default = 365 -} - -variable "noncurrent_version_expiry_days" { - description = "Days before non-current object versions are deleted." - type = number - default = 30 -} - -variable "cold_storage_enabled" { - description = "Enable transition of objects to GLACIER storage class." - type = bool - default = true -} - -variable "cold_storage_transition_days" { - description = "Days before objects are transitioned to GLACIER. Only evaluated when cold_storage_enabled = true. Must be less than retention_days (FR-016)." - type = number - default = 90 -} - -# ── Identity ───────────────────────────────────────────────────────────────── - -variable "project_id" { - description = "Scaleway project ID for bucket and IAM resource scoping." - type = string -} - -variable "ci_application_id" { - description = "IAM application ID of the github-ci identity (from 01-iam/bootstrap/scaleway outputs). Reserved for a future bucket policy Deny statement if Scaleway adds s3:DeleteBucket support." - type = string - default = "" -} - -# ── Infisical ──────────────────────────────────────────────────────────────── - -variable "infisical_workspace_id" { - description = "Infisical project (workspace) ID." - type = string - default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" -} - -variable "infisical_env_slug" { - description = "Infisical environment slug." - type = string - default = "staging" -} - -variable "infisical_folder_path" { - description = "Infisical folder path where backup workload credentials are written." - type = string - default = "/backup" -} - -variable "infisical_client_id" { - description = "Infisical universal auth client ID. Set for local development; leave empty when using OIDC in CI." - type = string - default = "" -} - -variable "infisical_client_secret" { - description = "Infisical universal auth client secret. Set for local development; leave empty when using OIDC in CI." - type = string - sensitive = true - default = "" -} diff --git a/03-backup/scaleway/version.tf b/03-backup/scaleway/version.tf deleted file mode 100644 index 25b2a39..0000000 --- a/03-backup/scaleway/version.tf +++ /dev/null @@ -1,52 +0,0 @@ -terraform { - backend "s3" { - bucket = "id-terraform-state20260612164136440800000001" - region = "eu-west-3" - workspace_key_prefix = "backup/scaleway" - key = "terraform.tfstate" - encrypt = true - use_lockfile = true - } - - required_providers { - scaleway = { - source = "scaleway/scaleway" - version = "~> 2.0" - } - aws = { - source = "hashicorp/aws" - version = "~> 6.0" - } - infisical = { - source = "infisical/infisical" - version = "~> 0.16" - } - time = { - source = "hashicorp/time" - version = "~> 0.12" - } - } -} - -provider "scaleway" {} - -# AWS provider — used ONLY by kms.tf (OpenBao auto-unseal key + scoped IAM user). -# Credentials are resolved by the AWS SDK chain, NOT hardcoded: -# - local: `aws sso login` -> the provider defaults to your SSO admin session -# -# IMPORTANT — apply path: these AWS resources (kms:CreateKey, iam:CreateUser, -# iam:CreateAccessKey) CANNOT be applied by the backup CI workflow, which assumes -# the S3-only `tf-state-access` role (and the CI permissions boundary explicitly -# denies IAM users / access keys). kms.tf is therefore an ADMIN-APPLIED, run-it- -# locally concern. A push that changes kms.tf will fail the backup CI apply. -provider "aws" { - region = var.aws_region -} - -# provider "infisical" { -# auth = { -# # Uncomment `universal` and comment `oidc` when running terraform locally. -# # universal = {} -# oidc = {} -# } -# } diff --git a/03-storage/README.md b/03-storage/README.md new file mode 100644 index 0000000..e7cf056 --- /dev/null +++ b/03-storage/README.md @@ -0,0 +1,33 @@ +# 03-storage — what this domain is for + +The cluster's **persistence layer**. Every workload on this cluster — the +cluster itself, really — is designed to be interruptible: nodes can be +destroyed and recreated, the whole cluster can be torn down and stood back +up. What survives that and lets it come back to the same state is whatever +this domain holds. A bucket here isn't "storage for a tool"; it's part of +what makes the cluster's interruptibility survivable in the first place. + +No distinction is drawn between *who* consumes a bucket (platform tooling +like OpenBao's snapshot agent or Velero, vs. a product/application) or +*what kind* of data it holds (a backup, metrics/log storage for something +like Prometheus or Loki, business data an application owns). All of it is +"persistent state the cluster depends on across a restart," and that's the +one property this domain cares about — not the consumer, not the data's +shape. + +## The contract + +- **One bucket, one workload identity, per consumer.** Never share a bucket + across two consumers even if their permission needs are identical. +- **Deletion is an admin human action, not something Terraform or CI can do.** + Buckets here hold data the cluster needs to come back from a restart; + `prevent_destroy` plus no destroy trigger in any CI workflow are both + load-bearing, not incidental. + +## What deliberately doesn't belong here + +- The CI identity that applies this root module — a separate credential + from each bucket's own workload identity, minted in + `01-iam/bootstrap/scaleway`, not something this domain creates for itself. +- Any data that doesn't need to survive the cluster being torn down and + recreated — ephemeral scratch state has no reason to live here. diff --git a/03-storage/scaleway/.terraform.lock.hcl b/03-storage/scaleway/.terraform.lock.hcl new file mode 100644 index 0000000..fad5703 --- /dev/null +++ b/03-storage/scaleway/.terraform.lock.hcl @@ -0,0 +1,69 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.0" + constraints = "~> 0.12" + hashes = [ + "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", + "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", + "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", + "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", + "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", + "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", + "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", + "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", + "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", + "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", + "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", + "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", + "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", + ] +} + +provider "registry.terraform.io/scaleway/scaleway" { + version = "2.80.0" + constraints = "~> 2.0" + hashes = [ + "h1:0TaO/rvDAoAeRdaGuPF0JXfoy51vRTElY6TefEpyvYk=", + "h1:jez/tG7RtI3qdLm8RuWwoCCjGb6K9JfHfYiaK1KsmBo=", + "zh:190206eddd683045906734af64c1f851173634a6246ba9d93059fd21e7e8d110", + "zh:2af6f3bd53e744af40ed3adbee0e744a60cebbc799047b63f975fa868629c0b4", + "zh:417587f0db0b0069d144bfaf42af536e2a26d124eca4bcc302aa84bcf96860b9", + "zh:6bdac38a21cdf2969db613bc5e4e4ca3167b50993793319468b13188e5fb0caa", + "zh:7b41685018c3b81e5a54b14c2d311b93b71cfc25a0d5c2d961b1d20460f98b20", + "zh:840f04bfb4cb3b8fdd316726d77d9ebd3b92f0ec1fb19d63f8b1834fe3f50a2a", + "zh:870133a4129e09108ae99bde094185d9ef3b4b2af4148080c4a38e295f0887a2", + "zh:a96476ed9ba7b291a7866a6b58aeb8a9ccf944d4e2af487211ec2f1d0ff1b3f2", + "zh:be9580aa19b4df881ff246e68fcb1ba1344baba960f652dd7f9e19d08e919c52", + "zh:cb3d05c18f655853ea11087d3ac2253055cc221ed707dd98bbe8dc8fc7215b95", + "zh:d33f232ca0e23b18505f4d7c1acb65cdf2f8e60e3ab2ecfbbdf48cfc765a057f", + "zh:dd0c10d1c609f5fe880da4716d94d36ebac7fdee282a8c5a8ce0829bff4c4959", + "zh:dd331f3ff9359b83ac2a5b370771715d3aff2a9110128b7996000a8c911d31fc", + ] +} diff --git a/03-storage/scaleway/env/03-backup-dev-bucket.tfvars b/03-storage/scaleway/env/03-backup-dev-bucket.tfvars new file mode 100644 index 0000000..bab3725 --- /dev/null +++ b/03-storage/scaleway/env/03-backup-dev-bucket.tfvars @@ -0,0 +1,27 @@ +region = "fr-par" +project_id = "6283c05b-a4c7-4f83-a75f-83adad236d54" + +# Lifecycle defaults are accepted for dev: +# retention_days = 365 +# noncurrent_version_expiry_days = 30 +# cold_storage_enabled = true +# cold_storage_transition_days = 90 + +buckets = { + backup = { + bucket_name = "backup-dev-id" + identity_app_prefix = "backup-k8s" + identity_policy_prefix = "backup-k8s-objects" + identity_purpose = "grants pods object read/write on the backup bucket" + api_key_purpose = "Backup workload credentials" + api_key_consumer = "Consumed via Infisical → ESO → Kubernetes Secret" + } + velero = { + bucket_name = "backup-velero-dev-id" + identity_app_prefix = "backup-velero-k8s" + identity_policy_prefix = "backup-velero-objects" + identity_purpose = "grants pods object read/write on the Velero backup bucket" + api_key_purpose = "Velero backup workload credentials" + api_key_consumer = "Consumed via OpenBao (kv/apps/velero/scaleway-s3-credentials) → ESO → Kubernetes Secret" + } +} diff --git a/03-storage/scaleway/main.tf b/03-storage/scaleway/main.tf new file mode 100644 index 0000000..f9d97f3 --- /dev/null +++ b/03-storage/scaleway/main.tf @@ -0,0 +1,31 @@ +# Backup gets its own bucket + identity, separate from Velero's (gitops repo +# platform/scaleway/velero.yml) even though the permission set is identical: +# confirmed live (2026-07-28) that OpenBao's snapshot script does a flat +# `s3cmd ls` on the bucket root for its own retention cleanup and chokes on +# any object/prefix it doesn't own — Velero writing into a `velero/` prefix +# inside the backup bucket broke every subsequent OpenBao snapshot job. +# Scaleway's IAM can't scope Object Storage permissions below project level +# anyway, so a separate bucket — not a separate IAM policy — is what actually +# isolates the two. See variables.tf for the shape of var.buckets; add a new +# tool bucket by adding a new map entry, no new resources needed here. +module "buckets" { + source = "../../modules/scaleway-bucket-with-identity" + for_each = var.buckets + + bucket_name = each.value.bucket_name + region = var.region + lifecycle_rule_id = "${each.key}-retention" + + versioning_enabled = var.versioning_enabled + retention_days = var.retention_days + noncurrent_version_expiry_days = var.noncurrent_version_expiry_days + cold_storage_enabled = var.cold_storage_enabled + cold_storage_transition_days = var.cold_storage_transition_days + + project_id = var.project_id + identity_application_name = "${each.value.identity_app_prefix}-${terraform.workspace}" + identity_application_description = "Kubernetes workload identity for ${terraform.workspace} — ${each.value.identity_purpose}." + identity_policy_name = "${each.value.identity_policy_prefix}-${terraform.workspace}" + identity_policy_description = "Object-level read/write on the ${each.key} project. No bucket-level permissions — cannot delete or reconfigure the bucket." + api_key_description = "${each.value.api_key_purpose} for ${terraform.workspace}. ${each.value.api_key_consumer}." +} diff --git a/03-storage/scaleway/outputs.tf b/03-storage/scaleway/outputs.tf new file mode 100644 index 0000000..9b1d9d2 --- /dev/null +++ b/03-storage/scaleway/outputs.tf @@ -0,0 +1,43 @@ +output "bucket_name" { + description = "Provisioned backup bucket name." + value = module.buckets["backup"].bucket_name +} + +output "bucket_region" { + description = "Region the backup bucket was created in." + value = module.buckets["backup"].bucket_region +} + +output "bucket_endpoint" { + description = "S3-compatible endpoint URL for the backup bucket." + value = module.buckets["backup"].bucket_endpoint +} + +output "workload_access_key" { + description = "Public access key for the scoped Kubernetes backup workload identity. The secret key is in Infisical only." + value = module.buckets["backup"].access_key +} + +output "workload_secret_key" { + sensitive = true + description = "Public access key for the scoped Kubernetes backup workload identity. The secret key is in Infisical only." + value = module.buckets["backup"].secret_key +} + +# ── Velero (separate bucket + identity) ────────────────────────────────────── + +output "velero_bucket_name" { + description = "Provisioned Velero backup bucket name." + value = module.buckets["velero"].bucket_name +} + +output "velero_workload_access_key" { + description = "Public access key for the scoped Velero Kubernetes workload identity." + value = module.buckets["velero"].access_key +} + +output "velero_workload_secret_key" { + sensitive = true + description = "Secret access key for the scoped Velero Kubernetes workload identity." + value = module.buckets["velero"].secret_key +} diff --git a/03-storage/scaleway/variables.tf b/03-storage/scaleway/variables.tf new file mode 100644 index 0000000..d96670f --- /dev/null +++ b/03-storage/scaleway/variables.tf @@ -0,0 +1,59 @@ +# One bucket + one scoped workload identity per entry, via +# modules/scaleway-bucket-with-identity. Add a new tool bucket by adding a new +# map entry here — no new .tf resources needed. +variable "buckets" { + description = "Map of bucket key => config." + type = map(object({ + bucket_name = string + identity_app_prefix = string + identity_policy_prefix = string + identity_purpose = string + api_key_purpose = string + api_key_consumer = string + })) +} + +variable "region" { + description = "Scaleway region for the buckets." + type = string + default = "fr-par" +} + +# ── Lifecycle (shared across every bucket in var.buckets) ─────────────────── + +variable "versioning_enabled" { + description = "Enable bucket versioning. Once enabled, can only be suspended, never disabled." + type = bool + default = true +} + +variable "retention_days" { + description = "Days before current-version objects expire." + type = number + default = 365 +} + +variable "noncurrent_version_expiry_days" { + description = "Days before non-current object versions are deleted." + type = number + default = 30 +} + +variable "cold_storage_enabled" { + description = "Enable transition of objects to GLACIER storage class." + type = bool + default = true +} + +variable "cold_storage_transition_days" { + description = "Days before objects are transitioned to GLACIER. Only evaluated when cold_storage_enabled = true. Must be less than retention_days." + type = number + default = 90 +} + +# ── Identity ───────────────────────────────────────────────────────────────── + +variable "project_id" { + description = "Scaleway project ID for bucket and IAM resource scoping." + type = string +} diff --git a/04-dns/scaleway/version.tf b/03-storage/scaleway/version.tf similarity index 78% rename from 04-dns/scaleway/version.tf rename to 03-storage/scaleway/version.tf index 4392bd7..253aa7c 100644 --- a/04-dns/scaleway/version.tf +++ b/03-storage/scaleway/version.tf @@ -2,7 +2,7 @@ terraform { backend "s3" { bucket = "id-terraform-state20260612164136440800000001" region = "eu-west-3" - workspace_key_prefix = "dns/scaleway" + workspace_key_prefix = "backup/scaleway" key = "terraform.tfstate" encrypt = true use_lockfile = true @@ -20,5 +20,4 @@ terraform { } } -# Creds, region and project_id come from the scw CLI config (like the other roots). provider "scaleway" {} diff --git a/04-dns/scaleway/env/04-dns-scaleway.tfvars b/04-dns/scaleway/env/04-dns-scaleway.tfvars deleted file mode 100644 index aa58c58..0000000 --- a/04-dns/scaleway/env/04-dns-scaleway.tfvars +++ /dev/null @@ -1,3 +0,0 @@ -project_id = "6283c05b-a4c7-4f83-a75f-83adad236d54" - -# api_key_rotation_days defaults to 365 diff --git a/04-dns/scaleway/main.tf b/04-dns/scaleway/main.tf deleted file mode 100644 index b3c9587..0000000 --- a/04-dns/scaleway/main.tf +++ /dev/null @@ -1,36 +0,0 @@ -# IAM identity for external-dns (gitops: platform/scaleway/external-dns.yml), -# scoped to Domains & DNS zone record management only — it can read/write DNS -# records, nothing else (no domain registration/transfer, no other product). -# -# scalepack.fr was bought directly through Scaleway Domains & DNS, so its zone -# already lives in the project this key is scoped to. -resource "scaleway_iam_application" "external_dns" { - name = "external-dns-${terraform.workspace}" - description = "Kubernetes workload identity for external-dns (${terraform.workspace}) — manages DNS zone records for domains bought through Scaleway." -} - -resource "scaleway_iam_policy" "external_dns" { - name = "external-dns-${terraform.workspace}" - description = "DNS zone record read/write for the external-dns workload. No domain registration/transfer access." - application_id = scaleway_iam_application.external_dns.id - - rule { - project_ids = [var.project_id] - permission_set_names = ["DomainsDNSFullAccess"] - } -} - -# Scaleway requires every API key to carry an expiry. time_rotating keeps the -# expiry self-renewing: once the window elapses, the next apply rotates the -# key — re-copy it into OpenBao by hand afterward (no automated push yet, see -# gitops apps/external-dns-init). -resource "time_rotating" "external_dns_key" { - rotation_days = var.api_key_rotation_days -} - -resource "scaleway_iam_api_key" "external_dns" { - application_id = scaleway_iam_application.external_dns.id - description = "external-dns workload credentials (${terraform.workspace})." - default_project_id = var.project_id - expires_at = time_rotating.external_dns_key.rotation_rfc3339 -} diff --git a/04-dns/scaleway/outputs.tf b/04-dns/scaleway/outputs.tf deleted file mode 100644 index 50fd9fb..0000000 --- a/04-dns/scaleway/outputs.tf +++ /dev/null @@ -1,10 +0,0 @@ -output "workload_access_key" { - description = "Public access key for the external-dns workload identity." - value = scaleway_iam_api_key.external_dns.access_key -} - -output "workload_secret_key" { - description = "Secret key for the external-dns workload identity. Not pushed anywhere yet (terraform output) — copy into OpenBao by hand at apps/external-dns/scaleway-dns-credentials (see gitops apps/external-dns-init)." - sensitive = true - value = scaleway_iam_api_key.external_dns.secret_key -} diff --git a/04-dns/scaleway/variables.tf b/04-dns/scaleway/variables.tf deleted file mode 100644 index 817aeae..0000000 --- a/04-dns/scaleway/variables.tf +++ /dev/null @@ -1,10 +0,0 @@ -variable "project_id" { - description = "Scaleway project ID for IAM resource scoping. Must be the project scalepack.fr's DNS zone lives in." - type = string -} - -variable "api_key_rotation_days" { - description = "Rotation window (days) for the external-dns API key expiry." - type = number - default = 365 -} diff --git a/05-secrets/README.md b/05-secrets/README.md new file mode 100644 index 0000000..da0b657 --- /dev/null +++ b/05-secrets/README.md @@ -0,0 +1,12 @@ +# 05-secrets — what this domain is for + +Terraform-managed OpenBao: its auth methods, mounts, policies, and secret +content. That's it — this domain is IaC for OpenBao's own configuration, not +the identities used to reach it (`01-iam`) or anything else. + +One service lives here today: [`openbao/`](openbao/README.md), split into +two roots — `bootstrap/` mints the one AppRole identity everything else +authenticates as (human-applied, rare changes), `managed/` is OpenBao's +actual structure and secret content, reconciled via that AppRole. Read +`openbao/README.md` for the actual detail (the self-init pattern, the CI +feedback loop). diff --git a/05-secrets/openbao/bootstrap/README.md b/05-secrets/openbao/bootstrap/README.md index fab1835..65aeebc 100644 --- a/05-secrets/openbao/bootstrap/README.md +++ b/05-secrets/openbao/bootstrap/README.md @@ -12,15 +12,20 @@ implements — read it before changing anything here. ## Why its own domain, not `01-iam/` `01-iam/` provisions identities for **external** systems Terraform talks to -(AWS, Scaleway, Infisical) so CI/humans can authenticate to them — none of -those depend on the cluster existing. OpenBao only exists *because* `02-cluster/` -stood it up first, so managing it declarativement has a strictly later lifecycle -than the cluster domain. Hence `05-secrets/` — its own domain, numbered above -`02-cluster/` on purpose. +(AWS, Scaleway) so CI/humans can authenticate to them — none of +those depend on the cluster existing. OpenBao only exists *because* +`10-cluster/` stood it up first, so managing it declaratively has a strictly +later lifecycle than the cluster domain — a real exception to the "number +roughly encodes apply order" convention (see `CLAUDE.md`), since `10-cluster/` +was moved to a higher number than `05-secrets/` later in this repo's history +for unrelated reasons (freeing up low numbers), without this domain's +dependency on it changing. Hence its own domain regardless: `05-secrets/` +reconciles OpenBao's structure, `01-iam/` doesn't touch anything +cluster-dependent. `bootstrap/` (not `ci-managed/`) because creating this AppRole itself needs a human admin credential (OIDC admin token, or the root token) — the same -human/admin-applied trust-anchor pattern as `01-iam/bootstrap/aws` and +human/admin-applied trust-anchor pattern as `00-foundation/aws` and `01-iam/bootstrap/scaleway`. A later `05-secrets/ci-managed/*` root could consume this AppRole if declarative OpenBao management ever moves into CI. @@ -86,7 +91,7 @@ terraform -chdir=05-secrets/openbao/bootstrap output -raw secret_id # sensitiv ``` The consuming root's `local.auto.tfvars` (per-developer, gitignored — same -convention as `02-cluster/scaleway/local.auto.tfvars`) should hold these two +convention as `10-cluster/scaleway/local.auto.tfvars`) should hold these two values, and its own `provider "vault" {}` should authenticate via `vault_approle_auth_backend_login` (or the equivalent `VAULT_ROLE_ID`/ `VAULT_SECRET_ID` env vars the provider reads natively) rather than a static diff --git a/05-secrets/openbao/managed/README.md b/05-secrets/openbao/managed/README.md index ce79d56..3a6fbfd 100644 --- a/05-secrets/openbao/managed/README.md +++ b/05-secrets/openbao/managed/README.md @@ -55,7 +55,7 @@ that triggers a rewrite; bump it to rotate: `admin-user` is the hardcoded literal `"admin"`. - `external_dns_scaleway_dns_credentials` (`apps/external-dns/scaleway-dns-credentials`) — `SCW_ACCESS_KEY`/`SCW_SECRET_KEY` come straight from - `data.terraform_remote_state.dns_scaleway` (infra's own `04-dns/scaleway` + `data.terraform_remote_state.dns_scaleway` (infra's own `01-iam/workload/scaleway` IAM root), not a hand-copied variable — closes the "seed pending, no automated push yet" gap that root's own `outputs.tf` used to flag. - `secrets_sync_github_eso_private_key` (`apps/secrets-sync/github/eso-github-app-private-key`) @@ -66,7 +66,7 @@ that triggers a rewrite; bump it to rotate: `var.secrets_sync_github` below. - `velero_scaleway_s3_credentials` (`apps/velero/scaleway-s3-credentials`) — `SCW_ACCESS_KEY`/`SCW_SECRET_KEY` come from - `data.terraform_remote_state.backup_scaleway` (infra's own `03-backup/scaleway` + `data.terraform_remote_state.backup_scaleway` (infra's own `03-storage/scaleway` root), but a SEPARATE IAM key and bucket from OpenBao's own snapshot agent — sharing OpenBao's bucket broke its own `s3cmd`-based retention cleanup (confirmed live 2026-07-28), so Velero gets `scaleway_object_bucket.velero` @@ -120,7 +120,7 @@ convention, not automation. round-tripped) — fetch current values from OpenBao directly (an admin OIDC session can read `kv/data/apps/dex/credentials` etc.) and add them to a gitignored `local.auto.tfvars` (matches `*.auto.tfvars` in the repo - `.gitignore`, same convention as `02-cluster/scaleway/local.auto.tfvars`). + `.gitignore`, same convention as `10-cluster/scaleway/local.auto.tfvars`). Never paste secret values into shell history or commit them. - Everything else (`random_password.*`) is Terraform-generated — no diff --git a/05-secrets/openbao/managed/main.tf b/05-secrets/openbao/managed/main.tf index 9ff38b3..14d4573 100644 --- a/05-secrets/openbao/managed/main.tf +++ b/05-secrets/openbao/managed/main.tf @@ -283,7 +283,7 @@ resource "vault_kv_secret_v2" "grafana_admin" { } # SCW_ACCESS_KEY/SCW_SECRET_KEY sourced straight from infra's own IAM root -# (04-dns/scaleway) instead of a hand-copied variable — closes the "seed +# (01-iam/workload/scaleway) instead of a hand-copied variable — closes the "seed # pending, no automated push yet" gap that root's own outputs.tf flags. resource "vault_kv_secret_v2" "external_dns_scaleway_dns_credentials" { mount = vault_mount.kv.path @@ -339,7 +339,7 @@ resource "vault_kv_secret_v2" "secrets_sync_github_repo" { # The only repo+environment target today. Written as a plain resource, not a # for_each over var.secrets_sync_github.repos.*.environments — there's one # member, and it needs a special-cased merge (SCW_ACCESS_KEY/SCW_SECRET_KEY -# from infra's own 04-dns/scaleway state, not a hand-copied variable value). +# from infra's own 01-iam/workload/scaleway state, not a hand-copied variable value). # A generic for_each here would just be a single case with a fake abstraction # wrapped around it. Revisit if/when a second repo+environment target with no # remote-state merge shows up. @@ -364,7 +364,7 @@ resource "vault_kv_secret_v2" "secrets_sync_github_infrastructure_scaleway" { # that OpenBao's snapshot script does a flat `s3cmd ls` on the bucket root for # its own retention cleanup and chokes on any object/prefix it doesn't own — # Velero writing into that same bucket broke every subsequent OpenBao -# snapshot job. See 03-backup/scaleway/main.tf's scaleway_object_bucket.velero +# snapshot job. See 03-storage/scaleway/main.tf's scaleway_object_bucket.velero # and iam.tf's scaleway_iam_application.velero. resource "vault_kv_secret_v2" "velero_scaleway_s3_credentials" { mount = vault_mount.kv.path diff --git a/10-cluster/README.md b/10-cluster/README.md new file mode 100644 index 0000000..11f63cb --- /dev/null +++ b/10-cluster/README.md @@ -0,0 +1,32 @@ +# 10-cluster — what this domain is for + +Stands up a Kubernetes cluster and hands it off to ArgoCD. That's the whole +job — everything that runs on the cluster afterward, including how the +cluster's own workloads get configured, is the `gitops` repo's +responsibility, not this domain's. + +## The contract + +- **One-time bootstrapper, not an ongoing reconciler.** The cluster's + internal state and workload status are never reflected in this root's + Terraform state — once ArgoCD is up, this root's job is done until the + cluster itself needs to change shape (node pool size, region, etc.). +- **Ends by deploying ArgoCD pointed at the `gitops` repo.** That's the + handoff point: everything downstream of it is declarative GitOps, not + Terraform. +- **One variant per target.** `local/` (minikube, for local dev — still + reads bootstrap secrets from Infisical, one of the few places in this repo + that still does) and `scaleway/` (the real Kapsule cluster) follow the same + three-step shape (fetch bootstrap secrets → deploy ArgoCD → deploy the + `argocd-apps` bootstrap Application) but are otherwise independent roots, + not a shared module — the two environments diverge enough (local + minikube vs. a real managed cluster + node pool) that forcing a shared + abstraction would cost more than it'd save. + +## What deliberately doesn't belong here + +- Anything that runs *on* the cluster once ArgoCD exists — that's `gitops`. +- Numbered low (moved from `02-` to `10-`) on purpose: this domain sits + downstream of every identity/storage/encryption domain that precedes it + numerically, and freed up low numbers for domains that are actually part + of the early bootstrap chain. diff --git a/02-cluster/local/.terraform.lock.hcl b/10-cluster/local/.terraform.lock.hcl similarity index 100% rename from 02-cluster/local/.terraform.lock.hcl rename to 10-cluster/local/.terraform.lock.hcl diff --git a/02-cluster/local/env/02-cluster-local-example.tfvars b/10-cluster/local/env/02-cluster-local-example.tfvars similarity index 100% rename from 02-cluster/local/env/02-cluster-local-example.tfvars rename to 10-cluster/local/env/02-cluster-local-example.tfvars diff --git a/02-cluster/local/env/02-cluster-local.tfvars b/10-cluster/local/env/02-cluster-local.tfvars similarity index 100% rename from 02-cluster/local/env/02-cluster-local.tfvars rename to 10-cluster/local/env/02-cluster-local.tfvars diff --git a/02-cluster/local/main.tf b/10-cluster/local/main.tf similarity index 97% rename from 02-cluster/local/main.tf rename to 10-cluster/local/main.tf index 5ae0992..3f703d8 100644 --- a/02-cluster/local/main.tf +++ b/10-cluster/local/main.tf @@ -31,7 +31,7 @@ resource "kubernetes_secret" "scaleway_s3_credentials" { # AWS credentials OpenBao reads at startup for KMS auto-unseal (seal "awskms"). # Sourced here — outside OpenBao — by necessity: OpenBao can't supply the very # creds it needs to unseal itself (chicken-and-egg). Values come from the -# 03-backup/scaleway kms.tf outputs, fed via the gitignored *.auto.tfvars. +# 02-encryption/aws kms outputs, fed via the gitignored *.auto.tfvars. # Key names (access_key/secret_key) mirror scaleway-s3-credentials so the # OpenBao chart's extraSecretEnvironmentVars mapping stays uniform. resource "kubernetes_secret" "openbao_unseal_aws" { diff --git a/02-cluster/local/variables.tf b/10-cluster/local/variables.tf similarity index 86% rename from 02-cluster/local/variables.tf rename to 10-cluster/local/variables.tf index 6536812..b5600c7 100644 --- a/02-cluster/local/variables.tf +++ b/10-cluster/local/variables.tf @@ -25,14 +25,14 @@ variable "scaleway_s3_secret_key" { } variable "openbao_unseal_aws_access_key_id" { - description = "AWS access key id OpenBao uses for KMS auto-unseal. From 03-backup/scaleway output `openbao_unseal_access_key_id`." + description = "AWS access key id OpenBao uses for KMS auto-unseal. From 02-encryption/aws output `openbao_unseal_access_key_id`." type = string sensitive = true # default = "" } variable "openbao_unseal_aws_secret_access_key" { - description = "AWS secret access key OpenBao uses for KMS auto-unseal. From 03-backup/scaleway output `openbao_unseal_secret_access_key`." + description = "AWS secret access key OpenBao uses for KMS auto-unseal. From 02-encryption/aws output `openbao_unseal_secret_access_key`." type = string sensitive = true # default = "" diff --git a/02-cluster/local/version.tf b/10-cluster/local/version.tf similarity index 100% rename from 02-cluster/local/version.tf rename to 10-cluster/local/version.tf diff --git a/02-cluster/scaleway/.terraform.lock.hcl b/10-cluster/scaleway/.terraform.lock.hcl similarity index 81% rename from 02-cluster/scaleway/.terraform.lock.hcl rename to 10-cluster/scaleway/.terraform.lock.hcl index 863acb6..adf0d9b 100644 --- a/02-cluster/scaleway/.terraform.lock.hcl +++ b/10-cluster/scaleway/.terraform.lock.hcl @@ -1,6 +1,29 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/aws" { + version = "6.57.1" + hashes = [ + "h1:Mz2BVjntgeXCYLdhPCpgTBvGNPmxJBJxqq5M4r27Hc8=", + "zh:2d29e22480a81c21fb3f2fd52f9bd3ca4a82c37f3bb1b1036e881e42cddc75a1", + "zh:33aeb08e9973199b30f8a8e48a58dc67cfb6e32879f7a1c05c521899fe718f53", + "zh:37b7f977a7e7d45ad11d42958bc264873fb34573eee925915038ea05607abc9e", + "zh:41ebdcf4bcd073a01d58505a5f5118b85668de357d2d9f266926923e817a1842", + "zh:43093dfc3559c2c0467c92f48b29ae0221d52e912fce03dd77abd90806fdcc7d", + "zh:63b4252933e828d3590c0c64b827ec0f8955aa52df719fe67f45a846111fccc4", + "zh:7473b036e9f8167c7a09e4865de95d07922eae6a612b94e819d44c87ba5298de", + "zh:783c73e66bf50a74983803e1ec6d6237bae2891f9d4fd4824cfd5350121552ae", + "zh:83681e1d8d002048b76d7144cb96c8c8501dc973d1fee41b7579a46ad9eb04c2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b08b4168d4e2a81badbbe65d95f692ed3292c2b71cd00b9889a3bb7cf54c1188", + "zh:b4320ca25f4f67beebcbd6563ece2e490bd9dcb0a7e59b5d7a437ddaf53768ed", + "zh:d7f99254d6e05bac3dffae9b437c47cd807b4e66d941e56364be0a28ead418bd", + "zh:e433e91689758a341c91840cc7b5d3a3c5089004766d20659d57199b88ad8a5f", + "zh:e4c5a9b0f96a5fe2b5ed5592d4c2ac33240cf5308bcb14e52d6f2e0eb183a014", + "zh:fc4b554ae98e40e3ab6878ec9501ba2b05213d30668ee357d48b207993ffbe03", + ] +} + provider "registry.terraform.io/hashicorp/helm" { version = "3.2.0" constraints = "~> 3.0" @@ -88,30 +111,6 @@ provider "registry.terraform.io/hashicorp/null" { ] } -provider "registry.terraform.io/infisical/infisical" { - version = "0.16.28" - constraints = "~> 0.16" - hashes = [ - "h1:3u5WxYFLl+DUSqoma4DEY/DYbN7fMt6yTDcrkpFQz5Q=", - "h1:BvcG6jgReLptymYOXetIEpaZBLA2rsbexEl7THzENM0=", - "zh:09d25451a3ebbb1e9ba5a73f29f6c9dfd2f890c3966ec66af401969164b42a67", - "zh:2e1eac9f42920336694baaa83e1e0ae252d4fded21d3c4ce874831c8ca9575b4", - "zh:306b370bdfb18ffb0d819c613fb2bc3377037a9be47a70ecdd6cc2e83bdeff14", - "zh:63d6291c6a81fe9d1469ff86d4dc2b6e8fbf93e255d0f3d58f98cfdc6817fc98", - "zh:66f01a8234b079cb3e9e80eefa2ee2d107191632d03db84fa5da42ad8e925261", - "zh:75794f043a2320a67706fe545f488d4cbaaccae844622b2a80967198f39bf226", - "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a66be07dd80c836b7bd44cc1818b35156665b50011209711ce264e4320c6ef9d", - "zh:ae895218e7616f439cc03ce98d1d9179741df89abb72520d70773fd427adf320", - "zh:bbb50efbfb6d1dd29013329fd38ab72e5b7b4694b840aae84e550f1991cee1a1", - "zh:c25cfe7ecf55201bd2433dc5d4dfe3bf608c75f3c22a3ec3f146636fef75a9dd", - "zh:d54fc418ff11ffd11fce7b02c77735686ee4efcb2222c9f62dd85d5a275cda4b", - "zh:e38e93b6f72204f37475b692c3c9878b62ebdd5ee2eabac90b9368f6651c3f88", - "zh:ed9a4625835728b0d6d8fa82be0cf6d71224654666223e0e74f49471ba12e90e", - "zh:f85e74fae43fb35182c99b2a1cc57cf40aa831218d359fe48f66e66b89ec5f9b", - ] -} - provider "registry.terraform.io/scaleway/scaleway" { version = "2.76.0" constraints = "~> 2.0" diff --git a/02-cluster/scaleway/argocd.tf b/10-cluster/scaleway/argocd.tf similarity index 100% rename from 02-cluster/scaleway/argocd.tf rename to 10-cluster/scaleway/argocd.tf diff --git a/02-cluster/scaleway/env/02-cluster-staging.tfvars b/10-cluster/scaleway/env/02-cluster-staging.tfvars similarity index 100% rename from 02-cluster/scaleway/env/02-cluster-staging.tfvars rename to 10-cluster/scaleway/env/02-cluster-staging.tfvars diff --git a/02-cluster/scaleway/main.tf b/10-cluster/scaleway/main.tf similarity index 97% rename from 02-cluster/scaleway/main.tf rename to 10-cluster/scaleway/main.tf index 12fcf0f..9f129d6 100644 --- a/02-cluster/scaleway/main.tf +++ b/10-cluster/scaleway/main.tf @@ -86,7 +86,7 @@ resource "kubernetes_secret" "scaleway_s3_credentials" { # AWS credentials OpenBao reads at startup for KMS auto-unseal (seal "awskms"). # Sourced here — outside OpenBao — by necessity: OpenBao can't supply the very # creds it needs to unseal itself (chicken-and-egg). Values come from the -# 03-backup/scaleway kms.tf outputs, fed via the gitignored *.auto.tfvars. +# 02-encryption/aws kms outputs, fed via the gitignored *.auto.tfvars. # Key names (access_key/secret_key) mirror scaleway-s3-credentials so the # OpenBao chart's extraSecretEnvironmentVars mapping stays uniform. resource "kubernetes_secret" "openbao_unseal_aws" { diff --git a/02-cluster/scaleway/outputs.tf b/10-cluster/scaleway/outputs.tf similarity index 100% rename from 02-cluster/scaleway/outputs.tf rename to 10-cluster/scaleway/outputs.tf diff --git a/02-cluster/scaleway/variables.tf b/10-cluster/scaleway/variables.tf similarity index 93% rename from 02-cluster/scaleway/variables.tf rename to 10-cluster/scaleway/variables.tf index c100731..689971d 100644 --- a/02-cluster/scaleway/variables.tf +++ b/10-cluster/scaleway/variables.tf @@ -65,14 +65,14 @@ variable "scaleway_s3_secret_key" { } variable "openbao_unseal_aws_access_key_id" { - description = "AWS access key id OpenBao uses for KMS auto-unseal. From 03-backup/scaleway output `openbao_unseal_access_key_id`." + description = "AWS access key id OpenBao uses for KMS auto-unseal. From 02-encryption/aws output `openbao_unseal_access_key_id`." type = string sensitive = true # default = "" } variable "openbao_unseal_aws_secret_access_key" { - description = "AWS secret access key OpenBao uses for KMS auto-unseal. From 03-backup/scaleway output `openbao_unseal_secret_access_key`." + description = "AWS secret access key OpenBao uses for KMS auto-unseal. From 02-encryption/aws output `openbao_unseal_secret_access_key`." type = string sensitive = true # default = "" diff --git a/02-cluster/scaleway/version.tf b/10-cluster/scaleway/version.tf similarity index 100% rename from 02-cluster/scaleway/version.tf rename to 10-cluster/scaleway/version.tf diff --git a/CLAUDE.md b/CLAUDE.md index a041991..d7fe52c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,16 +34,15 @@ The `.terraform.lock.hcl` in each root must cover **both** `darwin_arm64` (local `mise run lock` is equivalent to: ```bash -terraform -chdir=00-remote_state providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=00-foundation/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=01-iam/bootstrap/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=01-iam/bootstrap/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=01-iam/bootstrap/infisical providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=01-iam/ci-managed/aws-state-access providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=02-cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=02-cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=03-backup/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=04-dns/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=01-iam/workload/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=02-encryption/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=03-storage/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=05-secrets/openbao/bootstrap providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=10-cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=10-cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 ``` Commit the updated lock files alongside the version change. @@ -51,54 +50,112 @@ Commit the updated lock files alongside the version change. ## Architecture Terraform roots are organized by **domain** — the top-level folder is a numeric -**pseudo-ID** for what that domain owns (`00-remote_state`, `01-iam`, -`02-cluster`). The number encodes apply order / blast-radius across domains -(`00` first, built on by `01`, then `02`). The shared S3 state bucket -(`00-remote_state`) holds every root's remote state. - -Within a domain, sub-folders split roots by the **second axis: lifecycle / who -applies them** — `bootstrap/` = human/admin-applied trust anchors (rare changes, -need admin creds), `ci-managed/` = roots minted BY the CI (GitOps, capped by the -permissions boundary). A domain with a single root is flattened (the domain -folder *is* the root, e.g. `00-remote_state`). +**pseudo-ID**, generally named after the domain's function rather than what it +literally owns (`00-foundation`, `01-iam`, `02-encryption`...). The number +roughly encodes apply order / blast-radius +across domains, though it's a convention, not something any tooling enforces. +Gaps in the sequence (`04`, `06`-`09`) are deliberate — room for future domains +without a renumbering cascade; `10-cluster` in particular was moved up from +`02-` on purpose to free up low numbers for domains like `02-encryption`. + +The second path segment is the **cloud provider** a root targets (`aws`, +`scaleway`) — a domain with only one provider still nests under it (e.g. +`00-foundation/aws/`), so a second provider can be added later without a +restructure. Within `01-iam/`, there's a further split by **lifecycle / who +applies**: `bootstrap/` = human/admin-applied trust anchors (rare changes, need +admin creds), `workload/` = admin-applied identities that aren't trust anchors +(scoped credentials for a specific in-cluster workload, e.g. external-dns). ``` -modules/ # reusable Terraform modules (empty for now) -00-remote_state/ # domain: Terraform state backend — the shared AWS - # S3 bucket holding every root's remote state (admin-applied) -01-iam/ # domain: IAM identities & grants - bootstrap/ # human-applied trust anchors - aws/ # GitHub-OIDC → AWS: OIDC provider + role-creator role - # + permissions boundary + CI grant - scaleway/ # Scaleway CI identity (IAM app + project policy + static API key) - infisical/ # Infisical CI identity (keyless GitHub-OIDC → Infisical) - ci-managed/ # minted BY the CI, capped by the boundary - aws-state-access/ # org-wide tf-state-access role (the first CI-minted role) -02-cluster/ # domain: the Kubernetes platform +modules/ + scaleway-machine-identity/ # shared: IAM application + map of policies + + # rotating API key. Used by every Scaleway + # identity below instead of copy-pasted HCL. + scaleway-bucket-with-identity/ # shared: one bucket + one scoped identity + # (wraps scaleway-machine-identity). Used by + # 03-storage/scaleway's for_each over + # var.buckets — add a tool bucket there by + # adding a map entry, not new resources. +00-foundation/ + aws/ # domain: the base AWS layer everything else + # depends on — the S3 bucket holding every + # root's remote state, PLUS the GitHub OIDC + # provider + the one role (terraform-state- + # access) every workflow in this repo assumes + # for state R/W. Admin-applied. +01-iam/ # domain: IAM identities & grants (non-AWS) + bootstrap/ + aws/ # CI role scoped to 02-encryption/aws only + # (KMS + IAM user/access-key CRUD, plus the + # same state-bucket policy terraform-state- + # access uses) — see that section below + scaleway/ # Scaleway CI identity (github-ci: IAM app + + # 2 policies + static API key) + workload/ + scaleway/ # external-dns workload identity (DNS zone + # record R/W only — no bucket, no DNS zone + # resource managed here) +02-encryption/ + aws/ # domain: AWS KMS key + dedicated IAM user for + # OpenBao's auto-unseal. Standalone rather than + # folded into 05-secrets/openbao (different + # provider, different pattern — an AWS key/user + # pair, not an OpenBao/Vault-provider resource) +03-storage/ + scaleway/ # domain: Scaleway tool buckets + their scoped + # identities (backup, velero today; home for + # future tool buckets) +05-secrets/ + openbao/ # domain: OpenBao itself (bootstrap/ + managed/, + # see that directory) — untouched by the + # 2026-07-30 buckets/IAM consolidation +10-cluster/ # domain: the Kubernetes platform (moved up from + # 02- to free up low numbers for future domains) local/ # minikube — local dev and debugging. Local backend (local files) scaleway/ # Scaleway Kapsule cluster + ArgoCD bootstrap (homelab; WIP) ``` -**The dependency spine** runs strictly forward: `00-remote_state` (bucket) → -`01-iam/bootstrap/aws` (the trust anchor that lets CI apply anything) → -`01-iam/ci-managed/*` (roles the anchor mints) → `02-cluster/*`. `bootstrap/aws` -is the root of trust — nothing CI-applied can exist before it. +**The dependency spine** runs forward: `00-foundation/aws` (bucket + CI's AWS +role) → everything else, since every other root's backend points at that +bucket. `terraform-state-access` (the role every workflow assumes by default) +is scoped to exactly S3 read/write on the state bucket — no IAM-management +capability at all. Only one other role exists, `01-iam/bootstrap/aws`'s +`openbao-unseal-ci`, and it's narrowly scoped too: KMS + IAM user/access-key +CRUD under `/openbao/`, nothing broader, plus (via a `terraform_remote_state` +lookup, not a hardcoded ARN) the same state-bucket policy +`terraform-state-access` uses, so it can read/write the bucket for its own +backend. (This two-role setup replaced a larger system, retired 2026-07-30: the +original `01-iam/bootstrap/aws` + `01-iam/ci-managed/aws-state-access` together +built a "CI can safely mint further IAM roles" mechanism — a permissions +boundary + a policy letting the CI role create/attach *any* other role under a +managed path — whose only actual consumer was minting the one role that did +state R/W. Once that role's job narrowed to exactly "read/write this bucket," +there was no general IAM-management capability left to guard against +escalating, so the guardrail system went with it. `02-encryption/aws` needing +its own broader-than-S3 AWS rights later is why `01-iam/bootstrap/aws` came +back — but scoped to just that one domain's resource types, not "create any +role.") **Backend keys are decoupled from paths.** Each root pins its own -`workspace_key_prefix` in `version.tf` (e.g. `01-iam/ci-managed/aws-state-access` -still uses prefix `s3-lister-role`), and the workspace name comes from the -`env/.tfvars` filename — **neither is tied to the directory**. The -restructure was a pure `git mv` with no state migration. Don't "fix" a prefix or -rename a tfvars file to match its new path unless you also migrate the state -(renaming the tfvars file changes the workspace, hence the state key). This is -why some workspace names look dated (e.g. `aws-state-access` still uses the -`00-remote-state-iam` workspace). - -### `02-cluster/*` +`workspace_key_prefix` in `version.tf`, and the workspace name comes from the +`env/.tfvars` filename — **neither is tied to the directory**. This means +moving a root to a new directory is a pure `git mv` with **zero state +migration**, as long as you don't also rename the tfvars file or touch +`workspace_key_prefix`. Several roots have been moved this way and deliberately +keep a prefix/workspace name that no longer matches their path (e.g. +`01-iam/bootstrap/scaleway` still uses prefix `github-ci`; `02-encryption/aws` +still uses the workspace name `03-backup-dev-bucket`, inherited from before its +resources were extracted from `03-storage/scaleway` — required there, since +`local.unseal_name` in that root derives the live KMS alias + IAM user name from +`terraform.workspace`, so renaming the workspace would rename/recreate them). +Don't "fix" a prefix or rename a tfvars file to match its new path unless you +also migrate the state. + +### `10-cluster/*` Terraform here is only a **one-time bootstrapper** — everything after ArgoCD is up lives in the `gitops` repo. The cluster internal state nor status will be reflected in the terraform state. -### `02-cluster/local/` +### `10-cluster/local/` Warning : This environment expect you an accessible local kubernetes cluster access, likely configured within your ~/.kube/config. This is automatically handled via `mise run dev` @@ -107,31 +164,33 @@ Two-step, one-time bootstrap: 2. Deploy **ArgoCD** via Helm with the admin bcrypt password hash from Infisical (pre-hashed to prevent Terraform drift). 3. Deploy the **argocd-apps bootstrap** Application, pointing ArgoCD at `https://github.com/IntegratedDynamic/gitops.git`. ArgoCD then self-manages all further cluster state from that separate GitOps repo. -### `02-cluster/scaleway/` +### `10-cluster/scaleway/` Same bootstrap pattern as `local/`, but with the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) instead. -### `00-remote_state/` +### `00-foundation/aws/` -The shared org S3 bucket holding **every** root's remote state (built on `terraform-aws-modules/s3-bucket`: versioning, SSE, public-access block, TLS-only). Chicken-and-egg: its own state lives in the bucket it creates (one-time local-state bootstrap — see its README). Applied by an admin. +The shared org S3 bucket holding **every** root's remote state (built on `terraform-aws-modules/s3-bucket`: versioning, SSE, public-access block, TLS-only). Chicken-and-egg: its own state lives in the bucket it creates (one-time local-state bootstrap — see its README). Also creates the GitHub OIDC provider and the one role, `terraform-state-access` (via `terraform-aws-modules/iam`), every GitHub Actions workflow in this repo assumes — trust scoped to `repo:IntegratedDynamic/infrastructure:*`, policy scoped to exactly S3 list/get/put/delete on the state bucket, nothing else. Wired to CI via `vars.AWS_TERRAFORM_ROLE_ARN`. Applied by an admin (this root creates the very identity CI would otherwise need to apply it). See its README. -### `01-iam/ci-managed/aws-state-access/` +### `01-iam/bootstrap/aws/` -Org-wide Terraform-state **access** IAM **role created BY the CI** (the first role minted by `01-iam/bootstrap/aws`'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 role (`openbao-unseal-ci`) scoped to exactly what `02-encryption/aws` needs: full CRUD (create/read/update/**destroy**) on a KMS key + alias (necessarily unscoped by resource — KMS key IDs are random, `kms:CreateKey` has no resource-level permission support) and on an IAM user + access key scoped to the `/openbao/` path (matching that root's `aws_iam_user` path). Also attaches the same state-bucket policy `terraform-state-access` uses — read via a `data.terraform_remote_state` lookup at `00-foundation/aws`, not hardcoded — so this role can read/write the bucket for its own backend too. OIDC-trusted, scoped to `repo:IntegratedDynamic/infrastructure:*`. No general IAM-management capability (can't create roles, can't touch anything outside `/openbao/`), unlike the original `01-iam/bootstrap/aws` this replaces the *name* of but not the *design* of. ### `01-iam/bootstrap/scaleway/` -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 `01-iam/bootstrap/scaleway/README.md`. +Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + two policies (`Kubernetes`/`VPC`/`PrivateNetworks` FullAccess + `IPAMReadOnly` for cluster management; Object Storage + IAM application/policy management for the storage domain's CI workflow) + an API key, via `modules/scaleway-machine-identity`. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are set manually via `gh secret set` (Infisical, which used to carry this, is retired). Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `01-iam/bootstrap/scaleway/README.md`. -### `01-iam/bootstrap/infisical/` +### `01-iam/workload/scaleway/` -The **keyless GitHub-OIDC → Infisical** counterpart to `bootstrap/scaleway`: a Infisical machine identity + OIDC auth trusting GitHub Actions tokens, so the composite action can mint a short-lived Infisical token (no static Infisical secret) to read the secrets cluster bootstraps need. See `01-iam/bootstrap/infisical/README.md`. +One `module "identities" { for_each = var.identities }` block (via `modules/scaleway-machine-identity`, single policy per identity — this domain is for simple scoped workload credentials, not CI trust anchors) — `external-dns` today (Scaleway `DomainsDNSFullAccess`, scoped to the project scalepack.fr's zone lives in; no DNS zone/record resource is Terraform-managed here, this root exists purely to provision the identity). Add a future workload identity by adding a map entry to `var.identities`, no new `.tf` resources. `workload_access_key`/`workload_secret_key` outputs stay pinned to `external-dns` specifically (05-secrets/openbao/managed's `terraform_remote_state` reads them) — new identities' keys come from the generic `access_keys`/`secret_keys` map outputs instead. Moved here from `04-dns/scaleway` since it owns no bucket and isn't a CI trust anchor. -### `01-iam/bootstrap/aws/` +### `02-encryption/aws/` + +AWS KMS key + a single-purpose IAM user for OpenBao's `seal "awskms"` auto-unseal (OpenBao runs on Scaleway Kapsule, not AWS, so there's no instance profile to lean on — a static AWS access key is required). Standalone domain rather than folded into `05-secrets/openbao/` (different provider/pattern — plain AWS resources, not an OpenBao/Vault-provider resource) or left in `03-storage/scaleway` (not a bucket, not really "storage"). Managed via the `01-iam/bootstrap/aws` role above — see the root's `main.tf` header comment for the full apply-path rationale. Moved here from `06-openbao-unseal/aws` to free up a low domain number. -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. `01-iam/ci-managed/aws-state-access`). Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `01-iam/bootstrap/aws/README.md`. +### `03-storage/scaleway/` -**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 `01-iam/bootstrap/aws/iam-ci.tf`. +Scaleway tool buckets + their scoped identities: `backup` (OpenBao's own raft snapshots) and `velero` (Kubernetes backups), each with its own bucket AND its own workload identity — kept as separate buckets/identities because Velero writing into a shared bucket broke OpenBao's `s3cmd`-based retention cleanup (confirmed live 2026-07-28). One `module "buckets"` block with `for_each = var.buckets` (via `modules/scaleway-bucket-with-identity`) instantiates every bucket in `main.tf` — add a future tool bucket by adding a map entry to `var.buckets`, no new `.tf` resources. Renamed from `03-backup/scaleway` (which also held the OpenBao unseal KMS resources, now `02-encryption/aws`). ## Conventions diff --git a/README.md b/README.md index 2ae50ea..a24c0aa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This infrastructure contains the actual ScalePack infrastructure. -All remote based environment states are stored in the S3 bucket provisioned by `./00-remote_state`. Check `version.tf` files from any root module in this repository for an example, stored under eu-west-3 region. +All remote based environment states are stored in the S3 bucket provisioned by `./00-foundation/aws`. Check `version.tf` files from any root module in this repository for an example, stored under eu-west-3 region. ## Forking diff --git a/mise.toml b/mise.toml index ebe27ed..82b7ff3 100644 --- a/mise.toml +++ b/mise.toml @@ -26,13 +26,13 @@ kubectl wait --for=condition=Ready nodes --all --timeout=120s """ [tasks.local-init] -dir = "02-cluster/local" +dir = "10-cluster/local" run = """ terraform init """ [tasks.local-apply] -dir = "02-cluster/local" +dir = "10-cluster/local" run = """ ws=$(terraform workspace show) if [ "$ws" = "default" ]; then @@ -60,14 +60,13 @@ run = "minikube delete" [tasks.lock] description = "Re-generate all .terraform.lock.hcl files for darwin_arm64 + linux_amd64" run = """ -terraform -chdir=00-remote_state providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=00-foundation/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=01-iam/bootstrap/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=01-iam/bootstrap/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=01-iam/bootstrap/infisical providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=01-iam/ci-managed/aws-state-access providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=02-cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=02-cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=03-backup/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=04-dns/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=01-iam/workload/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=02-encryption/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=03-storage/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 terraform -chdir=05-secrets/openbao/bootstrap providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=10-cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=10-cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/modules/scaleway-bucket-with-identity/main.tf b/modules/scaleway-bucket-with-identity/main.tf new file mode 100644 index 0000000..4a86651 --- /dev/null +++ b/modules/scaleway-bucket-with-identity/main.tf @@ -0,0 +1,78 @@ +resource "scaleway_object_bucket" "this" { + name = var.bucket_name + region = var.region + + versioning { + enabled = var.versioning_enabled + } + + lifecycle_rule { + id = var.lifecycle_rule_id + enabled = true + + expiration { + days = var.retention_days + } + + noncurrent_version_expiration { + noncurrent_days = var.noncurrent_version_expiry_days + } + + dynamic "transition" { + for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] + content { + days = transition.value + storage_class = "GLACIER" + } + } + } + + # Deletion intentionally NOT protected at the provider level. Bucket + # deletion is a manual-only, human-operator action with admin credentials. + # Scaleway bucket policies do not support s3:DeleteBucket as an action, so + # the protection relies on two layers: (1) prevent_destroy below blocks + # terraform destroy, (2) no destroy trigger in any CI workflow that applies + # this root. + lifecycle { + prevent_destroy = true + + precondition { + condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days + error_message = "cold_storage_transition_days (${var.cold_storage_transition_days}) must be strictly less than retention_days (${var.retention_days}) when cold_storage_enabled is true." + } + } +} + +resource "scaleway_object_bucket_server_side_encryption_configuration" "this" { + bucket = scaleway_object_bucket.this.name + region = var.region + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +module "identity" { + source = "../scaleway-machine-identity" + + application_name = var.identity_application_name + application_description = var.identity_application_description + + policies = { + default = { + name = var.identity_policy_name + description = var.identity_policy_description + rules = [ + { + project_ids = [var.project_id] + permission_set_names = var.identity_permission_set_names + } + ] + } + } + + project_id = var.project_id + api_key_description = var.api_key_description +} diff --git a/modules/scaleway-bucket-with-identity/outputs.tf b/modules/scaleway-bucket-with-identity/outputs.tf new file mode 100644 index 0000000..2374df5 --- /dev/null +++ b/modules/scaleway-bucket-with-identity/outputs.tf @@ -0,0 +1,25 @@ +output "bucket_name" { + description = "Provisioned bucket name." + value = scaleway_object_bucket.this.name +} + +output "bucket_region" { + description = "Region the bucket was created in." + value = scaleway_object_bucket.this.region +} + +output "bucket_endpoint" { + description = "S3-compatible endpoint URL for the bucket." + value = "https://s3.${var.region}.scw.cloud/${var.bucket_name}" +} + +output "access_key" { + description = "Public access key for the scoped workload identity." + value = module.identity.access_key +} + +output "secret_key" { + description = "Secret access key for the scoped workload identity." + sensitive = true + value = module.identity.secret_key +} diff --git a/modules/scaleway-bucket-with-identity/variables.tf b/modules/scaleway-bucket-with-identity/variables.tf new file mode 100644 index 0000000..cb5e4b4 --- /dev/null +++ b/modules/scaleway-bucket-with-identity/variables.tf @@ -0,0 +1,89 @@ +variable "bucket_name" { + description = "Bucket name. Must include the environment name (e.g. backup-dev-id)." + type = string +} + +variable "lifecycle_rule_id" { + description = "ID of the bucket's lifecycle_rule block. Distinct per bucket only because Scaleway requires each bucket's rule to have an id." + type = string + default = "retention" +} + +variable "region" { + description = "Scaleway region for the bucket." + type = string + default = "fr-par" +} + +# ── Lifecycle ──────────────────────────────────────────────────────────────── + +variable "versioning_enabled" { + description = "Enable bucket versioning. Once enabled, can only be suspended, never disabled." + type = bool + default = true +} + +variable "retention_days" { + description = "Days before current-version objects expire." + type = number + default = 365 +} + +variable "noncurrent_version_expiry_days" { + description = "Days before non-current object versions are deleted." + type = number + default = 30 +} + +variable "cold_storage_enabled" { + description = "Enable transition of objects to GLACIER storage class." + type = bool + default = true +} + +variable "cold_storage_transition_days" { + description = "Days before objects are transitioned to GLACIER. Only evaluated when cold_storage_enabled = true. Must be less than retention_days." + type = number + default = 90 +} + +# ── Identity (modules/scaleway-machine-identity) ──────────────────────────── + +variable "project_id" { + description = "Scaleway project ID for bucket and IAM resource scoping." + type = string +} + +variable "identity_application_name" { + description = "Name of the IAM application backing this bucket's workload identity." + type = string +} + +variable "identity_application_description" { + description = "Description of the IAM application." + type = string + default = "" +} + +variable "identity_policy_name" { + description = "Name of the IAM policy granting this identity access to the bucket." + type = string +} + +variable "identity_policy_description" { + description = "Description of the IAM policy." + type = string + default = "" +} + +variable "identity_permission_set_names" { + description = "Scaleway permission sets granted to the identity, project-scoped (Scaleway IAM can't scope Object Storage permissions below project level, so this is the tightest available grant)." + type = list(string) + default = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite", "ObjectStorageBucketsRead", "ObjectStorageObjectsDelete"] +} + +variable "api_key_description" { + description = "Description of the generated API key." + type = string + default = "" +} diff --git a/modules/scaleway-bucket-with-identity/version.tf b/modules/scaleway-bucket-with-identity/version.tf new file mode 100644 index 0000000..7d4ee4c --- /dev/null +++ b/modules/scaleway-bucket-with-identity/version.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} diff --git a/04-dns/scaleway/.terraform.lock.hcl b/modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl similarity index 62% rename from 04-dns/scaleway/.terraform.lock.hcl rename to modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl index 1b73528..84d718b 100644 --- a/04-dns/scaleway/.terraform.lock.hcl +++ b/modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl @@ -1,35 +1,11 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. -provider "registry.terraform.io/hashicorp/aws" { - version = "6.55.0" - hashes = [ - "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=", - "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd", - "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966", - "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6", - "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67", - "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881", - "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5", - "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45", - "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899", - "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568", - "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34", - "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f", - "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1", - "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a", - "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51", - ] -} - provider "registry.terraform.io/hashicorp/time" { version = "0.14.0" constraints = "~> 0.12" hashes = [ "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", - "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", @@ -51,7 +27,6 @@ provider "registry.terraform.io/scaleway/scaleway" { constraints = "~> 2.0" hashes = [ "h1:LcxR9sNfLg0lMcPGni34/aFAIL0A3x06mZ8Lhg9LYtk=", - "h1:ppfv4S+zXnjoESuhtHx/wE3GZO1sIn+d7c54uouZQrs=", "zh:01ec419b6692bd0ee6c1b64c6a7a9823bc2b757ea6d37958ebf7bf0057ef8083", "zh:025a88b258bd3283439c3380e27edefb4529af875a281e111e43bff9ecc28244", "zh:1ce6ab69fbf08ae529dc7978ccaad00a2b13752f086372f4916d063cf65f5ae8", diff --git a/modules/scaleway-machine-identity/examples/basic/main.tf b/modules/scaleway-machine-identity/examples/basic/main.tf new file mode 100644 index 0000000..f7135c6 --- /dev/null +++ b/modules/scaleway-machine-identity/examples/basic/main.tf @@ -0,0 +1,71 @@ +# Validation harness only — `terraform init` + `validate` + `plan` to sanity +# check the module's schema in isolation. Never `apply` this: it has no +# backend and is not a real root. + +terraform { + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} + +provider "scaleway" {} + +# Single-policy case (kubernetes/velero/external-dns shape). +module "single_policy" { + source = "../.." + + application_name = "example-single" + project_id = "00000000-0000-0000-0000-000000000000" + + policies = { + default = { + name = "example-single-objects" + rules = [ + { + project_ids = ["00000000-0000-0000-0000-000000000000"] + permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"] + } + ] + } + } +} + +# Dual-policy, mixed project+org rule case (github-ci shape). +module "dual_policy" { + source = "../.." + + application_name = "example-dual" + project_id = "00000000-0000-0000-0000-000000000000" + + policies = { + cluster_management = { + name = "example-cluster-management" + rules = [ + { + project_ids = ["00000000-0000-0000-0000-000000000000"] + permission_set_names = ["KubernetesFullAccess"] + } + ] + } + backup_management = { + name = "example-backup-management" + rules = [ + { + project_ids = ["00000000-0000-0000-0000-000000000000"] + permission_set_names = ["ObjectStorageBucketsRead", "ObjectStorageBucketsWrite"] + }, + { + organization_id = "00000000-0000-0000-0000-000000000000" + permission_set_names = ["IAMApplicationManager", "IAMPolicyManager"] + } + ] + } + } +} diff --git a/modules/scaleway-machine-identity/main.tf b/modules/scaleway-machine-identity/main.tf new file mode 100644 index 0000000..4077b99 --- /dev/null +++ b/modules/scaleway-machine-identity/main.tf @@ -0,0 +1,35 @@ +resource "scaleway_iam_application" "this" { + name = var.application_name + description = var.application_description +} + +resource "scaleway_iam_policy" "this" { + for_each = var.policies + + name = each.value.name + description = each.value.description + application_id = scaleway_iam_application.this.id + + dynamic "rule" { + for_each = each.value.rules + content { + project_ids = rule.value.project_ids + organization_id = rule.value.organization_id + permission_set_names = rule.value.permission_set_names + } + } +} + +# Scaleway requires every API key to carry an expiry. time_rotating keeps it +# self-renewing: the timestamp holds steady until the window elapses, then +# the next apply pushes it forward and rotates the key. +resource "time_rotating" "this" { + rotation_days = var.api_key_rotation_days +} + +resource "scaleway_iam_api_key" "this" { + application_id = scaleway_iam_application.this.id + description = var.api_key_description + default_project_id = var.project_id + expires_at = time_rotating.this.rotation_rfc3339 +} diff --git a/modules/scaleway-machine-identity/outputs.tf b/modules/scaleway-machine-identity/outputs.tf new file mode 100644 index 0000000..36d9941 --- /dev/null +++ b/modules/scaleway-machine-identity/outputs.tf @@ -0,0 +1,20 @@ +output "application_id" { + description = "IAM application ID backing this machine identity." + value = scaleway_iam_application.this.id +} + +output "access_key" { + description = "Public access key identifier for the generated API key." + value = scaleway_iam_api_key.this.access_key +} + +output "secret_key" { + description = "Secret access key for the generated API key." + sensitive = true + value = scaleway_iam_api_key.this.secret_key +} + +output "policy_ids" { + description = "Map of policy-purpose slug => scaleway_iam_policy ID." + value = { for k, p in scaleway_iam_policy.this : k => p.id } +} diff --git a/modules/scaleway-machine-identity/variables.tf b/modules/scaleway-machine-identity/variables.tf new file mode 100644 index 0000000..3bfc195 --- /dev/null +++ b/modules/scaleway-machine-identity/variables.tf @@ -0,0 +1,46 @@ +variable "application_name" { + description = "Name of the Scaleway IAM application (the machine identity itself)." + type = string +} + +variable "application_description" { + description = "Description of the IAM application." + type = string + default = "" +} + +# Keyed by an arbitrary policy-purpose slug. One entry covers the common case +# (a single project-scoped rule); an identity that needs more than one policy +# object (e.g. one project-scoped + one org-scoped, or two independently +# named policies) adds more entries — this is a map of policies, not a +# single policy with a rule list, specifically so call sites with more than +# one `scaleway_iam_policy` today can be wrapped as a pure address rename. +variable "policies" { + description = "Map of policy-purpose slug => policy definition. Each becomes its own scaleway_iam_policy, supporting one or more rule blocks." + type = map(object({ + name = string + description = optional(string, "") + rules = list(object({ + project_ids = optional(list(string)) + organization_id = optional(string) + permission_set_names = list(string) + })) + })) +} + +variable "project_id" { + description = "Scaleway project ID the API key defaults to (default_project_id)." + type = string +} + +variable "api_key_description" { + description = "Description of the generated API key." + type = string + default = "" +} + +variable "api_key_rotation_days" { + description = "Lifetime (days) of the API key before terraform rotates it on the next apply. Scaleway requires every API key to carry an expiry." + type = number + default = 365 +} diff --git a/modules/scaleway-machine-identity/version.tf b/modules/scaleway-machine-identity/version.tf new file mode 100644 index 0000000..7d4ee4c --- /dev/null +++ b/modules/scaleway-machine-identity/version.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +}