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 où 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 :
- les quatre variables externes, que
~/.aws/gen-tfvars.shrécupère depuis GitLab ; 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 :
SecretStorerépond à où je lis, et avec quelle identité ;ExternalSecretré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 :
- 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 parpassword_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 quepassword_wo_version, un compteur. - La CI lit toujours ce state, et c'est toujours indispensable : elle applique la stack
ephemeral. La restriction de préfixe de!343ne pouvait rien ici, puisqu'elle porte sur ce préfixe même. La CI n'y trouve plus cette valeur. - 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)