Leçon 3 — Gouverner

Politiques as code sur le plan

8 min de lecture

La règle de Logi-Express tient en une phrase du DPO : « Aucune donnée hors de l'Union européenne. » Le module platform la vérifie (startswith(var.region, "europe-")). Mais un nouveau module, écrit par une autre équipe, crée un bucket avec location = "US" en dur ; un autre crée un secret en réplication auto, que Google réplique où il veut. La validation d'un module ne protège que ce module. La règle de l'entreprise doit s'appliquer à tout ce que Terraform s'apprête à créer, quel que soit le module qui le crée : c'est-à-dire au plan.

Le mécanisme

flowchart LR
  P["terraform plan -out=dev.tfplan"] --> J["terraform show -json<br/>plans/dev.json"]
  J --> C["conftest test --policy policies"]
  R["policies/terraform.rego<br/>deny / warn"] --> C
  C -- "deny vide" --> OK["passe"]
  C -- "un deny" --> KO["FAIL + message, code 1"]
  T["policies/terraform_test.rego"] --> V["conftest verify<br/>(tests des règles)"]

Le plan JSON contient, pour chaque ressource, resource_changes[].change.after : les valeurs résolues de ce qui sera créé. Variables, sorties de modules, default_labels du provider : tout y est calculé. Une règle sur le plan voit donc ce que ni le code source ni checkov ne voient :

ContrôleSur le code (checkov)Sur le plan (conftest)
bucket en US passé par une variableinvisiblelocation = "US"
label cost-center posé par default_labelsinvisibleprésent dans terraform_labels
ressource suppriméeanalysée quand mêmeignorée (actions = ["delete"])

OPA (Open Policy Agent) évalue des règles écrites en Rego, un langage déclaratif : une règle deny produit un ensemble de messages ; si l'ensemble est vide, la politique passe. conftest applique ces règles à des fichiers (JSON, YAML, HCL).

La mise en œuvre

files/policies/terraform.rego (extrait)
package main

allowed_regions := {"europe-west1", "europe-west4", "europe-west9"}
required_labels := {"formation-lab", "env", "managed-by", "cost-center"}

# Ressources créées ou modifiées par ce plan (suppressions et no-op ignorées).
changes contains rc if {
	some rc in input.resource_changes
	some action in rc.change.actions
	action in {"create", "update"}
}

deny contains msg if {
	some rc in changes
	some field in ["location", "region"]
	loc := lower(object.get(rc.change.after, field, ""))
	loc != ""
	not loc in allowed_regions
	msg := sprintf("%s : région %q non autorisée (UE uniquement)", [rc.address, loc])
}

# terraform_labels = labels de la ressource + default_labels du provider.
deny contains msg if {
	some rc in changes
	labels := rc.change.after.terraform_labels
	missing := required_labels - {k | some k, _ in labels}
	count(missing) > 0
	msg := sprintf("%s : labels obligatoires absents %v", [rc.address, sort(missing)])
}

deny contains msg if {
	some rc in changes
	rc.type == "google_cloud_run_v2_service"
	rc.change.after.ingress == "INGRESS_TRAFFIC_ALL"
	rc.change.after.terraform_labels.env == "prod"
	msg := sprintf("%s : ingress ouvert à Internet interdit en prod", [rc.address])
}

Le fichier complet a 10 règles deny et 1 warn : régions, répliques de secret, réplication auto, labels, allUsers/allAuthenticatedUsers (en member et en members), IP publique de VM (access_config) et de Cloud SQL (ipv4_enabled), public_access_prevention, ingress Internet en prod (refusé) et hors prod (averti). lower() sur la région : GCS renvoie EUROPE-WEST1, la plupart des API europe-west1.

Une politique est du code : elle a ses tests, sans Terraform ni cloud.

files/policies/terraform_test.rego (extrait)
test_bucket_hors_ue_refuse if {
	deny["google_storage_bucket.b : région \"us\" non autorisée (UE uniquement)"] with input as bucket({
		"location": "US",
		"public_access_prevention": "enforced",
		"terraform_labels": ok_labels,
	})
}

test_suppression_ignoree if {
	count(deny) == 0 with input as {"resource_changes": [{
		"address": "google_storage_bucket.old",
		"type": "google_storage_bucket",
		"change": {"actions": ["delete"], "after": null},
	}]}
}

make setup télécharge conftest 0.71.1 dans .tools/ et vérifie son empreinte SHA-256 contre checksums.txt de la publication. make policy produit les deux plans puis applique conftest, et checkov en mode plan :

make test      # inclut conftest verify
make policy    # plans dev et prod réels, puis conftest test et checkov sur les JSON

Résultat obtenu (2026-10-16)

$ make policy
prod : Plan: 18 to add, 0 to change, 0 to destroy.
dev  : Plan: 13 to add, 0 to change, 0 to destroy.

22 tests, 22 passed, 0 warnings, 0 failures, 0 exceptions
checkov plans/dev.json : Passed checks: 19, Failed checks: 0, Skipped checks: 0
checkov plans/prod.json : Passed checks: 29, Failed checks: 0, Skipped checks: 0

22 = 11 règles × 2 plans. Puis un plan de prod avec l'ingress ouvert, que les modules acceptent (l'ingress n'est pas validé dans cloud-run-service, c'est voulu : le dev en a besoin) :

$ terraform -chdir=infra/envs/prod plan -var ingress=INGRESS_TRAFFIC_ALL -out=plans/bad.tfplan ...
$ conftest test --policy policies plans/bad.json
FAIL - plans/bad.json - main - module.platform.module.service[0].google_cloud_run_v2_service.this : ingress ouvert à Internet interdit en prod

11 tests, 10 passed, 0 warnings, 1 failure, 0 exceptions
Plan de prod refusé par la politique d'ingress (sortie réelle)

Avec -var region=us-central1, le plan n'arrive même pas jusqu'à conftest : la validation du module platform l'arrête (Error: Invalid value for variable). Les deux protections se complètent : le module refuse ce qu'il sait refuser, la politique attrape le reste.

Ce qui casse en production

  • La règle qui ne voit rien. Une règle sur rc.change.after.labels au lieu de terraform_labels : les labels posés par default_labels n'y figurent pas, toutes les ressources « manquent » de cost-center, quelqu'un désactive la règle. Parade : un test avec une ressource conforme (test_bucket_conforme), qui doit produire zéro deny.
  • Valeurs inconnues au plan. after ne contient pas les valeurs (known after apply) ; elles sont dans after_unknown. Une règle rc.change.after.name sur un nom calculé ne se déclenche jamais. Parade : écrire les règles sur des attributs connus au plan (région, labels, ingress), et le documenter.
  • Les suppressions refusées. Sans le filtre changes, un plan qui supprime un vieux bucket en US est bloqué, alors qu'il corrige le problème. Parade : ne contrôler que create et update, et tester ce cas (test_suppression_ignoree).
  • Rego qui change sous vos pieds. OPA 1.0 a rendu obligatoires if et contains ; une politique écrite pour 0.x échoue au parsing. Parade : épingler la version de conftest (ici par SHA-256), et conftest verify en CI.

La règle de décision

Dans un module : les validations propres à son contrat. Dans checkov : les bonnes pratiques de sécurité génériques. Sur le plan, avec OPA : les règles de l'entreprise (régions, labels, exposition), parce qu'elles portent sur des valeurs résolues et doivent s'appliquer à tout module, y compris ceux que vous n'avez pas écrits. deny pour ce qui ne se négocie pas, warn pour ce qui se tolère hors prod.

Pratique

Une équipe ajoute un module qui crée un bucket avec location = var.location, passée à "US" dans sa racine. Le module n'a pas de validation et checkov passe. Où l'erreur est-elle arrêtée dans cette chaîne ?

Vous ajoutez la règle « labels obligatoires ». Sur le premier plan réel, elle échoue sur google_project_iam_member et google_secret_manager_secret_version. Ces ressources n'acceptent pas de labels. Que faites-vous ?

Exercice. Écrivez une règle qui refuse toute création de google_service_account_key (une clé JSON de compte de service), et son test.

Voir la solution
deny contains msg if {
	some rc in changes
	rc.type == "google_service_account_key"
	msg := sprintf("%s : clé de compte de service interdite (utilisez la fédération d'identité)", [rc.address])
}
test_cle_de_compte_refusee if {
	some msg in deny with input as {"resource_changes": [{
		"address": "google_service_account_key.k",
		"type": "google_service_account_key",
		"change": {"actions": ["create"], "after": {}},
	}]}
	contains(msg, "clé de compte")
}

make test doit afficher 9 tests, 9 passed. Une clé JSON est un secret de longue durée qui finit dans un dépôt ou un poste ; la CI de ce projet n'en a pas besoin (leçon 4).

  • Je produis le plan JSON et j'y trouve les valeurs résolues d'une ressource.
  • J'écris une règle Rego deny sur les créations et modifications seulement.
  • Je teste chaque règle avec un cas conforme et un cas refusé (conftest verify).
  • Je répartis les contrôles entre validation de module, checkov et politique sur le plan.
Suivant