Skip to content

Cycle de vie d'un secret (de la variable GitLab au pod)

À qui ça s'adresse. Le projet a changé trois fois ses règles sur les secrets en une semaine (ADR 033, 034, 037). Chaque page existante en couvre un morceau. Celle-ci raconte le trajet complet d'une valeur, du moment où elle naît jusqu'au moment où un pod la lit, et dit qui peut la voir à chaque étape.

0. Le vocabulaire, une fois pour toutes

Terme Ce que c'est
Secrets Manager (ASM) le service AWS qui stocke une valeur secrète et contrôle qui la lit
Secret Kubernetes un objet du cluster qui porte une valeur, monté dans un pod comme variable d'environnement
ESO (External Secrets Operator) l'opérateur qui recopie une valeur d'AWS vers un Secret Kubernetes
ExternalSecret le manifeste qui dit quoi recopier et vers quoi
SecretStore le manifeste qui dit lire et avec quelle identité
IRSA (IAM Roles for Service Accounts) le mécanisme qui donne un rôle AWS à un pod, sans clé d'accès
Politique de ressource une règle attachée au secret lui-même, et non à celui qui demande
State Terraform l'inventaire de ce que Terraform gère. Il contient la valeur de tout ce qu'il gère

1. Les sept secrets, et d'où vient chaque valeur

Le projet a sept secrets dans Secrets Manager. Ils ne se fabriquent pas de la même façon, et c'est cette différence qui décide de tout le reste.

Secret Contenu Origine de la valeur
fastapi-eks/app DB_PASSWORD, SECRET_KEY DB_PASSWORD externe, SECRET_KEY générée
fastapi-eks/dev DB_PASSWORD, SECRET_KEY générées
fastapi-eks/staging DB_PASSWORD, SECRET_KEY générées
fastapi-eks/prod DB_PASSWORD, SECRET_KEY générées
fastapi-eks/grafana admin-user, admin-password, gitlab-oidc-client-secret mot de passe généré, secret OIDC externe
fastapi-eks/alertmanager-slack slack-webhook-url externe
fastapi-eks/argocd admin-password-bcrypt, gitlab-oidc-client-secret externes

Une valeur générée naît d'un openssl rand -hex dans scripts/seed-secrets.sh. Personne ne la connaît, elle n'existe qu'à un seul endroit.

Une valeur externe vient d'ailleurs, parce qu'un tiers la fabrique : Slack pour son webhook, GitLab pour son client secret OIDC, htpasswd pour le bcrypt d'ArgoCD. Elle vit dans une variable CI GitLab (protected + masked) et le script la recopie telle quelle.

flowchart LR
    subgraph externe["Valeurs EXTERNES"]
        SL["Slack<br/>webhook entrant"]
        GL["GitLab<br/>app OAuth du groupe"]
        HT["htpasswd -nbBC 10<br/>en local"]
    end
    VAR["Variables CI GitLab<br/>TF_VAR_*<br/>protected + masked"]
    GEN["openssl rand -hex<br/>dans seed-secrets.sh"]
    ASM[("AWS Secrets Manager<br/>les 7 secrets")]

    SL --> VAR
    GL --> VAR
    HT --> VAR
    VAR -->|"seed-secrets.sh<br/>recopie"| ASM
    GEN -->|"seed-secrets.sh<br/>fabrique"| ASM

Retiens la distinction, tout en découle : on ne tourne pas une valeur générée comme une valeur externe, et on ne les récupère pas au même endroit.

2. Pourquoi la valeur n'est plus dans le state Terraform

C'était le cas jusqu'au 2 septembre 2026, et c'est le sujet des ADR 033 et 034.

Le state est l'inventaire de ce que Terraform gère, et il contient la valeur de chaque attribut. Terraform fabriquait les mots de passe avec random_password et les posait avec aws_secretsmanager_secret_version. Les deux ressources stockent leur valeur en clair dans le state.

Le state vit dans un bucket S3. Il existait donc deux exemplaires de chaque secret : celui de Secrets Manager, protégé, et celui du state, sans aucun contrôle.

Le piège qui vient à l'esprit en premier, et qui ne marche pas

lifecycle { ignore_changes = [secret_string] } empêche Terraform de modifier la ressource, pas de la lire. Au refresh, il appelle GetSecretValue et réécrit la valeur dans le state, à chaque plan. La parade ne réduit rien et donne l'impression du contraire, ce qui est pire que ne rien faire.

La décision de l'ADR 033 : Terraform déclare le secret, il n'en connaît jamais la valeur. Le module terraform/modules/secrets/ ne porte plus que des aws_secretsmanager_secret : un nom, une description, des tags, et le point d'accroche de la politique de ressource.

Ce que ça coûte : la stack ne se reconstruit plus d'un seul apply. Après un terraform apply from-scratch, les sept secrets existent vides, et scripts/seed-secrets.sh est le geste qui les remplit. C'est un arbitrage assumé.

3. Poser les valeurs : seed-secrets.sh

bash scripts/seed-secrets.sh --check          # n'écrit RIEN, dresse l'état
bash scripts/seed-secrets.sh --seed           # pose les valeurs MANQUANTES
bash scripts/seed-secrets.sh --seed --force   # RÉÉCRIT les valeurs existantes

--force seul ne marche pas

Sans --seed, le script ne sait pas quoi faire et sort son aide avec un code 2. Et --force réécrit les douze valeurs, pas seulement celle qui t'intéresse.

Le script a besoin de deux choses dans l'environnement :

  1. les quatre variables externes, que ~/.aws/gen-tfvars.sh récupère depuis GitLab ;
  2. SECRETS_READER_ROLE_ARN, sans quoi sa vérification finale échoue — voir la section 5.

Il se termine toujours par une relecture, et c'est délibéré : le succès d'un put ne prouve rien, seule la relecture compte.

4. De Secrets Manager au pod : ESO

Une valeur dans Secrets Manager n'est utilisable par personne dans le cluster. Il faut qu'elle devienne un Secret Kubernetes, que le pod monte en variables d'environnement. C'est le travail d'ESO.

Deux manifestes, deux questions différentes :

  • SecretStore répond à où je lis, et avec quelle identité ;
  • ExternalSecret répond à quoi je lis, et vers quel Secret Kubernetes.
flowchart TD
    ASM[("Secrets Manager<br/>fastapi-eks/prod")]
    SS["SecretStore<br/>aws-secrets-manager<br/>auth: jwt → SA fastapi"]
    ES["ExternalSecret<br/>fastapi-secrets<br/>key: fastapi-eks/prod"]
    ESO["contrôleur ESO"]
    SEC["Secret K8s<br/>fastapi-secrets"]
    POD["Pod de l'API<br/>envFrom.secretRef"]

    ES -->|"quoi lire"| ESO
    SS -->|"où et avec qui"| ESO
    ESO -->|"assume le rôle IRSA<br/>de l'env"| ASM
    ASM -->|"la valeur"| ESO
    ESO -->|"crée et possède"| SEC
    SEC --> POD

refreshInterval: 1h : ESO relit AWS toutes les heures. Une valeur tournée arrive donc dans le pod au bout d'une heure au plus, pas instantanément.

creationPolicy: Owner : ESO crée et possède le Secret Kubernetes. Supprimer l'ExternalSecret supprime le Secret.

Le placeholder qui fait du bruit exprès

Dans k8s/base/externalsecret.yaml, la clé du secret AWS vaut MUST-BE-OVERRIDDEN-BY-OVERLAY. Chaque overlay d'environnement doit la remplacer par fastapi-eks/<env>.

Laisser fastapi-eks/app par défaut aurait rendu l'oubli silencieux et dangereux : l'environnement serait reparti sur les credentials master de la base historique. Avec un nom qui n'existe pas, l'oubli se voit tout de suite — ESO ne résout rien, le Secret n'est pas créé, et le pod reste bloqué au démarrage.

Un défaut qui marche à moitié est pire qu'une panne franche.

5. Qui peut lire quoi : trois mécanismes empilés

C'est le point qui perd le plus souvent, parce que trois contrôles différents s'appliquent à la même valeur.

flowchart TD
    Q{"Qui demande<br/>GetSecretValue ?"}
    P1["1. La politique de RESSOURCE (#193)<br/>attachée AU SECRET<br/>Deny sauf principaux nommés"]
    P2["2. La politique d'IDENTITÉ<br/>attachée au rôle IRSA<br/>Allow sur les ARN listés"]
    P3["3. Le SecretStore en mode jwt (#138)<br/>quel rôle le pod peut assumer"]
    OK(["la valeur"])
    KO(["AccessDenied"])

    Q --> P1
    P1 -->|"principal nommé"| P2
    P1 -->|"sinon"| KO
    P2 -->|"ARN autorisé"| P3
    P2 -->|"sinon"| KO
    P3 -->|"SA du namespace"| OK
    P3 -->|"sinon"| KO

1. La politique de ressource est attachée au secret. C'est un Deny sur GetSecretValue pour tout principal non nommé. Elle refuse iamadmin, l'administrateur du compte. C'est voulu, c'est le sujet de Politique de ressource sur les secrets.

2. La politique d'identité est attachée au rôle. Le rôle IRSA d'un environnement ne liste que l'ARN du secret de son environnement.

3. Le SecretStore en mode jwt décide quelle identité ESO emprunte. Avant, ESO lisait avec son propre rôle, partagé par tous les namespaces : un ExternalSecret de dev pouvait nommer fastapi-eks/prod et ESO le lui servait. Le nom du secret était la seule séparation, et elle ne vivait que dans un fichier du dépôt. Voir Identités et secrets par environnement.

Le tableau qui répond « qui voit quoi »

Identité Lit les 7 secrets ? Comment
user/iamadmin non refusé par la politique de ressource, mesuré 7 fois sur 7
role/fastapi-eks-secrets-reader oui nommé dans la politique, Get et Put
rôle IRSA d'ESO oui les 7 ARN listés, pour le contrôleur
rôle IRSA app-<env> un seul l'ARN de son environnement
user/gitlab_ci_infra non pas de GetSecretValue dans sa policy

Le rôle secrets-reader ne peut pas s'assumer lui-même

Sa politique de confiance ne nomme que user/iamadmin et youss_admin. Une fois le rôle assumé, retenter un assume-role depuis ce rôle échoue en AccessDenied : c'est de l'enchaînement de rôles, et il n'est pas autorisé. C'était le bug #217.

6. Tourner une valeur

Le geste dépend de l'origine de la valeur, et c'est toute l'utilité de la distinction de la section 1.

Type Où on la change Puis
Générée nulle part, seed-secrets.sh en fabrique une neuve --seed --force
Externe dans l'interface tierce (Slack, GitLab, htpasswd) variable CI, puis --seed --force

Aucun cluster n'est nécessaire. Secrets Manager vit dans la stack persistent, qui survit au teardown. Slack et GitLab sont des services externes.

Mais la rotation n'est pas prouvée tant qu'un consommateur ne l'a pas acceptée. Au montage suivant : ESO résout les sept secrets, le SSO ArgoCD et Grafana passe, Alertmanager poste.

L'ordre compte, et il a coûté cher

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 ajoutait une copie au lieu d'en retirer une.

Ce que tourner une valeur ne fait pas

Changer la valeur dans Secrets Manager ne révoque pas le credential à sa source. Les trois cas diffèrent :

Valeur L'ancienne meurt
Client secret OIDC GitLab automatiquement, au Renew secret
Bcrypt ArgoCD il n'existe qu'ici, il est écrasé
Webhook Slack seulement si tu supprimes l'ancien à la main

Un webhook Slack non supprimé continue de fonctionner. Faire le geste sans le résultat.

7. Le cas à part : le mot de passe master RDS

Tout ce qui précède ne s'applique pas à cette valeur, et c'est la source de confusion la plus fréquente.

Le master user est le compte administrateur de l'instance PostgreSQL. Il ne sert pas à l'application, qui se connecte avec app_dev, app_staging ou app_prod. Il sert à créer les bases et ces users.

flowchart TD
    VAR["Variable CI<br/>TF_VAR_db_password"]
    TF["terraform/ephemeral<br/>aws_db_instance.password_wo<br/>écriture seule"]
    ST[("state ephemeral<br/>password_wo_version = 1<br/>AUCUNE valeur")]
    RDS[("instance RDS")]
    ASM[("fastapi-eks/app<br/>DB_PASSWORD")]
    JOB["Job de bootstrap DB<br/>crée les 3 bases"]
    CI["user/gitlab_ci_infra"]

    VAR --> TF
    VAR -->|"seed-secrets.sh"| ASM
    TF -->|"à la création"| RDS
    TF -.->|"un compteur, pas la valeur"| ST
    ASM -->|"via ESO"| JOB
    JOB -->|"se connecte en master"| RDS
    CI -.->|"lit forcément"| ST

Trois choses le distinguent des sept autres secrets :

  1. Terraform le transmet, mais ne le garde plus (ADR 038, #219). On ne peut pas retirer le mot de passe d'un aws_db_instance : RDS en exige un à la création. On peut en revanche le passer par password_wo, un attribut en écriture seule : Terraform l'envoie à l'API RDS, puis ne l'écrit ni dans le state ni dans le plan. Il ne garde que password_wo_version, un compteur.
  2. La CI lit toujours ce state, et c'est toujours indispensable : elle applique la stack ephemeral. La restriction de préfixe de !343 ne pouvait rien ici, puisqu'elle porte sur ce préfixe même. La CI n'y trouve plus cette valeur.
  3. La même valeur a deux rôles : master RDS et fastapi-eks/app:DB_PASSWORD, que le Job de bootstrap lit pour se connecter en master. Ce n'est pas une confusion : les deux doivent être identiques, et une source unique, TF_VAR_db_password, les garantit égales.

Le garde-fou : une variable éphémère

db_password est déclarée ephemeral = true, à la racine et dans le module. Une valeur éphémère ne peut alimenter qu'un attribut en écriture seule. Réécrire un jour password = var.db_password ne rouvre donc pas le flux en silence : terraform validate refuse, avec Invalid use of ephemeral value.

Pourquoi pas manage_master_user_password

C'était la piste de départ de #219 : RDS génère la valeur et la range dans un secret qu'il possède. Mais c'est AWS qui nomme ce secret (rds!db-...), et le nom change à chaque recréation de l'instance. L'ExternalSecret du Job, en GitOps, doit citer un nom fixe écrit dans le dépôt. Les deux ne se rejoignent pas sans outillage en plus. Détail dans l'ADR 038.

8. Ce que le versioning du bucket de state a changé

Arrêter d'écrire une valeur ne retire pas celles déjà écrites. Le bucket de state a le versioning activé : chaque apply conserve la version précédente.

Mesure du 2026-09-09 : 936 versions et 333 delete markers sous fastapi-eks/, dont 513 pour le seul state ephemeral.

L'ADR 037 a tranché en deux gestes :

  • la rotation des trois valeurs qui étaient encore vivantes dans ces copies ;
  • une règle de cycle de vie à 30 jours, qui borne l'accumulation.

Une leçon transposable, apprise ici

La purge supprime les copies sans invalider la valeur, la rotation invalide la valeur sans supprimer les copies. Il fallait les deux.

Et noncurrent_days compte à partir du moment où une version est devenue non courante, pas depuis la pose de la règle. Une version de mai part au premier passage, sous 24 à 48 heures. C'est l'apply de la règle qui est le point de non-retour.

9. Récapitulatif : où vit une valeur, à chaque instant

Étape La valeur est... Qui la voit
Fabrication dans Slack / GitLab / openssl toi
Stockage amont variable CI GitLab, masked les jobs CI, toi
Pose en transit dans seed-secrets.sh le rôle secrets-reader
Repos Secrets Manager les principaux nommés dans la politique de ressource
Transfert lu par ESO le rôle IRSA de l'env
Usage Secret Kubernetes, variable d'environnement les pods du namespace
Jamais ~~dans le state Terraform~~ depuis l'ADR 033, et l'ADR 038 pour le master RDS

Voir aussi

  • Politique de ressource sur les secrets — le contrôle qui refuse l'administrateur, et ce qu'il ne protège pas
  • Identités et secrets par environnement — IRSA scopé, le cloisonnement dev / staging / prod
  • Bases par environnement — la barrière côté PostgreSQL
  • Escalade de privilèges IAM — la chaîne en trois maillons
  • ADR 033 — Terraform déclare les secrets, il ne les fabrique plus
  • ADR 034 — Terraform déclare les identités CI, il n'en fabrique plus les clés
  • ADR 037 — le sort des anciennes versions du state
  • ADR 038 — le mot de passe master RDS en écriture seule
  • docs/aws-setup.md §14 (repose des valeurs) et §15 (pose des clés)