درس 3 — Construire les modules

Identités, registre et secrets

7 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Un audit de sécurité tombe sur le bucket d'état Terraform d'une équipe data : dedans, en clair, le mot de passe de la base de prod, le jeton de l'outil de ticketing et une clé de compte de service. Rien n'a fuité au sens strict ; mais toute personne qui pouvait lire l'état pouvait lire les secrets. Avant Terraform 1.11, google_secret_manager_secret_version.secret_data écrivait toujours la valeur dans l'état. Cette leçon construit les trois modules qui touchent aux droits : comptes de service, registre d'images, secrets.

Le mécanisme

flowchart LR
  ENV[".env : TICKETING_TOKEN"] -- "TF_VAR_ticketing_token" --> RV["variable ephemeral"]
  RV --> MV["module secrets : var.values (ephemeral)"]
  MV -- "secret_data_wo (write-only)" --> API["API Secret Manager"]
  MV -. "rien" .-> ST[("État Terraform")]
  SA["compte de service run"] -- "secretAccessor" --> SEC["secret lab-pia-dev-ticketing-token"]
  SEC -- "variable TICKETING_TOKEN" --> CR["Cloud Run"]

Deux mécanismes de Terraform ≥ 1.11 se combinent :

  • une variable éphémère (ephemeral = true) n'est jamais écrite dans le plan ni dans l'état ; Terraform refuse de l'utiliser ailleurs que dans un contexte éphémère ;
  • un argument write-only (secret_data_wo) est transmis à l'API à l'apply, puis oublié. Comme Terraform ne le stocke pas, il ne peut pas détecter qu'il a changé : on incrémente secret_data_wo_version pour pousser une nouvelle valeur.

La mise en œuvre

Comptes de service : le moindre privilège, rôle par rôle

files/infra/modules/platform/main.tf (extrait)
module "identities" {
  source     = "../service-accounts"
  project_id = var.project_id
  prefix     = local.prefix
  accounts = {
    run = {
      description = "Exécution du service de tri (Vertex AI + journaux)"
      project_roles = [
        "roles/aiplatform.user",
        "roles/logging.logWriter",
        "roles/monitoring.metricWriter",
      ]
    }
  }
}

Le module utilise google_project_iam_member, additif : il ajoute un membre à un rôle sans toucher aux autres. google_project_iam_binding remplace la liste complète des membres d'un rôle, et google_project_iam_policy la politique entière du projet ; un apply avec l'un ou l'autre peut retirer les droits de collègues ou d'agents de service Google.

L'identifiant d'un compte de service fait 30 caractères au plus. D'où le préfixe court lab-pia-dev plutôt que lab-plateforme-ia-modulaire-dev, et une validation dans le module (length(var.prefix) <= 20) qui échoue au plan plutôt qu'à l'apply.

Registre : immuable en prod, nettoyé partout

files/infra/modules/artifact-registry/main.tf (extrait)
dynamic "docker_config" { # bloc posé en prod seulement (voir plus bas)
  for_each = var.immutable_tags ? [1] : []
  content { immutable_tags = true }
}
cleanup_policies {
  id     = "garder-les-recentes"
  action = "KEEP"
  most_recent_versions { keep_count = var.keep_versions }
}
cleanup_policies {
  id     = "supprimer-le-reste"
  action = "DELETE"
  condition { older_than = "604800s" }
}

Une politique KEEP l'emporte sur une politique DELETE : les 10 versions les plus récentes restent, même anciennes. Le service Cloud Run du même projet tire ses images avec l'agent de service Cloud Run, qui a déjà accès au registre du projet ; la variable readers sert au cas inter-projets (un registre central partagé entre projets de prod).

Secrets : la valeur ne passe pas par l'état

files/infra/modules/secrets/main.tf (extrait)
resource "google_secret_manager_secret" "this" {
  for_each  = var.secrets
  secret_id = "${var.prefix}-${each.key}"
  replication {
    user_managed {
      replicas { location = var.region } # la donnée reste dans l'UE
    }
  }
}

resource "google_secret_manager_secret_version" "this" {
  for_each               = var.secrets
  secret                 = google_secret_manager_secret.this[each.key].id
  secret_data_wo         = var.values[each.key]
  secret_data_wo_version = each.value.version
}

La réplication auto copierait le secret dans des régions choisies par Google, hors UE comprises ; user_managed fixe la liste.

Les lecteurs du secret sont passés en map { run = <identité> }, pas en liste : la clé ticketing-token/run est connue au plan, l'identité (calculée) n'apparaît que dans la valeur. Le projet suivant montre l'erreur Invalid for_each argument que la version « liste » produisait dans un test.

Résultat obtenu (2026-10-15)

Dans le plan, la valeur n'apparaît pas :

  + resource "google_secret_manager_secret_version" "this" {
      + secret_data_wo         = (write-only attribute)
      + secret_data_wo_version = "1"

Après l'apply, on cherche la valeur du jeton dans l'état distant :

$ gcloud storage cat gs://lab-pia-tfstate-8f4e94/plateforme-ia/dev/default.tfstate | grep -c "$TICKETING_TOKEN"
0

Et le service la reçoit bien (ticketing_token_loaded: True sur /health, leçon 7).

Ce qui casse en production

  • Rotation qui ne fait rien. On change TICKETING_TOKEN dans .env, apply répond « No changes ». Normal : un argument write-only n'est pas comparé. Parade : incrémenter ticketing_token_version en même temps, ou faire la rotation hors Terraform (gcloud secrets versions add) et laisser Terraform gérer le seul contenant.
  • Cloud Run démarre avant le droit de lecture. Symptôme : la révision échoue avec Permission denied on secret. Cause : la liaison IAM secretAccessor n'existait pas encore. Parade : depends_on = [module.secrets] sur le module service (fait dans platform).
  • iam_binding qui efface les autres. Un google_project_iam_binding sur roles/run.invoker retire les invokers ajoutés par une autre équipe ; découvert au premier appel en 403. Parade : iam_member partout, sauf politique entièrement gérée par une seule équipe.
  • Dérive fantôme sur le registre. Avec un bloc docker_config { immutable_tags = false } écrit en dur, le plan qui suit le premier apply annonce + docker_config { immutable_tags = false } : l'API ne renvoie pas le bloc quand la valeur est fausse. Mesuré le 2026-10-16 sur ce lab, c'est ce qui a fait échouer la première détection de dérive du projet suivant. Parade : dynamic qui ne pose le bloc que lorsqu'il vaut true.
  • Tag écrasé en prod. Sans immutable_tags, :v1.4.0 pointe un jour vers une autre image ; un rollback redéploie autre chose que prévu. Parade : tags immuables en prod, et déploiement par digest (@sha256:…), comme le fait make cloud-up.

La règle de décision

Une valeur secrète dans Terraform : variable ephemeral + argument write-only, ou création du secret seul et alimentation hors Terraform. Jamais de secret_data classique sur un état partagé. Une identité par service, des rôles prédéfinis ajoutés un par un en iam_member. Surveillez dans les revues tout iam_binding, iam_policy et tout rôle owner/editor.

Pratique

Le jeton de ticketing a fuité. Vous mettez la nouvelle valeur dans TF_VAR_ticketing_token et lancez apply : « No changes ». Que faites-vous ?

Une équipe voisine signale que ses déploiements Cloud Run échouent depuis votre dernier apply sur le projet partagé. Votre diff ajoutait un google_project_iam_binding pour roles/run.admin. Quelle est la cause probable ?

Exercice. Ajoutez un second secret, crm-api-key, lisible par le compte run et injecté dans le service sous CRM_API_KEY. Quelles lignes de platform/main.tf changent ?

Voir la solution
module "secrets" {
  # ...
  secrets = {
    ticketing-token = { accessors = { run = module.identities.members["run"] }, version = var.ticketing_token_version }
    crm-api-key     = { accessors = { run = module.identities.members["run"] } }
  }
  values = { ticketing-token = var.ticketing_token, crm-api-key = var.crm_api_key }
}

module "service" {
  # ...
  secret_env = {
    TICKETING_TOKEN = { secret = module.secrets.ids["ticketing-token"], version = module.secrets.versions["ticketing-token"] }
    CRM_API_KEY     = { secret = module.secrets.ids["crm-api-key"], version = module.secrets.versions["crm-api-key"] }
  }
}

Plus une variable crm_api_key (sensitive = true, ephemeral = true) dans platform et dans chaque racine. Le module secrets lui-même ne change pas : c'est le signe que son contrat est bon.

  • Je passe une valeur secrète par variable ephemeral et argument write-only, et je sais la faire tourner.
  • Je choisis entre iam_member, iam_binding et iam_policy en connaissant l'effet de chacun.
  • Je configure un registre avec tags immuables et politique de nettoyage.
  • Je vérifie qu'un secret est absent de l'état distant.
التالي