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émentesecret_data_wo_versionpour pousser une nouvelle valeur.
La mise en œuvre
Comptes de service : le moindre privilège, rôle par rôle
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
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
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"
0Et 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_TOKENdans.env,applyrépond « No changes ». Normal : un argument write-only n'est pas comparé. Parade : incrémenterticketing_token_versionen 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 IAMsecretAccessorn'existait pas encore. Parade :depends_on = [module.secrets]sur le module service (fait dansplatform). iam_bindingqui efface les autres. Ungoogle_project_iam_bindingsurroles/run.invokerretire les invokers ajoutés par une autre équipe ; découvert au premier appel en 403. Parade :iam_memberpartout, 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 :dynamicqui ne pose le bloc que lorsqu'il vauttrue. - Tag écrasé en prod. Sans
immutable_tags,:v1.4.0pointe 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 faitmake 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 ?
Terraform ne stocke pas un argument write-only, il ne peut donc pas voir qu'il a changé : le compteur de version est le signal prévu. -replace sur le secret le supprimerait avec toutes ses versions et ses droits ; revenir à secret_data réécrit la valeur dans l'état ; refresh ne lit pas la valeur.
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 ?
iam_binding fait autorité sur un rôle : il retire tout membre non listé. iam_member ajoute sans retirer. La propagation n'explique pas un retrait de droit ; l'état est par équipe et le quota n'a pas de rapport avec le diff.
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
ephemeralet argument write-only, et je sais la faire tourner. - Je choisis entre
iam_member,iam_bindingetiam_policyen 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.