From afcd2409e9c2af21264fcf048082dc522561712f Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:32:44 +0200 Subject: [PATCH 1/7] feat(modules): add shared scaleway-machine-identity and scaleway-bucket-with-identity modules scaleway-machine-identity: IAM application + a map of policies (each supporting one or more project/org-scoped rule blocks) + a rotating API key - replaces four copy-pasted app/policy/key blocks across the repo with one module, keyed by policy-purpose slug so a call site with more than one scaleway_iam_policy (github-ci's cluster-management + backup-management) can be represented without merging policy objects. scaleway-bucket-with-identity: packages a Scaleway object bucket + its SSE config + one scaleway-machine-identity instance into a single unit, so a domain that owns several buckets can loop over a map instead of hand-writing bucket+identity blocks per bucket. Both include a throwaway examples/basic/ validated via init+validate before being wired into any real root's state. --- modules/scaleway-bucket-with-identity/main.tf | 78 ++++++++++++++++ .../scaleway-bucket-with-identity/outputs.tf | 25 ++++++ .../variables.tf | 89 +++++++++++++++++++ .../scaleway-bucket-with-identity/version.tf | 12 +++ .../examples/basic/.terraform.lock.hcl | 44 +++++++++ .../examples/basic/main.tf | 71 +++++++++++++++ modules/scaleway-machine-identity/main.tf | 35 ++++++++ modules/scaleway-machine-identity/outputs.tf | 20 +++++ .../scaleway-machine-identity/variables.tf | 46 ++++++++++ modules/scaleway-machine-identity/version.tf | 12 +++ 10 files changed, 432 insertions(+) create mode 100644 modules/scaleway-bucket-with-identity/main.tf create mode 100644 modules/scaleway-bucket-with-identity/outputs.tf create mode 100644 modules/scaleway-bucket-with-identity/variables.tf create mode 100644 modules/scaleway-bucket-with-identity/version.tf create mode 100644 modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl create mode 100644 modules/scaleway-machine-identity/examples/basic/main.tf create mode 100644 modules/scaleway-machine-identity/main.tf create mode 100644 modules/scaleway-machine-identity/outputs.tf create mode 100644 modules/scaleway-machine-identity/variables.tf create mode 100644 modules/scaleway-machine-identity/version.tf 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/modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl b/modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl new file mode 100644 index 0000000..84d718b --- /dev/null +++ b/modules/scaleway-machine-identity/examples/basic/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.0" + constraints = "~> 0.12" + hashes = [ + "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", + "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.79.0" + constraints = "~> 2.0" + hashes = [ + "h1:LcxR9sNfLg0lMcPGni34/aFAIL0A3x06mZ8Lhg9LYtk=", + "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/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" + } + } +} From 316b167174be8c1a22e954cab051128abaadb461 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:32:59 +0200 Subject: [PATCH 2/7] refactor(iam): migrate github-ci and external-dns to the shared module, drop Infisical github-ci (01-iam/bootstrap/scaleway) now goes through module "ci_identity" (two policies: cluster_management, backup_management), replacing the hand-written app/policy/key resources. Also drops infisical_secret_folder.ci and the two infisical_secret resources - Infisical is retired repo-wide, the key is distributed by hand as already documented. 01-iam/bootstrap/infisical (the Infisical CI trust anchor) is removed entirely, no longer used by anything. external-dns moves from 04-dns/scaleway to 01-iam/workload/scaleway - it owns no DNS zone/record resource, only a workload identity, and isn't a CI trust anchor, so it doesn't belong under 01-iam/bootstrap/ or as its own numbered domain. Wrapped with module "identities" { for_each = var.identities } so a future non-bucket workload identity is a map entry, not new resources. workload_access_key/workload_secret_key outputs stay pinned to external-dns specifically since 05-secrets/openbao/managed reads them by that name; generic access_keys/secret_keys map outputs cover future identities. All state moves verified via a clean terraform plan (zero resource recreation, same live credentials before/after). --- .../bootstrap/infisical/.terraform.lock.hcl | 26 --- 01-iam/bootstrap/infisical/README.md | 87 ---------- .../infisical/env/01-iam-infisical.tfvars | 8 - 01-iam/bootstrap/infisical/main.tf | 56 ------- 01-iam/bootstrap/infisical/outputs.tf | 11 -- 01-iam/bootstrap/infisical/variables.tf | 43 ----- 01-iam/bootstrap/infisical/version.tf | 26 --- 01-iam/bootstrap/scaleway/.terraform.lock.hcl | 79 +++++---- 01-iam/bootstrap/scaleway/README.md | 55 +++---- .../scaleway/env/01-iam-scaleway.tfvars | 4 - 01-iam/bootstrap/scaleway/main.tf | 152 +++++++----------- 01-iam/bootstrap/scaleway/outputs.tf | 7 +- 01-iam/bootstrap/scaleway/variables.tf | 18 --- 01-iam/bootstrap/scaleway/version.tf | 21 +-- 01-iam/workload/scaleway/.terraform.lock.hcl | 69 ++++++++ .../scaleway/env/04-dns-scaleway.tfvars | 16 ++ 01-iam/workload/scaleway/main.tf | 24 +++ 01-iam/workload/scaleway/outputs.tf | 25 +++ 01-iam/workload/scaleway/variables.tf | 23 +++ .../workload}/scaleway/version.tf | 5 + 04-dns/scaleway/.terraform.lock.hcl | 69 -------- 04-dns/scaleway/env/04-dns-scaleway.tfvars | 3 - 04-dns/scaleway/main.tf | 36 ----- 04-dns/scaleway/outputs.tf | 10 -- 04-dns/scaleway/variables.tf | 10 -- 25 files changed, 289 insertions(+), 594 deletions(-) delete mode 100644 01-iam/bootstrap/infisical/.terraform.lock.hcl delete mode 100644 01-iam/bootstrap/infisical/README.md delete mode 100644 01-iam/bootstrap/infisical/env/01-iam-infisical.tfvars delete mode 100644 01-iam/bootstrap/infisical/main.tf delete mode 100644 01-iam/bootstrap/infisical/outputs.tf delete mode 100644 01-iam/bootstrap/infisical/variables.tf delete mode 100644 01-iam/bootstrap/infisical/version.tf create mode 100644 01-iam/workload/scaleway/.terraform.lock.hcl create mode 100644 01-iam/workload/scaleway/env/04-dns-scaleway.tfvars create mode 100644 01-iam/workload/scaleway/main.tf create mode 100644 01-iam/workload/scaleway/outputs.tf create mode 100644 01-iam/workload/scaleway/variables.tf rename {04-dns => 01-iam/workload}/scaleway/version.tf (62%) delete mode 100644 04-dns/scaleway/.terraform.lock.hcl delete mode 100644 04-dns/scaleway/env/04-dns-scaleway.tfvars delete mode 100644 04-dns/scaleway/main.tf delete mode 100644 04-dns/scaleway/outputs.tf delete mode 100644 04-dns/scaleway/variables.tf 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/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/04-dns/scaleway/version.tf b/01-iam/workload/scaleway/version.tf similarity index 62% rename from 04-dns/scaleway/version.tf rename to 01-iam/workload/scaleway/version.tf index 4392bd7..07f8180 100644 --- a/04-dns/scaleway/version.tf +++ b/01-iam/workload/scaleway/version.tf @@ -2,6 +2,11 @@ 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 diff --git a/04-dns/scaleway/.terraform.lock.hcl b/04-dns/scaleway/.terraform.lock.hcl deleted file mode 100644 index 1b73528..0000000 --- a/04-dns/scaleway/.terraform.lock.hcl +++ /dev/null @@ -1,69 +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.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", - "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.79.0" - constraints = "~> 2.0" - hashes = [ - "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/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 -} From 74710359d5678fe243d6732b772947aba95e99ef Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:33:12 +0200 Subject: [PATCH 3/7] refactor(storage): split 03-backup into 03-storage (buckets) and 02-encryption (KMS) 03-backup/scaleway conflated two unrelated concerns: Scaleway object buckets + their workload identities, and an AWS KMS key + IAM user for OpenBao's own auto-unseal. Splits them: - 03-storage/scaleway: the backup + velero buckets, now provisioned via a single `module "buckets" { for_each = var.buckets }` block (modules/scaleway-bucket-with-identity) instead of hand-written duplicate bucket/identity blocks per bucket - a future tool bucket is a map entry, not new resources. Root-level output names (workload_access_key, velero_bucket_name, etc.) unchanged, so 05-secrets/openbao/managed's terraform_remote_state reads need no update. - 02-encryption/aws: the AWS KMS key + IAM user, moved via a genuine cross-backend terraform state mv (new backend key openbao-unseal/aws) rather than a directory rename. The tfvars filename is deliberately kept identical (03-backup-dev-bucket.tfvars) to keep terraform.workspace unchanged - local.unseal_name derives the live KMS alias + IAM user name from it, so renaming the workspace would have renamed/recreated them. All moves verified: 0 resource recreation on the bucket/identity side (only two description-string updates), and the KMS resources kept their exact same AWS resource IDs across the cross-backend move. --- 02-encryption/aws/.terraform.lock.hcl | 27 ++++ .../aws/env/03-backup-dev-bucket.tfvars | 6 + .../kms.tf => 02-encryption/aws/main.tf | 16 +-- 02-encryption/aws/outputs.tf | 25 ++++ 02-encryption/aws/variables.tf | 5 + 02-encryption/aws/version.tf | 35 ++++++ 03-backup/scaleway/.terraform.lock.hcl | 93 -------------- .../scaleway/env/03-backup-dev-bucket.tfvars | 12 -- 03-backup/scaleway/iam.tf | 60 --------- 03-backup/scaleway/main.tf | 116 ------------------ 03-backup/scaleway/outputs.tf | 71 ----------- 03-backup/scaleway/variables.tf | 99 --------------- 03-backup/scaleway/version.tf | 52 -------- 03-storage/README.md | 33 +++++ 03-storage/scaleway/.terraform.lock.hcl | 69 +++++++++++ .../scaleway/env/03-backup-dev-bucket.tfvars | 27 ++++ 03-storage/scaleway/main.tf | 31 +++++ 03-storage/scaleway/outputs.tf | 43 +++++++ 03-storage/scaleway/variables.tf | 59 +++++++++ 03-storage/scaleway/version.tf | 23 ++++ 20 files changed, 392 insertions(+), 510 deletions(-) create mode 100644 02-encryption/aws/.terraform.lock.hcl create mode 100644 02-encryption/aws/env/03-backup-dev-bucket.tfvars rename 03-backup/scaleway/kms.tf => 02-encryption/aws/main.tf (85%) create mode 100644 02-encryption/aws/outputs.tf create mode 100644 02-encryption/aws/variables.tf create mode 100644 02-encryption/aws/version.tf delete mode 100644 03-backup/scaleway/.terraform.lock.hcl delete mode 100644 03-backup/scaleway/env/03-backup-dev-bucket.tfvars delete mode 100644 03-backup/scaleway/iam.tf delete mode 100644 03-backup/scaleway/main.tf delete mode 100644 03-backup/scaleway/outputs.tf delete mode 100644 03-backup/scaleway/variables.tf delete mode 100644 03-backup/scaleway/version.tf create mode 100644 03-storage/README.md create mode 100644 03-storage/scaleway/.terraform.lock.hcl create mode 100644 03-storage/scaleway/env/03-backup-dev-bucket.tfvars create mode 100644 03-storage/scaleway/main.tf create mode 100644 03-storage/scaleway/outputs.tf create mode 100644 03-storage/scaleway/variables.tf create mode 100644 03-storage/scaleway/version.tf 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/03-storage/scaleway/version.tf b/03-storage/scaleway/version.tf new file mode 100644 index 0000000..253aa7c --- /dev/null +++ b/03-storage/scaleway/version.tf @@ -0,0 +1,23 @@ +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" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} + +provider "scaleway" {} From 37c523783e55a52a4cbf43bda6a15adfe0ee5265 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:33:26 +0200 Subject: [PATCH 4/7] refactor(remote-state): merge state bucket + AWS CI roles into 00-foundation/aws 00-remote_state + 01-iam/bootstrap/aws + 01-iam/ci-managed/aws-state-access were three roots for what was fundamentally one concern: the AWS substrate every other root depends on. Merges them into 00-foundation/aws: - The state bucket moves in via a pure directory rename (zero state impact - workspace_key_prefix/tfvars filename untouched). - The GitHub OIDC provider moves in via a real cross-backend state mv (same module/address in both roots, a straight 1:1 move). - One new role, terraform-state-access (via terraform-aws-modules/iam, matching the OIDC-provider module already in use), replaces the old github-actions-terraform + tf-state-access pair. Scoped to exactly S3 list/get/put/delete on the state bucket - no IAM-management capability at all, unlike the system it replaces: the old bootstrap/aws + ci-managed/aws-state-access together built an entire "CI can mint further IAM roles" mechanism (a permissions boundary + a policy letting the CI role create/attach other roles under a managed path) whose only actual consumer was minting the one role that did state R/W. Once that role's job narrows to exactly "read/write this bucket," there's no IAM-management capability left to guard against escalating, so the whole guardrail system goes with it. Both old roles destroyed only after confirming (via a live GitHub Actions dry run) that scaleway.yml successfully assumes the new role under vars.AWS_TERRAFORM_ROLE_ARN. iam_terraform-backend-role.yml deleted - nothing left for it to apply. 01-iam/bootstrap/aws and 01-iam/ci-managed/aws-state-access are empty after this (the former is recreated with different, narrower content in the next commit). --- .../workflows/iam_terraform-backend-role.yml | 58 ------- .github/workflows/scaleway.yml | 21 +-- 00-foundation/aws/.terraform.lock.hcl | 49 ++++++ .../aws}/README.md | 57 +++++-- .../aws}/env/00-remote-state-backend.tfvars | 0 00-foundation/aws/main.tf | 147 ++++++++++++++++++ 00-foundation/aws/outputs.tf | 24 +++ .../aws}/variables.tf | 14 ++ .../aws}/version.tf | 6 + 00-remote_state/.terraform.lock.hcl | 27 ---- 00-remote_state/main.tf | 60 ------- 00-remote_state/outputs.tf | 14 -- .../aws-state-access/.terraform.lock.hcl | 27 ---- .../env/00-remote-state-iam.tfvars | 20 --- 01-iam/ci-managed/aws-state-access/main.tf | 66 -------- 01-iam/ci-managed/aws-state-access/outputs.tf | 4 - .../ci-managed/aws-state-access/variables.tf | 51 ------ 01-iam/ci-managed/aws-state-access/version.tf | 27 ---- 18 files changed, 297 insertions(+), 375 deletions(-) delete mode 100644 .github/workflows/iam_terraform-backend-role.yml create mode 100644 00-foundation/aws/.terraform.lock.hcl rename {00-remote_state => 00-foundation/aws}/README.md (59%) rename {00-remote_state => 00-foundation/aws}/env/00-remote-state-backend.tfvars (100%) create mode 100644 00-foundation/aws/main.tf create mode 100644 00-foundation/aws/outputs.tf rename {00-remote_state => 00-foundation/aws}/variables.tf (54%) rename {00-remote_state => 00-foundation/aws}/version.tf (83%) delete mode 100644 00-remote_state/.terraform.lock.hcl delete mode 100644 00-remote_state/main.tf delete mode 100644 00-remote_state/outputs.tf delete mode 100644 01-iam/ci-managed/aws-state-access/.terraform.lock.hcl delete mode 100644 01-iam/ci-managed/aws-state-access/env/00-remote-state-iam.tfvars delete mode 100644 01-iam/ci-managed/aws-state-access/main.tf delete mode 100644 01-iam/ci-managed/aws-state-access/outputs.tf delete mode 100644 01-iam/ci-managed/aws-state-access/variables.tf delete mode 100644 01-iam/ci-managed/aws-state-access/version.tf 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/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/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 -} From 0b7fcecddcc30b1078219d4150a1f875e89371aa Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:33:37 +0200 Subject: [PATCH 5/7] feat(iam): add 01-iam/bootstrap/aws back, scoped to exactly 02-encryption New role openbao-unseal-ci, OIDC-trusted (repo:IntegratedDynamic/ infrastructure:* only), with exactly two policies: full CRUD (create/read/update/destroy) on the KMS key+alias and IAM user+access- key that 02-encryption/aws manages - nothing broader - plus the same state-bucket policy 00-foundation/aws's terraform-state-access role uses, read via a data.terraform_remote_state lookup (not a hardcoded ARN), so this role can also read/write the bucket for its own backend. Closes the gap 02-encryption/aws's own comments used to flag: it required broad IAM/KMS rights the S3-state-only terraform-state-access role doesn't have, so it was admin-applied only. Now there's a second, narrowly-scoped role that can carry that capability without widening terraform-state-access itself - this domain is meant to run through CI/CD like everything else, not stay admin-only. Verified with a clean "No changes" plan after switching the state-bucket policy reference from a hardcoded ARN variable to the terraform_remote_state lookup. --- 01-iam/bootstrap/aws/.terraform.lock.hcl | 58 ++--- 01-iam/bootstrap/aws/README.md | 146 ----------- 01-iam/bootstrap/aws/env/01-iam-aws.tfvars | 1 + 01-iam/bootstrap/aws/iam-ci.tf | 269 --------------------- 01-iam/bootstrap/aws/main.tf | 120 ++++++--- 01-iam/bootstrap/aws/outputs.tf | 24 +- 01-iam/bootstrap/aws/variables.tf | 30 +-- 01-iam/bootstrap/aws/version.tf | 18 +- 8 files changed, 116 insertions(+), 550 deletions(-) delete mode 100644 01-iam/bootstrap/aws/README.md create mode 100644 01-iam/bootstrap/aws/env/01-iam-aws.tfvars delete mode 100644 01-iam/bootstrap/aws/iam-ci.tf 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 } From 13ac7fe8dd9b15bc19db293e66b7dd831f633d55 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:33:44 +0200 Subject: [PATCH 6/7] refactor: move 02-cluster to 10-cluster, free up low domain numbers Pure git mv (zero state impact - workspace_key_prefix and tfvars filenames untouched, same decoupling convention used for every other directory move in this refactor). Frees up low numbers for domains that are actually part of the early bootstrap chain (00-foundation, 01-iam, 02-encryption, 03-storage) - 10-cluster sits downstream of all of them. --- 10-cluster/README.md | 32 +++++++++++++ .../local/.terraform.lock.hcl | 0 .../local/env/02-cluster-local-example.tfvars | 0 .../local/env/02-cluster-local.tfvars | 0 {02-cluster => 10-cluster}/local/main.tf | 2 +- {02-cluster => 10-cluster}/local/variables.tf | 4 +- {02-cluster => 10-cluster}/local/version.tf | 0 .../scaleway/.terraform.lock.hcl | 47 +++++++++---------- {02-cluster => 10-cluster}/scaleway/argocd.tf | 0 .../scaleway/env/02-cluster-staging.tfvars | 0 {02-cluster => 10-cluster}/scaleway/main.tf | 2 +- .../scaleway/outputs.tf | 0 .../scaleway/variables.tf | 4 +- .../scaleway/version.tf | 0 14 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 10-cluster/README.md rename {02-cluster => 10-cluster}/local/.terraform.lock.hcl (100%) rename {02-cluster => 10-cluster}/local/env/02-cluster-local-example.tfvars (100%) rename {02-cluster => 10-cluster}/local/env/02-cluster-local.tfvars (100%) rename {02-cluster => 10-cluster}/local/main.tf (97%) rename {02-cluster => 10-cluster}/local/variables.tf (86%) rename {02-cluster => 10-cluster}/local/version.tf (100%) rename {02-cluster => 10-cluster}/scaleway/.terraform.lock.hcl (81%) rename {02-cluster => 10-cluster}/scaleway/argocd.tf (100%) rename {02-cluster => 10-cluster}/scaleway/env/02-cluster-staging.tfvars (100%) rename {02-cluster => 10-cluster}/scaleway/main.tf (97%) rename {02-cluster => 10-cluster}/scaleway/outputs.tf (100%) rename {02-cluster => 10-cluster}/scaleway/variables.tf (93%) rename {02-cluster => 10-cluster}/scaleway/version.tf (100%) 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 From a93be57ab5c374b348ba332043e449726669aa59 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sat, 1 Aug 2026 01:34:04 +0200 Subject: [PATCH 7/7] docs: add remaining domain READMEs, fix path references repo-wide Adds a spec-style README.md to every remaining domain (00-foundation, 01-iam, 02-encryption, 05-secrets) - what the domain is for, its contract, what deliberately doesn't belong there, independent of which provider implements it today. 02-encryption's is deliberately concrete rather than provider-agnostic: its shape is directly driven by which vendors OpenBao's auto-unseal plugins support, not a generic architectural choice. Updates mise.toml/CLAUDE.md's lock task path lists, CLAUDE.md's Architecture section (tree diagram, dependency spine, per-root docs) to match the new domain layout, terraform-lock.yml's matrix (was pointed at two now-deleted directories - would have failed the next matching PR; also widened to cover roots it never included), and dangling path references left in comments/READMEs after the various moves (05-secrets/openbao/managed's comments and README, the state bucket reference in the root README). --- .github/workflows/terraform-lock.yml | 8 +- 00-foundation/README.md | 70 ++++++++++ 01-iam/README.md | 45 +++++++ 02-encryption/README.md | 32 +++++ 05-secrets/README.md | 12 ++ 05-secrets/openbao/bootstrap/README.md | 19 ++- 05-secrets/openbao/managed/README.md | 6 +- 05-secrets/openbao/managed/main.tf | 6 +- CLAUDE.md | 169 +++++++++++++++++-------- README.md | 2 +- mise.toml | 17 ++- 11 files changed, 305 insertions(+), 81 deletions(-) create mode 100644 00-foundation/README.md create mode 100644 01-iam/README.md create mode 100644 02-encryption/README.md create mode 100644 05-secrets/README.md 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/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/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/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/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 """