Identités et secrets par environnement
Suite directe de Bases par environnement. #137 avait
construit la barrière côté PostgreSQL : trois databases, trois users, GRANT scopés.
Mais rien n'y était branché, et surtout rien n'empêchait un environnement d'obtenir
les credentials d'un autre. C'est ce que #138 ferme.
Validé en live le 2026-07-30, résultats en fin de page.
1. Le problème
Ce qui existait avant
Les trois environnements lisaient le même secret AWS fastapi-eks/app, celui qui
porte les credentials du user master de l'instance RDS.
flowchart LR
subgraph envs["Les 3 environnements"]
d["pod fastapi-dev"]
s["pod fastapi-staging"]
p["pod fastapi-prod"]
end
d --> eso["Contrôleur ESO<br/>rôle fastapi-eks-eso-irsa"]
s --> eso
p --> eso
eso -->|"autorisé sur TOUT"| asm[("fastapi-eks/app<br/>credentials MASTER")]
asm --> db[("fastapi_db<br/>= la base de PROD")]
Une seule identité AWS pour tout le monde, un seul secret, une seule base.
Les deux défauts, de gravité croissante
Le visible : dev et staging écrivaient dans la base de prod. C'est SEC-006, colmaté en supprimant leurs routes publiques (#146).
Le structurel, et c'est celui qui compte : même après #137, le cloisonnement PostgreSQL supposait que chaque pod n'obtienne que le mot de passe de son environnement. Rien ne le garantissait.
Le raisonnement à retenir
Il suffisait qu'un ExternalSecret de dev nomme fastapi-eks/prod pour que les
credentials de prod atterrissent dans le namespace de dev. Les GRANT de #137
n'y pouvaient plus rien : le user présenté à PostgreSQL était alors le bon.
Autrement dit, la séparation entre dev et prod ne tenait qu'à une chaîne de caractères dans un fichier du dépôt, jamais vérifiée par personne d'autre que l'auteur de la MR.
2. Comment ça marche maintenant
Vue d'ensemble
flowchart LR
subgraph dev["namespace fastapi-dev"]
pd["pod"] --> sad["SA fastapi<br/>annoté role-arn"]
end
subgraph prod["namespace fastapi-prod"]
pp["pod"] --> sap["SA fastapi<br/>annoté role-arn"]
end
sad --> rd["rôle IAM<br/>app-dev-irsa"]
sap --> rp["rôle IAM<br/>app-prod-irsa"]
rd -->|"seul ARN autorisé"| sd[("fastapi-eks/dev")]
rp -->|"seul ARN autorisé"| sp[("fastapi-eks/prod")]
rd -.->|"AccessDenied"| sp
sd --> dbd[("app_dev")]
sp --> dbp[("app_prod")]
La flèche en pointillés est le cœur du dispositif : elle n'est pas seulement absente du dépôt, elle est refusée par IAM.
Le changement de fond : qui demande le secret
External Secrets Operator peut s'authentifier de deux façons, et c'est ce choix qui détermine si le scope IRSA sert à quelque chose.
| Avant — « controller IRSA » | Maintenant — modèle jwt |
|
|---|---|---|
Bloc auth du SecretStore |
absent | jwt.serviceAccountRef |
| Identité présentée à IAM | le pod contrôleur ESO | le SA du namespace demandeur |
| Portée | tout le cluster | un environnement |
| Ce qu'IAM peut distinguer | rien | l'env qui demande |
Avant, le SecretStore n'avait aucun bloc auth : ESO lisait avec son propre
rôle, identique pour tout le cluster. IAM ne voyait jamais quel namespace
demandait.
Pourquoi l'ordre des travaux importait
Ajouter des rôles par environnement sans changer ce mode aurait produit des rôles que rien n'évalue. L'ADR 029 planifiait « un rôle par env limité à son préfixe ASM » sans voir ce prérequis : c'est en l'implémentant qu'il est apparu.
Maintenant, le SecretStore de chaque env nomme un ServiceAccount :
auth:
jwt:
serviceAccountRef:
name: fastapi
ESO demande à l'API Kubernetes un token pour ce ServiceAccount, et s'en sert pour assumer le rôle annoté dessus. IAM évalue enfin l'identité de l'environnement.
La séquence, étape par étape
sequenceDiagram
participant ES as ExternalSecret<br/>(fastapi-dev)
participant ESO as Contrôleur ESO
participant API as API Kubernetes
participant STS as AWS STS
participant ASM as Secrets Manager
ES->>ESO: réconciliation (toutes les 1h)
ESO->>API: TokenRequest pour le SA `fastapi`<br/>du namespace fastapi-dev
API-->>ESO: JWT signé par l'issuer OIDC du cluster
ESO->>STS: AssumeRoleWithWebIdentity<br/>(rôle app-dev-irsa + JWT)
Note over STS: vérifie le `sub` :<br/>system:serviceaccount:fastapi-dev:fastapi
STS-->>ESO: credentials temporaires de app-dev-irsa
ESO->>ASM: GetSecretValue(fastapi-eks/dev)
Note over ASM: la policy du rôle n'autorise<br/>QUE cet ARN
ASM-->>ESO: DB_PASSWORD + SECRET_KEY
ESO->>ES: crée le Secret K8s `fastapi-secrets`
Les trois maillons, et ce que chacun verrouille
| Maillon | Où | Ce qu'il empêche |
|---|---|---|
auth.jwt.serviceAccountRef |
k8s/base/secretstore.yaml |
qu'IAM évalue le contrôleur au lieu de l'env |
sub de la trust policy |
terraform/ephemeral/irsa.tf |
qu'un SA fastapi d'un autre namespace assume le rôle |
| ARN exact dans la policy | terraform/ephemeral/irsa.tf |
que le rôle lise autre chose que son propre secret |
Pourquoi un ARN exact et pas un préfixe
Secrets Manager suffixe chaque secret de six caractères aléatoires. Une policy
sur fastapi-eks/dev* aurait donc aussi couvert un futur
fastapi-eks/dev-autre-chose créé par mégarde. L'ARN issu du lookup Terraform
ne laisse pas cette porte ouverte.
3. Ce que ça donne par environnement
| Env | Namespace | Rôle IAM | Secret ASM | Database / user |
|---|---|---|---|---|
| dev | fastapi-dev |
fastapi-eks-app-dev-irsa |
fastapi-eks/dev |
app_dev |
| staging | fastapi-staging |
fastapi-eks-app-staging-irsa |
fastapi-eks/staging |
app_staging |
| prod | fastapi-prod |
fastapi-eks-app-prod-irsa |
fastapi-eks/prod |
app_prod |
La prod bascule aussi
Elle tournait sur fastapi_db avec le user master, celui qui détient
CREATE DATABASE et CREATE ROLE sur l'instance : compromettre l'application
donnait la main sur les bases des trois environnements. Elle passe sur app_prod,
un user qui n'est propriétaire que de sa propre base.
Aucune migration de données n'a été nécessaire : le RDS vit dans le stack
éphémère et renaît vide à chaque montée, alembic reconstruisant le schéma au
démarrage.
Un SECRET_KEY par environnement
Avec une clé de signature partagée, un JWT émis par dev restait valide en prod : la signature vérifiait, et le cloisonnement des databases n'y changeait rien. Chaque env a désormais la sienne.
4. Le garde-fou contre l'oubli
Le base ne porte plus de nom de secret utilisable :
remoteRef:
key: MUST-BE-OVERRIDDEN-BY-OVERLAY
Y laisser l'ancien fastapi-eks/app aurait fait d'un patch d'overlay oublié une
panne silencieuse et dangereuse : le nouvel environnement serait reparti sur les
credentials master, donc sur la base de prod. C'est très exactement le mécanisme de
SEC-006.
Avec un nom qui n'existe pas, l'oubli se voit immédiatement : ESO ne résout rien, le
Secret n'est pas créé, et le pod reste bloqué au démarrage faute de son envFrom.
Le principe
Quand une valeur par défaut peut mener à la production, il vaut mieux qu'elle
casse. Même parti pris que le $patch: delete de #146.
5. DNS — pourquoi api-dev et non dev.api
Un certificat wildcard ne couvre qu'un seul niveau de label (RFC 6125).
*.devopsyouss.com couvre api.devopsyouss.com, mais pas
dev.api.devopsyouss.com. Le schéma prévu à l'origine par l'ADR 029 terminait donc
en erreur de certificat, constaté en live le 2026-07-26.
Deux issues étaient possibles, un certificat par env ou un schéma plat. Le schéma plat l'emporte : chaque émission DNS-01 supplémentaire est un point de défaillance de plus au démarrage, sur un cluster monté et détruit tous les jours.
| Env | Host API | Couvert par le wildcard |
|---|---|---|
| dev | api-dev.devopsyouss.com |
oui |
| staging | api-staging.devopsyouss.com |
oui |
| prod | api.devopsyouss.com |
oui |
Le piège du Gateway partagé
Le Gateway vit dans le namespace plateforme fastapi (1 Gateway = 1 NLB pour
les trois envs). Le patch d'overlay doit donc poser aussi :
- op: add
path: /spec/parentRefs/0/namespace
value: fastapi
Sans lui, la HTTPRoute cherche le Gateway dans son propre namespace, ne le
trouve pas, et reste silencieusement non attachée. Aucune erreur, aucun
événement parlant : juste un host qui ne répond pas. D'où la vérification
explicite de status.parents[].conditions[Accepted] dans le protocole ci-dessous.
6. Déployer — l'ordre compte
Le SECRET_KEY par env vit dans le stack persistent, les rôles IRSA dans
l'éphémère. Les deux ne sont pas appliqués par le même chemin.
flowchart TD
a["1. terraform apply<br/>stack PERSISTENT"] --> b{"SECRET_KEY présent<br/>dans les 3 secrets ?"}
b -->|non| x["ESO échouera :<br/>propriété absente"]
b -->|oui| c["2. pipeline infra ACTION=start<br/>terraform apply ÉPHÉMÈRE"]
c --> d["3. job bootstrap<br/>ansible : ESO, ArgoCD, Gateway"]
d --> e["4. ArgoCD synchronise<br/>les 6 overlays"]
e --> f["5. Vérifications live"]
L'étape 1 est manuelle et ne peut pas être sautée
Aucun job du pipeline infra ne touche le stack persistent. Si SECRET_KEY
manque dans fastapi-eks/{dev,staging,prod}, ESO cherchera une propriété
inexistante et les pods ne démarreront pas.
Contrôle avant de monter le cluster :
aws secretsmanager get-secret-value --secret-id fastapi-eks/dev \
--query SecretString --output text | python3 -m json.tool
# attendu : DB_PASSWORD ET SECRET_KEY
7. Vérifier — protocole et résultats du 2026-07-30
Les commandes ci-dessous sont le protocole à rejouer ; les sorties sont celles réellement obtenues lors de la validation live.
Prérequis
Exporter le profil youss-admin. devops-portfolio résout sur une identité
absente des access entries EKS et se fait refuser par l'API server avec un message
trompeur (« must be logged in »).
Étape 1 — Les secrets sont résolus
kubectl get externalsecret -A | grep fastapi
fastapi-dev fastapi-secrets SecretStore aws-secrets-manager SecretSynced True
fastapi-prod fastapi-secrets SecretStore aws-secrets-manager SecretSynced True
fastapi-staging fastapi-secrets SecretStore aws-secrets-manager SecretSynced True
fastapi db-bootstrap SecretStore aws-secrets-manager SecretSynced True
C'est le verdict sur le changement de mode d'authentification. db-bootstrap
conserve volontairement l'ancien modèle : le Job a besoin des trois mots de passe
pour créer les trois users.
Étape 2 — Chaque env parle à sa database
for ns in fastapi-dev fastapi-staging fastapi-prod; do
echo -n "$ns -> "
kubectl get cm fastapi-config -n $ns -o jsonpath='{.data.DB_NAME} / {.data.DB_USERNAME}'
echo
done
fastapi-dev -> app_dev / app_dev
fastapi-staging -> app_staging / app_staging
fastapi-prod -> app_prod / app_prod
Les pods Running sans redémarrage (1 en dev, 2 en staging, 2 en prod) prouvent
qu'alembic a pu créer le schéma dans les trois bases avec les users dédiés — donc
que la sortie de la prod hors du user master fonctionne.
Étape 3 — Les routes sont attachées
for ns in fastapi-dev fastapi-staging fastapi-prod; do
echo -n "$ns -> "
kubectl get httproute fastapi -n $ns \
-o jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status} {.status.parents[0].conditions[?(@.type=="Accepted")].reason}'
echo
done
fastapi-dev -> True Accepted
fastapi-staging -> True Accepted
fastapi-prod -> True Accepted
À vérifier explicitement : une route non attachée ne produit aucune erreur.
Étape 4 — Les hosts répondent sous le bon certificat
for h in api-dev api-staging api; do
echo -n "$h -> "
curl -sS -o /dev/null -w "%{http_code} cert=%{ssl_verify_result}\n" \
https://$h.devopsyouss.com/healthz/ready
done
api-dev -> 200 cert=0
api-staging -> 200 cert=0
api -> 200 cert=0
ssl_verify_result=0 confirme que le wildcard existant couvre bien les trois hosts,
sans émission supplémentaire.
Étape 5 — LA vérification : dev ne peut pas lire le secret de prod
Les quatre étapes précédentes montrent que tout fonctionne. Seule celle-ci montre que c'est cloisonné. Un rôle trop large produirait exactement les mêmes résultats aux étapes 1 à 4.
On crée un ExternalSecret jetable qui vise délibérément le chemin de prod depuis le
namespace de dev.
kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: test-isolation
namespace: fastapi-dev
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: test-isolation
data:
- secretKey: DB_PASSWORD
remoteRef:
key: fastapi-eks/prod
property: DB_PASSWORD
EOF
kubectl describe externalsecret test-isolation -n fastapi-dev | tail -20
Résultat obtenu :
Warning UpdateFailed external-secrets
error processing spec.data[0] (key: fastapi-eks/prod), err: operation error
Secrets Manager: GetSecretValue, api error AccessDeniedException:
User: arn:aws:sts::199167114788:assumed-role/fastapi-eks-app-dev-irsa/external-secrets-provider-aws
is not authorized to perform: secretsmanager:GetSecretValue
on resource: fastapi-eks/prod
because no identity-based policy allows the secretsmanager:GetSecretValue action
Ce message prouve trois choses à la fois, et c'est pour ça qu'il vaut mieux que n'importe quelle capture d'écran de configuration :
- l'identité utilisée est
assumed-role/fastapi-eks-app-dev-irsa, pas le rôle du contrôleur — doncauth.jwtfonctionne et la trust policy accepte le SA defastapi-dev; - ce rôle n'est pas autorisé sur
fastapi-eks/prod— donc la policy scopée à l'ARN unique fait son travail ; - c'est un refus IAM, et non un secret introuvable ou une propriété absente, qui auraient produit une erreur générique sans rien démontrer.
Avant #138, cette même requête aurait été servie sans difficulté.
Nettoyer, et se méfier de selfHeal
kubectl delete externalsecret test-isolation -n fastapi-dev
Ne pas faire ce test en patchant l'ExternalSecret existant : toutes les
Applications tournent en selfHeal: true et ArgoCD annulerait la modification en
quelques secondes. L'objet jetable, lui, n'est pas suivi par ArgoCD et n'est donc
pas pruné.
8. Ce que #138 ne fait PAS
Le frontend de dev et staging restait non exposé. Ce n'était plus pour la raison de
146, levée ici, mais parce que VITE_API_BASE_URL était un argument de build figé
dans l'image (frontend/Dockerfile), que la CI ne passait aucun --build-arg, et
qu'une seule image frontend est promue par digest pour les trois environnements.
Les trois envs servaient donc une SPA pointant en dur sur l'API de prod. Exposer
app-dev.devopsyouss.com aurait donné un front de dev dont le navigateur écrit dans
les données de prod : SEC-006 à l'identique, déplacé du backend vers le client.
Levé en #156 : l'URL est désormais lue au runtime dans /config/config.js, servi
par un ConfigMap propre à chaque overlay. L'image reste unique et promue par digest,
mais chaque env sert sa propre URL d'API, et les deux fronts sont exposés. Voir
Configuration runtime du frontend.
L'isolation réseau vers la RDS reste imparfaite. Les trois envs joignent le même endpoint, la NetworkPolicy ne peut donc pas les distinguer au niveau réseau. La barrière est celle du moteur PostgreSQL et celle d'IAM, pas celle du réseau. Voir Bases par environnement.