Skip to content

AWS Setup Guide

Prérequis

  • Compte AWS actif
  • IAM User avec AdministratorAccess
  • MFA activĂ© ET exigĂ© par politique IAM sur iamadmin (voir section 9). Sans session MFA, la clĂ© statique ne peut plus rien faire
  • Dev Container configurĂ© (voir README.md)

1. Configurer aws-vault

# Dans le Dev Container
export AWS_VAULT_BACKEND=file   # déjà dans .bashrc
aws-vault add devops-portfolio
# AWS Access Key ID     : AKIA...
# AWS Secret Access Key : xxxxxxx
# Choisir une passphrase pour chiffrer les credentials

2. Configurer ~/.aws/config

[profile devops-portfolio]
region = eu-west-3
mfa_serial = arn:aws:iam::TON_ACCOUNT_ID:mfa/TON_DEVICE
duration_seconds = 43200    # 12 heures

Récupérer l'ARN MFA :

AWS Console → IAM → Users → TON_USER
→ Security credentials → Multi-factor authentication
→ Copier l'ARN


3. Ouvrir une session AWS (workflow quotidien)

# Alias disponible dans le Dev Container
awslogin

# Équivalent complet
aws-vault exec devops-portfolio --duration=12h -- bash

# Entrer le code MFA (6 chiffres) une seule fois
# → credentials valides 12h dans le shell

Ces identifiants vivent dans ce shell, et il suffit dans presque tous les cas. Les faire sortir vers un fichier de session est le cas particulier, pas la rĂšgle — et il a ses piĂšges. Voir la section 12, qui dĂ©crit aussi quel outil vit de quel cĂŽtĂ© du pont ~/.aws.


4. Vérifier la connexion

aws sts get-caller-identity
# {
#   "UserId": "AIDA...",
#   "Account": "199167114788",
#   "Arn": "arn:aws:iam::199167114788:user/iamadmin"
# }

5. Workflow Terraform quotidien

# Matin — ouvrir la session
awslogin
# → entrer MFA une fois

# Lancer l'infrastructure
cd terraform/vpc && terraform apply -auto-approve
cd terraform/eks && terraform apply \
  -var="vpc_id=VPC_ID" \
  -var='private_subnet_ids=["SUBNET_1","SUBNET_2"]' \
  -auto-approve

# Configurer kubectl
aws eks update-kubeconfig \
  --region eu-west-3 \
  --name fastapi-eks-cluster

# Vérifier le cluster
kubectl get nodes

# Soir — OBLIGATOIRE dĂ©truire pour Ă©viter les coĂ»ts
cd terraform/eks && terraform destroy \
  -var="vpc_id=VPC_ID" \
  -var='private_subnet_ids=["SUBNET_1","SUBNET_2"]' \
  -auto-approve
cd terraform/vpc && terraform destroy -auto-approve

Ajouter la section workflow Terraform complet

Déploiement complet

cd terraform
terraform apply -auto-approve   # create_nat_gateway=true dans tfvars

aws eks update-kubeconfig --region eu-west-3 --name fastapi-eks-cluster

kubectl get nodes

6. Commandes utiles

# Voir les sessions aws-vault actives
aws-vault list

# Supprimer une session expirée
aws-vault clear devops-portfolio

# Voir les credentials actuels
aws sts get-caller-identity

# Vérifier la région configurée
aws configure get region

7. Ressources AWS créées par Terraform

Module Ressources Coût/heure
Backend S3 bucket ~0$
VPC VPC, subnets, IGW, NAT GW ~0.05$/h
EKS Cluster + nodes t3.medium ~0.15$/h
RDS db.t3.micro PostgreSQL ~0.02$/h
ECR Registry ~0$

CoĂ»t total si allumĂ© 4h/jour : ~0.88$/jour → ~26$/mois


8. Budget Alert configuré

AWS Console → Billing → Budgets
→ devops-portfolio-budget : 50$
→ Alertes : 5€, 15€, 20€, 40€ (forecasted)

9. MFA obligatoire sur iamadmin (constat AW-9)

Depuis le 2026-08-29, une politique IAM refuse toute action de iamadmin en l'absence de MFA. Avant, le MFA était une habitude de travail. C'est désormais un contrÎle.

Code : terraform/modules/iam/main.tf, ressource aws_iam_policy.force_mfa, appliquée par la stack terraform/persistent.

Ce que ça change au quotidien

Rien, si tu ouvres ta session aws-vault le matin comme la section 3 le décrit. Dans une session MFA, aws:MultiFactorAuthPresent vaut true, la condition du refus est fausse, et rien n'est bloqué.

Ce qui est fermé, c'est le chemin de la clé d'accÚs statique utilisée seule, sans second facteur. C'est exactement la menace du constat AW-9.

Deux détails d'écriture qui décident de tout

BoolIfExists et non Bool. La clé de condition a trois états, pas deux : true en session MFA, false en session sans MFA, et absente avec une clé de longue durée. Avec Bool, une condition dont la clé est absente n'est pas évaluée : le refus ne se déclenche pas, et on croit le contraire. C'est précisément le cas d'usage à bloquer.

Neuf actions exclues du refus, dont sts:GetSessionToken. C'est l'appel qui échange une clé statique contre une session MFA, et il se fait forcément sans contexte MFA. Le refuser fermerait la boucle : plus rien sans MFA, et plus de MFA obtenable. Les huit autres couvrent l'enrÎlement, la lecture de son propre compte et le changement de mot de passe.

Preuve du contrĂŽle

Une politique posée n'est pas une politique qui mord. Les deux tests, joués le 2026-08-29 avec la clé brute (env -u AWS_SESSION_TOKEN) :

Test Action Attendu Obtenu
Négatif aws iam list-users refus AccessDenied, la politique nommée dans le message
Positif aws iam list-mfa-devices passe la liste s'affiche, la porte de sortie fonctionne

Le message de refus cite explicit deny in an identity-based policy: .../fastapi-eks-force-mfa, donc la cause est nommée et non déduite.

Si tu perds ta session MFA

La clé statique garde le droit de s'enrÎler et d'appeler sts:GetSessionToken. Tu n'es donc jamais enfermé dehors, c'est le sens des neuf exclusions.

🛑 Ne pas supprimer la clĂ© d'accĂšs de iamadmin

La question s'est posée le 2026-08-29 : une clé qui n'existe pas ne s'utilise avec aucun facteur, donc la supprimer fermerait AW-9 plus fort qu'une politique. La réponse est non, et voici pourquoi, pour ne pas rejouer le débat.

aws-vault stocke cette clé statique, chiffrée sur disque (section 1 de ce document). C'est elle qu'il présente chaque matin à sts:GetSessionToken, avec le code MFA, pour obtenir la session de 12 h :

clĂ© statique (chiffrĂ©e par aws-vault) + code MFA → sts:GetSessionToken → session 12 h

Cette clé est la racine de tout l'accÚs CLI : awslogin, Terraform, le teardown.

Une distinction Ă  garder au clair, parce qu'elle est contre-intuitive :

Suppression envisagée Console web CLI / API
Le compte iamadmin cassée cassée
Sa clé d'accÚs seulement intacte cassée, aws-vault en dépend

La console n'utilise pas la clé d'accÚs, elle utilise mot de passe et MFA. Ce n'est donc pas la console qui impose de garder la clé, c'est aws-vault.

Ce qui rendrait la suppression possible : une migration vers IAM Identity Center (ex-AWS SSO), oĂč la CLI obtient ses identifiants d'une connexion plutĂŽt que d'une clĂ© stockĂ©e. Chantier Ă  part entiĂšre. Ne pas supprimer cette clĂ© avant.


10. CloudTrail, le socle de trace du compte (constat AW-1)

Depuis le 2026-08-29. Avant, le compte n'avait aucune trace d'appel API : rien n'aurait dit qu'une clé avait été utilisée, ni par qui, ni pour quoi.

Code : terraform/modules/cloudtrail/, appliqué par la stack terraform/persistent.

Pourquoi c'est un socle et pas une brique parmi d'autres

Sans trace, aucun service de détection ne sert à rien, puisqu'il n'y a rien à détecter dans.

Et concrÚtement, la réduction de la politique IAM de la CI (#185, constat AW-2) ne peut pas se faire sans lui : elle doit s'appuyer sur la liste des appels réellement émis par ces clés. Sans CloudTrail, elle devine. C'est pour ça que #187 passe avant #185 dans le sprint.

Ce qui est activé, et ce qui ne l'est pas

Réglage Valeur Pourquoi
is_multi_region_trail true un appel dans une région non couverte ne laisserait rien
include_global_service_events true sans ce drapeau, les appels IAM n'apparaissent pas, donc ceux qui comptent le plus ici
enable_log_file_validation true empreinte signée de chaque fichier : permet de prouver qu'un journal n'a pas été altéré aprÚs coup
data events non activés facturés à l'événement. Les événements de gestion du premier trail d'un compte sont gratuits, et le constat porte sur « qui a appelé quoi »

Le bucket de destination

Chiffré en AES256, tous les accÚs publics bloqués, et une politique de bucket qui n'autorise que le service CloudTrail à écrire.

Cette politique porte une condition aws:SourceArn sur le trail. Sans elle, le service CloudTrail d'un autre compte AWS pourrait écrire dans ce bucket : le principal est un service, il n'identifie pas à lui seul le compte appelant. C'est le motif dit du « député confus ».

Une expiration à 90 jours, posée dÚs l'origine

Le mĂȘme audit corrige, Ă  l'action 15, un groupe de logs Ă  rĂ©tention illimitĂ©e qui facture Ă  vie. CrĂ©er ce bucket sans terme reproduirait Ă  neuf le dĂ©faut qu'on est en train de corriger.

La durée se rÚgle par log_retention_days.

Preuve du contrĂŽle, 2026-08-29

Un trail actif n'est pas un trail qui trace. La preuve demandée par l'audit est de retrouver un appel identifié, choisi à l'avance, dans les fichiers du bucket.

Appel témoin : aws iam get-user --user-name preuve-187-165742, sur un utilisateur qui n'existe pas. Il ne crée rien, et son nom unique le rend retrouvable parmi des milliers de lignes.

Retrouvé dans AWSLogs/<compte>/CloudTrail/us-east-1/2026/08/29/..._20260829T1700Z_....json.gz :

{
  "eventTime": "2026-08-29T16:57:45Z",
  "eventName": "GetUser",
  "eventSource": "iam.amazonaws.com",
  "awsRegion": "us-east-1",
  "userIdentity": "arn:aws:iam::<compte>:user/iamadmin",
  "sourceIPAddress": "<ip du poste>",
  "requestParameters": { "userName": "preuve-187-165742" },
  "errorCode": "NoSuchEntityException"
}

L'appel a été émis depuis eu-west-3 et il est enregistré en us-east-1. C'est la démonstration concrÚte de la section précédente : IAM est un service global, ses événements partent vers us-east-1, et un trail mono-région n'aurait rien vu de cet appel.

Deux détails qui coûtent du temps si on ne les connaßt pas :

  • Un trail neuf ne livre pas tout de suite. Un premier tĂ©moin Ă©mis 4 minutes aprĂšs la crĂ©ation du trail n'est jamais apparu, et n'apparaĂźtra pas. Compter jusqu'Ă  15 minutes de dĂ©marrage. Une fois chaud, la livraison a pris 6 minutes.
  • Ne jamais prouver ceci avec aws cloudtrail lookup-events. AWS conserve un historique d'Ă©vĂ©nements de 90 jours par dĂ©faut, sans aucun trail : cette commande rĂ©pondrait mĂȘme si le trail Ă©tait cassĂ©. Elle ne discrimine pas. La preuve vient du bucket, ou elle ne vient pas.

Ce que la trace a montré dÚs le premier jour

En moins de quatre heures, la trace a répondu à une question qu'on ne pouvait pas poser la veille : une activité réguliÚre, toutes les 20 à 40 minutes, dans des régions inutilisées (eu-north-1, us-east-1), sur des services jamais employés ici (Batch, Glue, Ground Station, Image Builder, Redshift).

Identifiée sans ambiguïté grùce au champ userIdentity : AWS Resource Explorer (AWSServiceRoleForResourceExplorer), le service qui indexe les ressources du compte pour la recherche. Légitime, rien à faire.

Le point n'est pas la découverte, c'est qu'elle était invisible jusqu'à ce jour. C'est exactement ce que décrit le constat AW-1.

L'arbitrage sur le chiffrement, pour qu'il puisse ĂȘtre rĂ©examinĂ©

tfsec demande une clĂ© KMS dĂ©diĂ©e sur le trail. ÉcartĂ©, avec le motif Ă©crit dans le code :

  • les journaux sont chiffrĂ©s au repos, le bucket porte SSE-S3
  • ce que KMS ajouterait, c'est une seconde barriĂšre d'autorisation, utile contre un attaquant qui aurait dĂ©jĂ  la lecture S3
  • coĂ»t d'environ 1 $/mois par clĂ©, sur un budget projet de 50 $
  • cohĂ©rence : le bucket de state Terraform, qui porte des donnĂ©es plus sensibles que ces journaux, a tranchĂ© le mĂȘme arbitrage dans le mĂȘme sens

Si le compte devient multi-utilisateur, cet arbitrage se rouvre.


11. La rétention du journal du control plane EKS (constat AW-6)

Action 15 de l'audit, seconde moitié de l'issue #187.

Le control plane est la partie du cluster gérée par AWS : le serveur d'API, l'authentifieur, le contrÎleur. Le module EKS lui demande d'écrire trois familles de journaux (api, audit, authenticator) dans CloudWatch Logs, via enabled_cluster_log_types.

Le constat : un défaut, pas une décision

Ces journaux atterrissent dans un log group (le conteneur qui regroupe des journaux de mĂȘme origine) nommĂ© /aws/eks/fastapi-eks-cluster/cluster. EKS le crĂ©e tout seul Ă  la premiĂšre Ă©criture, et un groupe créé ainsi naĂźt avec une rĂ©tention Ă  None.

None veut dire conservé pour toujours. Personne n'a choisi cette durée, c'est le défaut.

Deux conséquences se cumulent :

  • le groupe survit Ă  la destruction du cluster, donc il n'est jamais nettoyĂ© par le teardown du soir ;
  • il facture Ă  vie, sur un projet dont le cluster n'existe que quelques heures par semaine.

Pourquoi la ressource vit dans la stack persistent

CloudTrail est dans persistent parce qu'une trace qui disparaßt au teardown ne trace rien. Ce groupe-ci y est pour la raison inverse : il survit déjà, et c'est justement le problÚme.

Le dĂ©clarer dans ephemeral le ferait dĂ©truire Ă  chaque terraform destroy du soir. On effacerait le journal du control plane Ă  chaque fin de session — or cette trace qui survit Ă  la destruction du cluster est prĂ©cisĂ©ment le point fort mesurĂ© par l'audit face au homelab.

La stack persistent n'est jamais dĂ©truite. C'est le seul endroit oĂč la ressource est cohĂ©rente.

Ce que Terraform ajoute par rapport Ă  la commande

La commande d'urgence existe et fait le travail :

aws logs put-retention-policy \
  --log-group-name /aws/eks/fastapi-eks-cluster/cluster \
  --retention-in-days 90

Mais elle est jouée une fois, par une personne, et rien ne la rejoue. La case de l'issue #187 demande explicitement les deux : la rétention visible cÎté AWS, et portée par le code.

Le paramĂštre se rĂšgle par eks_log_retention_days, Ă  90 jours par dĂ©faut. Quatre-vingt-dix jours couvrent un trimestre d'investigation a posteriori, ce qui est la durĂ©e dĂ©jĂ  retenue pour l'expiration du bucket CloudTrail — mĂȘme question, mĂȘme rĂ©ponse.

L'adoption du groupe existant, Ă  jouer une fois

Le groupe existe déjà cÎté AWS. Un terraform apply voudrait donc le créer et sortirait ResourceAlreadyExistsException. Il faut l'adopter dans l'état Terraform avant :

cd terraform/persistent
terraform import aws_cloudwatch_log_group.eks_cluster /aws/eks/fastapi-eks-cluster/cluster
terraform apply

L'état de persistent n'étant jamais détruit, cet import ne se rejoue pas.

Un bloc import {} aurait rendu ce geste dĂ©claratif, donc impossible Ă  oublier. ÉcartĂ© aprĂšs mesure : le parser HCL de tfsec ne connaĂźt pas ce type de bloc et sort Unsupported block type, ce qui fait Ă©chouer le scan de tout terraform/. Le job tfsec-scan Ă©tant allow_failure: true, la couverture de sĂ©curitĂ© de l'infrastructure entiĂšre aurait disparu sans faire rougir la moindre MR. Un garde-fou dĂ©claratif qui aveugle un autre garde-fou n'est pas un gain.

Preuve du contrĂŽle, 2026-09-01

Le contrÎle n'est pas « la ressource est dans le code », c'est la valeur lue cÎté AWS :

aws logs describe-log-groups \
  --log-group-name-prefix /aws/eks/fastapi-eks-cluster \
  --query 'logGroups[].[logGroupName,retentionInDays]' --output table

Avant, puis aprĂšs :

|  /aws/eks/fastapi-eks-cluster/cluster  |  None |
|  /aws/eks/fastapi-eks-cluster/cluster  |  90   |

terraform apply : 0 added, 1 changed, 0 destroyed, en updated in-place. Le groupe n'a pas Ă©tĂ© recréé, les journaux existants sont intacts. Un must be replaced aurait dĂ» faire arrĂȘter.

L'ordre des deux gestes se lit dans le plan. put-retention-policy a Ă©tĂ© jouĂ© avant terraform import, donc Terraform a importĂ© une ressource dĂ©jĂ  conforme : le plan ne portait que les trois tags, la rĂ©tention restant dans les « unchanged attributes ». Dans l'autre ordre, l'apply aurait eu Ă  corriger la rĂ©tention lui-mĂȘme.

L'apply a été joué en -target=aws_cloudwatch_log_group.eks_cluster. La stack persistent porte aussi module.secrets : le -target le sort du graphe, donc aucune variable ne pouvait atteindre un secret. -target est déconseillé en usage courant, c'est ici la propriété cherchée.

Pourquoi cette action attendait le 01/09

Le contenu sans terme de ce groupe est la matiÚre du scénario purple team PT-7 du rapport d'audit. On ne raccourcit pas la conservation d'une preuve avant d'avoir rendu le rapport qui s'appuie dessus.

Le rapport étant livré, la contrainte tombe. Poser 90 jours ne supprime d'ailleurs rien aujourd'hui : cela borne ce qui sera conservé à partir de maintenant.


12. Le pont ~/.aws, et quand un fichier de session est vraiment nécessaire

Cette section existe parce que la procédure était connue et nulle part écrite. Elle a coûté une soirée le 2026-08-31, sur trois piÚges qui se ressemblent tous.

L'outillage est réparti, aucun environnement n'a tout

   WSL                                  Dev Container
   ───                                  ─────────────
   glab ✔    docker ✔                   aws-vault ✔   terraform ✔   aws ✔
   aws ✔     terraform ✔                (pas de glab, pas de docker)

   dépÎt : ~/Project-DEVOPS-FASTAPI-2026    dépÎt : /workspace
                     │                                │
                     └──────────  ~/.aws  ────────────┘
                     monté sur /home/devops/.aws

aws-vault (le coffre qui garde la clé AWS chiffrée sur disque) n'existe que dans le Dev Container. glab n'existe que dans WSL. Le dossier ~/.aws est monté des deux cÎtés : c'est le seul point de passage entre les deux.

Les deux terraform ne sont pas non plus la mĂȘme version : 1.14.3 en WSL, 1.15 dans la CI et dans le Dev Container. Un state Ă©crit par 1.15 ne se lit pas avec 1.14. Depuis WSL, passer par l'image de la CI plutĂŽt que par le binaire local :

docker run --rm -v "$PWD":/src -w /src/terraform/persistent \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
  -e AWS_REGION -e AWS_DEFAULT_REGION \
  --entrypoint terraform hashicorp/terraform:1.15@sha256:adae4566... plan -input=false

Les -e NOM sans valeur reprennent celle du shell : aucun secret ne passe par la ligne de commande.

Conséquence directe, mesurée le 2026-08-31 : un export TF_VAR_x="$(glab variable get ...)" joué dans le Dev Container échoue en silence. glab n'y est pas, la substitution rend une chaßne vide, et Terraform accepte TF_VAR_x="" comme une valeur fournie. Les secrets sont alors calculés avec du vide, sans une seule erreur à l'écran.

Le cas normal : pas de fichier du tout

awslogin ouvre un nouveau shell avec les identifiants déjà dedans, valables 12 h :

awslogin          # = aws-vault exec devops-portfolio --duration=12h -- bash

Tout ce qui est lancé depuis ce shell hérite des identifiants. Terraform et aws compris.

C'est le cas par dĂ©faut, et il ne demande aucun fichier de session. Si le travail tient dans ce shell, s'arrĂȘter ici.

Le cas particulier : faire sortir les identifiants de ce shell

~/.aws/session-aws.env sert à une seule chose : rendre les identifiants utilisables ailleurs que dans le shell awslogin — depuis WSL, ou par un agent qui n'ouvre pas ce shell.

Trois piÚges, tous rencontrés :

PiĂšge Ce qu'on voit La rĂšgle
Pas de préfixe export Terraform ne voit pas la variable aws-vault --format=env produit CLE=valeur, que source définit dans le shell sans la transmettre aux processus enfants
Fichier tronqué à 0 octet le fichier existe et est vide aws-vault export refuse de tourner à l'intérieur d'une session aws-vault, alors que le > a déjà vidé le fichier
Jetons périmés ExpiredToken sur une commande qui marchait hier les jetons STS durent 36 h au maximum
Nom de profil trompeur profile iamadmin: credentials missing le profil iamadmin ne porte aucune clĂ©. Celui qui rĂ©sout vers user/iamadmin s'appelle devops-portfolio — aws-vault list le montre, colonne Credentials
Déjà dans un sous-shell in an existing aws-vault subshell les identifiants sont déjà dans l'environnement : les recopier. Le unset AWS_VAULT que le message propose ouvre une session STS de plus, avec MFA à ressaisir, pour ce qu'on a déjà
export nu en tĂȘte de fichier chaque source crache un declare -x de tout l'environnement aws-vault sort une ligne vide, qu'un sed 's/^/export /' transforme en export sans argument. Filtrer sur ^[A-Z_]+= avant de prĂ©fixer

Les deux premiers ont la mĂȘme parade : Ă©crire dans un fichier temporaire, et ne le dĂ©placer Ă  sa place qu'une fois la collecte rĂ©ussie. Un > en tĂȘte de commande dĂ©truit l'ancien contenu avant de savoir si le nouveau existera.

Le script qui tranche entre les deux cas

~/.aws/gen-session-aws.sh — hors dĂ©pĂŽt, il vit dans ~/.aws, donc visible des deux cĂŽtĂ©s du pont. Écrit le 2026-09-01, aprĂšs avoir repayĂ© les mĂȘmes piĂšges une session de plus.

bash ~/.aws/gen-session-aws.sh              # profil par défaut : iamadmin
bash ~/.aws/gen-session-aws.sh devops-portfolio

Il choisit sa branche tout seul, sur $AWS_VAULT :

$AWS_VAULT Situation Ce qu'il fait
non vide on est dans un shell awslogin recopie les variables AWS_* de l'environnement, aucun appel Ă  aws-vault, pas de MFA
vide shell normal aws-vault export --format=env <profil>

Puis, dans les deux cas : collecte avant toute redirection (le > ne peut pas vider le fichier sur un échec), abandon si la collecte est vide, filtrage des lignes qui ne sont pas des affectations, préfixe export, chmod 600.

Il termine en affichant l'identité réellement obtenue et sa date d'expiration :

/home/devops/.aws/session-aws.env ecrit : 6 variables
identite   : arn:aws:iam::199167114788:user/iamadmin
expire     : 2026-09-02T00:03:16Z

⚠ C'est la ligne identite qui fait foi, jamais le profil demandĂ©. Trois profils existent pour deux identitĂ©s utiles, et l'un d'eux (iamadmin) est vide et dĂ©tourne du bon. Un nom de profil n'est pas une identitĂ© : seul sts get-caller-identity tranche.

Les variables Terraform, et celle qui n'est pas dans GitLab

Mis Ă  jour par l'ADR 033 (#198) : persistent ne rĂ©clame plus qu'une variable, TF_VAR_cost_alert_email. Les quatre ci-dessous restent utiles — TF_VAR_db_password pour la stack ephemeral (RDS), les trois autres pour scripts/seed-secrets.sh — mais Terraform ne les lit plus sur persistent. Voir la section 14.

La stack persistent réclamait cinq variables sans valeur par défaut. Quatre vivent dans les variables CI du projet et se lisent avec glab, donc depuis WSL :

TF_VAR_db_password   TF_VAR_slack_webhook_url
TF_VAR_argocd_admin_bcrypt   TF_VAR_argocd_gitlab_client_secret

La cinquiĂšme, TF_VAR_cost_alert_email, n'y est pas — le dĂ©pĂŽt est public, et c'est Ă©crit dans terraform/persistent/variables.tf. Elle se saisit Ă  la main.

Les cinq sont rĂ©clamĂ©es mĂȘme avec -target : Terraform valide la configuration entiĂšre avant d'appliquer le filtre.

Le garde-fou Ă  poser avant de poser des valeurs

⚠ Le motif de ce garde-fou a changĂ© avec l'ADR 033. Avant, une variable vide passĂ©e Ă  terraform apply sur persistent Ă©crasait un secret. Ce n'est plus possible : module.secrets ne reçoit plus aucune valeur. Le risque a dĂ©mĂ©nagĂ© vers scripts/seed-secrets.sh, qui porte le mĂȘme contrĂŽle en interne et refuse d'Ă©crire si une des quatre est vide.

Le garde-fou reste utile Ă  la main, avant de lancer le script :

for v in TF_VAR_db_password TF_VAR_slack_webhook_url TF_VAR_argocd_admin_bcrypt \
         TF_VAR_argocd_gitlab_client_secret TF_VAR_cost_alert_email; do
  [ -n "${!v:-}" ] || echo "VIDE : $v"
done

⚠ Ce contrĂŽle vĂ©rifie que la variable n'est pas vide, pas qu'elle est juste. Une valeur bidon passerait. Ne jamais en mettre une pour « faire passer » la commande.

Deux réflexes qui vont avec

  • terraform plan -input=false plutĂŽt que terraform plan. Terraform prend le verrou d'Ă©tat avant de demander une variable manquante : un plan interrompu Ă  l'invite laisse le verrou posĂ©. Avec ce drapeau, il Ă©choue au lieu d'attendre. Sortie par force-unlock, aprĂšs avoir vĂ©rifiĂ© qu'aucun processus ne tourne — terraform-ls, le serveur de langage de l'Ă©diteur, ne compte pas.
  • kubectl est un autre sujet et un autre fichier. L'identitĂ© du cluster est youss_admin, pas iamadmin : voir docs/validation-runbook.md section 0.

13. La politique de mot de passe du compte (constat AW-7)

Action 9 de l'audit. À lire avec la section 9 : mĂȘme surface, mĂȘme utilisateur.

Ce que c'est

La politique de mot de passe IAM est un réglage unique et global. Une seule peut exister par compte AWS, et elle s'applique à tout mot de passe de console créé dessus, quel que soit l'utilisateur.

Elle impose la longueur, la présence de majuscules, minuscules, chiffres, symboles, l'expiration et la non-réutilisation.

Le constat, et la question que le contrĂŽle ne pose pas

Sept contrÎles CIS en échec, un par rÚgle : le réglage n'existait pas du tout, pour personne.

Reste la question que l'outil ne pose jamais — un rĂ©glage absent, qui protĂ©gerait-il s'il existait ? D'oĂč la vĂ©rification manquante, aws iam get-login-profile, la commande qui dit si un utilisateur IAM possĂšde un mot de passe de console. RejouĂ©e sur les cinq utilisateurs du compte le 2026-08-19 :

Utilisateurs du compte Avec mot de passe console
5 1

Un seul, iamadmin. Les quatre autres sont des comptes machine : ils s'authentifient par clé d'accÚs, sans mot de passe de console, donc sans rien qu'une telle politique puisse encadrer.

Ce qui change la lecture du constat : il ne concerne qu'un compte, et c'est celui qui porte AdministratorAccess. Un constat étroit et net vaut mieux qu'un constat large et creux.

Ce qui est posé

RĂšgle Valeur
Longueur minimale 14
Majuscule, minuscule, chiffre, symbole exigés
Réutilisation interdite sur les 24 derniers
L'utilisateur peut changer son mot de passe oui
Expiration 90 jours — voir l'arbitrage ci-dessous

L'arbitrage sur l'expiration, pour qu'il puisse ĂȘtre rĂ©examinĂ©

C'est le seul des sept réglages qui se discute.

Le NIST SP 800-63B dĂ©conseille la rotation pĂ©riodique depuis 2017. Elle pousse aux mots de passe dĂ©rivĂ©s — Motdepasse1!, puis Motdepasse2! — et dĂ©grade ce qu'elle prĂ©tend renforcer. CIS AWS Foundations v3.0.0 a retirĂ© ce contrĂŽle pour cette raison.

Elle est retenue quand mĂȘme, sur deux motifs propres Ă  ce compte :

  • le rĂ©fĂ©rentiel outillĂ© du projet compte sept contrĂŽles, et un huitiĂšme rouge sans explication coĂ»terait plus Ă  relire que la rĂšgle ne coĂ»te Ă  subir ;
  • le seul compte concernĂ© est protĂ©gĂ© par MFA (section 9), donc le mot de passe seul n'ouvre rien.

Si le projet s'aligne explicitement sur CIS v3.0.0, cette ligne se retire du Terraform.

Preuve du contrĂŽle, 2026-09-01

Le contrĂŽle n'est pas le plan Terraform, c'est la valeur lue sur AWS :

aws iam get-account-password-policy
{
  "PasswordPolicy": {
    "MinimumPasswordLength": 14,
    "RequireSymbols": true,
    "RequireNumbers": true,
    "RequireUppercaseCharacters": true,
    "RequireLowercaseCharacters": true,
    "AllowUsersToChangePassword": true,
    "ExpirePasswords": true,
    "MaxPasswordAge": 90,
    "PasswordReusePrevention": 24
  }
}

terraform apply -target=module.iam.aws_iam_account_password_policy.main : 1 to add, 0 to change, 0 to destroy.

hard_expiry, le réglage qui pouvait fermer le compte

HardExpiry n'apparaĂźt pas dans la sortie ci-dessus : AWS l'omet quand il vaut false.

Ce champ décide de ce qui arrive quand un mot de passe expire :

Valeur Effet Ă  l'expiration
false l'utilisateur se connecte encore, et change son mot de passe lui-mĂȘme
true il est verrouillé dehors, seul un administrateur peut le débloquer

Sur ce compte, true serait un piÚge : iamadmin est le seul compte humain, et c'est lui l'administrateur. Personne ne pourrait le débloquer. Combiné aux 90 jours ci-dessus, la porte du compte AWS se refermerait sans clef.

false est déjà le comportement par défaut. Il est néanmoins écrit explicitement dans le Terraform : on ne fait pas reposer l'accÚs au compte sur un défaut implicite, que le prochain lecteur ne verra pas. La ligne coûte moins cher que la panne.


14. Les valeurs des secrets, posées hors Terraform (ADR 033)

Depuis l'ADR 033 (#198), Terraform déclare les sept secrets Secrets Manager mais n'en connaßt plus la valeur. random_password et aws_secretsmanager_secret_version ont été retirés du module, parce qu'ils stockaient la valeur en clair dans le state.

Le partage tient en une phrase : Terraform crée le contenant, scripts/seed-secrets.sh pose le contenu.

Ce que ça change au quotidien

Situation Avant AprĂšs
apply from-scratch de persistent les valeurs naissent seules les sept secrets naissent vides
SymptĂŽme si on oublie de les poser — ESO ne rĂ©sout pas, pods en CreateContainerConfigError
Variables réclamées par persistent cinq une (TF_VAR_cost_alert_email)

Le symptÎme d'oubli ne dit pas ce qui manque. C'est pour ça que le contrÎle ci-dessous fait partie du montage, et pas d'un aide-mémoire.

Poser les valeurs

# 4 valeurs externes attendues dans l'environnement (gen-tfvars.sh les exporte déjà)
bash scripts/seed-secrets.sh --check    # n'écrit rien, dresse l'état des sept secrets
bash scripts/seed-secrets.sh --seed     # pose les valeurs MANQUANTES, puis relit

⚠ Depuis le 2026-09-06, les SEPT secrets portent une politique de ressource qui refuse la lecture Ă  iamadmin (#193, Ă©tendu depuis grafana seul). Le script sort alors ABANDON dĂšs le premier secret rencontrĂ© et s'arrĂȘte lĂ , sans vĂ©rifier les six suivants. Ce n'est pas une panne, c'est la barriĂšre qui fonctionne.

Conséquence pratique : sous iamadmin, --check ne rend plus aucune information utile. Le rÎle n'est plus un contournement occasionnel, c'est le mode normal d'usage du script.

Il faut assumer le rÎle prévu pour ça :

export SECRETS_READER_ROLE_ARN=$(cd terraform/persistent && terraform output -raw secrets_reader_role_arn)
bash scripts/seed-secrets.sh --check

Le script affiche l'identité effective en premiÚre ligne : elle doit se lire assumed-role/fastapi-eks-secrets-reader/..., et non user/iamadmin. C'est elle qui tranche, pas la présence de la variable.

iamadmin peut s'attribuer ce rÎle : la barriÚre ne rend pas la lecture impossible, elle la rend délibérée et tracée dans CloudTrail.

⚠ Le tĂ©moin du 2026-09-04 n'existe plus. Il reposait sur fastapi-eks/app, alors non protĂ©gĂ© et lisible sans le rĂŽle. Un tĂ©moin est un cas dont on connaĂźt dĂ©jĂ  la rĂ©ponse : il ne teste pas la barriĂšre, il teste le test. Sans lui, un refus peut aussi bien venir d'une clĂ© expirĂ©e.

Ce n'est pas une rĂ©gression, c'est un tĂ©moin gratuit qui disparaĂźt. Le remplaçant est plus juste : comparer avec et sans le rĂŽle sur le MÊME secret, une seule variable changeant, l'identitĂ©. L'ancien changeait de secret et supposait qu'un secret reste non protĂ©gĂ©, ce qui Ă©tait provisoire par nature.

aws secretsmanager get-secret-value --secret-id fastapi-eks/prod   # sans le rĂŽle -> AccessDenied
aws secretsmanager get-secret-value --secret-id fastapi-eks/prod   # avec le rĂŽle -> passe

Seul changement réel au quotidien : il faut désormais y penser. Avant, un secret non protégé traßnait et rendait le service tout seul.

Mesuré le 2026-09-06, les trois sens : iamadmin refusé sur app, dev, argocd et grafana ; les sept lisibles via secrets-reader ; les sept ExternalSecret resynchronisés par ESO aprÚs la pose. Détail et schémas : Politique de ressource sur les secrets.

--seed ne réécrit jamais une valeur dĂ©jĂ  posĂ©e. La réécriture demande --force, et c'est une rotation, pas une rĂ©paration : changer fastapi-eks/{dev,staging,prod} change le mot de passe attendu des users PostgreSQL, et le Job de bootstrap DB doit ĂȘtre rejouĂ© derriĂšre.

Les huit valeurs générées le sont par openssl rand -hex : alphanumérique comme le special = false d'avant, et 256 bits pour les SECRET_KEY, la taille exacte d'une clé HS256.

La bascule elle-mĂȘme, Ă  ne jouer qu'une fois

⚠ L'ordre est contraint, et il n'y a pas d'apply au milieu. Les sept secrets portent recovery_window_in_days = 0 : une suppression est immĂ©diate et sans retour.

# 1. Sortir les 15 ressources porteuses de valeur, en UNE commande
cd terraform/persistent
terraform state rm \
  'module.secrets.random_password.secret_key' \
  'module.secrets.random_password.grafana_admin' \
  'module.secrets.random_password.db_env["dev"]' \
  'module.secrets.random_password.db_env["staging"]' \
  'module.secrets.random_password.db_env["prod"]' \
  'module.secrets.random_password.secret_key_env["dev"]' \
  'module.secrets.random_password.secret_key_env["staging"]' \
  'module.secrets.random_password.secret_key_env["prod"]' \
  'module.secrets.aws_secretsmanager_secret_version.app' \
  'module.secrets.aws_secretsmanager_secret_version.grafana' \
  'module.secrets.aws_secretsmanager_secret_version.alertmanager_slack' \
  'module.secrets.aws_secretsmanager_secret_version.argocd' \
  'module.secrets.aws_secretsmanager_secret_version.env["dev"]' \
  'module.secrets.aws_secretsmanager_secret_version.env["staging"]' \
  'module.secrets.aws_secretsmanager_secret_version.env["prod"]'

# 2. SEULEMENT ensuite, prendre le code qui retire les blocs (la MR de #198)

# 3. Le plan doit ĂȘtre vide
terraform plan -input=false      # -> No changes

Jouer 2 avant 1, ou glisser un apply entre les deux, détruit les sept versions chez AWS : Terraform les a encore dans son state et ne les voit plus dans la configuration.

La preuve, et ce qu'elle ne couvre pas

Un plan vide ne prouve pas que le state est propre. La vérification se fait sur le fichier :

aws s3 cp s3://yk-devops-terraform-state/fastapi-eks/persistent/terraform.tfstate - \
  | jq -r '.resources[] | select(.type=="random_password" or .type=="aws_secretsmanager_secret_version") | .type'
# -> aucune ligne

⚠ Le state reste nĂ©anmoins porteur de secrets. module.iam crĂ©e trois aws_iam_access_key (gitlab_ci, gitlab_ci_infra, cockpit_panel) dont l'attribut secret est stockĂ© en clair par construction du provider. Le nettoyage ci-dessus vaut au pĂ©rimĂštre des sept secrets applicatifs, pas au-delĂ . Les anciennes versions du bucket portent par ailleurs les valeurs d'avant : c'est #199.


⚠ RĂšgles d'or

1. Jamais laisser EKS/NAT allumés la nuit
2. Jamais committer de credentials AWS
3. Toujours utiliser aws-vault (jamais aws configure)
4. Vérifier aws-vault list avant de partir le soir
5. Ne jamais tester un refus IAM avec `sts:GetCallerIdentity` : il rĂ©ussit mĂȘme quand une politique le refuse explicitement, donc il ne discrimine rien

15. Les clés d'accÚs de la CI, créées hors Terraform (ADR 034)

Depuis l'ADR 034 (#202), Terraform dĂ©clare les trois identitĂ©s CI mais n'en fabrique plus les clĂ©s. aws_iam_access_key et les six outputs correspondants ont Ă©tĂ© retirĂ©s du module, parce que le provider stocke l'attribut secret en clair dans le state — il ne peut pas faire autrement, l'API IAM ne rend le secret qu'une fois, Ă  la crĂ©ation.

Le partage tient en une phrase, symétrique de celle de la section 14 : Terraform déclare l'identité, il n'en connaßt jamais les identifiants.

Les trois identités concernées

Utilisateur IAM Sert Ă  Variables CI
fastapi-eks-gitlab-ci push ECR AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
fastapi-eks-gitlab-ci-infra terraform apply de l'infra AWS_INFRA_ACCESS_KEY_ID, AWS_INFRA_SECRET_ACCESS_KEY
fastapi-eks-cockpit-panel lecture d'état pour le cockpit hors GitLab

Ce que ça change au quotidien

Situation Avant AprĂšs
apply from-scratch de persistent les trois clés naissent seules les utilisateurs naissent sans clé
SymptĂŽme si on oublie de les crĂ©er — premier job AWS en NoCredentials
Rotation d'une clé terraform taint + apply aws iam create-access-key, hors Terraform

Le symptÎme d'oubli est au moins explicite, contrairement à celui de la section 14 : un NoCredentials nomme le problÚme, un ESO qui ne résout pas ne dit pas ce qui manque.

Créer une clé (montage from-scratch, ou rotation)

⚠ L'API IAM ne rend le secret qu'une seule fois. Il n'y a pas de « repose » comme pour un secret Secrets Manager : perdre la valeur entre sa crĂ©ation et sa pose dans GitLab oblige Ă  supprimer la clĂ© et Ă  recommencer.

# 1. Créer la clé et capturer la valeur DANS LA MEME COMMANDE
aws iam create-access-key --user-name fastapi-eks-gitlab-ci-infra \
  --query 'AccessKey.[AccessKeyId,SecretAccessKey]' --output text
# 2. Poser les deux valeurs dans les variables CI (Settings > CI/CD > Variables)
#    AWS_INFRA_* sont Masked ET Protected (#184) : elles n'existent que sur les
#    branches protégées. Une MR depuis une branche de travail sortira NoCredentials,
#    c'est structurel et ce n'est pas une panne.
# 3. Prouver la nouvelle clé par un pipeline VERT avant de toucher à l'ancienne
# 4. Seulement alors, supprimer l'ancienne
aws iam delete-access-key --user-name fastapi-eks-gitlab-ci-infra --access-key-id AKIA_ANCIENNE

L'ordre des étapes 3 et 4 n'est pas négociable. Un utilisateur IAM accepte deux clés simultanées : c'est exactement ce qui permet de valider la nouvelle avant de retirer l'ancienne. Supprimer d'abord, c'est se retrouver sans CI si la nouvelle valeur a été mal collée.

La rotation vient APRÈS la parade, jamais avant

Tant que Terraform fabriquait la valeur, la tourner la faisait revenir dans le state au plan suivant : on tuait des copies anciennes pour en créer une nouvelle, vivante. C'est la raison pour laquelle la rotation de #202 se joue une fois aws_iam_access_key sorti du code.

La preuve, et ce qu'elle ne couvre pas

Le critĂšre de succĂšs est la vĂ©rification d'absence sur le fichier de state tĂ©lĂ©chargĂ©, section outputs comprise — jamais la sortie du plan, jamais le succĂšs de l'apply.

terraform state pull > /tmp/state.json
# 0 ressource aws_iam_access_key, 0 output *_secret_access_key, et la valeur absente du fichier

Ce qui n'est pas couvert : les anciennes versions du state portent toujours les valeurs d'avant la parade. Le bucket est versionnĂ©, et c'est le sujet de #199. La parade arrĂȘte le flux, elle n'efface pas le stock.