Pour qui : DevOps (mise en place), sécurité (validation), PM (compréhension du circuit).
1. Le circuit
Développeur GitHub Service Terraform (CI) Google Cloud
─────────── ──────── ────────────────────── ────────────
branche + PR ──▶ PR ouverte ──(webhook)──▶ terraform plan ──▶ commentaire dans la PR
│ (lecture seule sur GCP)
▼
Revue + CODEOWNERS
(sécurité obligatoire sur les garde-fous)
│ approuvée + fusion dans main
▼
main ──────────────────▶ terraform apply ──────────────────▶ ressources
│
▼
verify_controls.sh ── KO ──▶ pipeline en échec + alerte
│ OK
▼
alerte « garde-fou modifié » reçue ── la sécurité la rapproche de la PR
Quatre règles :
- Personne n'exécute
applydepuis son poste en production. Seul le compte de la CI a les droits d'écriture. - Les fichiers garde-fous exigent l'approbation de l'équipe sécurité (CODEOWNERS).
- Le
planpublié dans la PR est celui qui sera appliqué (pas de recalcul surprise). - Le contrôle de conformité clôt chaque déploiement.
2. CODEOWNERS : la sécurité valide ce qui la concerne
Fichier .github/CODEOWNERS du dépôt :
# Par défaut : l'équipe plateforme
* @acme/plateforme-cloud
# Garde-fous : approbation de la sécurité OBLIGATOIRE
terraform/20_data_protection.tf @acme/securite-ia
terraform/30_ai_guardrails.tf @acme/securite-ia
terraform/60_advanced.tf @acme/securite-ia
terraform/70_detection.tf @acme/securite-ia
terraform/75_shadow_ai.tf @acme/securite-ia
terraform/80_logging.tf @acme/securite-ia @acme/dpo
terraform/85_siem_access.tf @acme/securite-ia
terraform/95_secure_web_proxy.tf @acme/securite-ia
envs/*.tfvars @acme/securite-ia
Dans GitHub : Settings → Branches → règle sur main → Require a pull request, Require review from Code Owners,
Require status checks (le job plan), Do not allow bypassing.
3. Pipeline GitHub Actions (exemple complet)
Si Acme utilise son service Terraform managé (Atlantis, Terraform Enterprise, Spacelift…), c'est lui qui joue ce rôle : le circuit est le même (voir le projet 07, simulé en local). Sinon, ce workflow suffit.
L'authentification se fait par Workload Identity Federation : aucun secret ni clé JSON dans GitHub.
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
paths: ["terraform/**", "envs/**"]
push:
branches: [main]
paths: ["terraform/**", "envs/**"]
permissions:
contents: read
id-token: write # jeton OIDC pour la fédération d'identité
pull-requests: write # publier le plan en commentaire
env:
TF_IN_AUTOMATION: "true"
ENV: recette # un job par environnement / domaine en pratique
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
defaults: { run: { working-directory: terraform } }
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/acme
service_account: tf-plan@acme-ge-recette.iam.gserviceaccount.com # LECTURE seule
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.16.3" }
- run: terraform init -input=false -backend-config="prefix=securite-acme/${ENV}"
- run: terraform fmt -check -recursive
- run: terraform validate
- id: plan
run: terraform plan -input=false -no-color -var-file=../envs/${ENV}.tfvars -out=tfplan | tee plan.txt
- name: Refuser toute destruction non annoncée
run: |
if grep -E "^\s+# .* (will be destroyed|must be replaced)" plan.txt && \
! grep -q "destruction-approuvee" <<< "${{ join(github.event.pull_request.labels.*.name, ' ') }}"; then
echo "::error::Le plan détruit ou remplace des ressources. Ajouter le label 'destruction-approuvee' après revue."
exit 1
fi
- uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('terraform/plan.txt', 'utf8').slice(-60000);
github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number,
body: "### terraform plan (" + process.env.ENV + ")\n```\n" + plan + "\n```" });
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: recette # protection d'environnement GitHub : approbation manuelle possible
defaults: { run: { working-directory: terraform } }
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/acme
service_account: tf-apply@acme-ge-recette.iam.gserviceaccount.com # ÉCRITURE
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.16.3" }
- uses: google-github-actions/setup-gcloud@v2
- run: terraform init -input=false -backend-config="prefix=securite-acme/${ENV}"
- run: terraform apply -input=false -auto-approve -var-file=../envs/${ENV}.tfvars
- name: Contrôle de conformité
working-directory: .
run: PROJECT_ID=$(terraform -chdir=terraform output -raw project_id 2>/dev/null || echo acme-ge-recette) APP_ID=acme-ge ./scripts/verify_controls.shDeux comptes de service, deux niveaux de droits : tf-plan (lecture, utilisé sur les PR, donc sur du code non
encore relu) et tf-apply (écriture, utilisé seulement sur main). La fédération d'identité limite tf-apply à la
branche main du dépôt (condition d'attribut assertion.ref == 'refs/heads/main').
Le plan est recalculé au moment de l'apply : entre-temps, un autre changement a pu passer. Avec un service type Atlantis, le plan relu est celui appliqué (verrou par PR). C'est un argument pour le service managé.
4. Un projet par domaine (mesure 8)
envs/
├── recette.tfvars
├── rh.tfvars project_id = "acme-ge-rh", user_groups = ["group:ge-rh@acme.example"]
└── finance.tfvars project_id = "acme-ge-finance", user_groups = ["group:ge-finance@acme.example"]
Même code, un état par domaine (-backend-config="prefix=securite-acme/rh"), une matrice de jobs dans le
pipeline. Un domaine compromis ou mal configuré n'expose pas les autres.
5. Après l'apply : rapprocher l'alerte de la PR
Chaque modification d'un garde-fou déclenche l'alerte « Gemini Enterprise : modification d'un garde-fou de sécurité ».
La sécurité vérifie : auteur = compte tf-apply, heure = fin du job, PR fusionnée correspondante. Toute alerte
sans PR est un incident (docs/04 §5).
6. Dérive : détecter les clics en console
Planifier un job quotidien terraform plan -detailed-exitcode (code 2 = différences). Une différence = quelqu'un a
modifié en console : on remet le code (apply) et on cherche l'auteur dans l'audit.