Skip to content

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 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 :

  1. l'identité utilisée est assumed-role/fastapi-eks-app-dev-irsa, pas le rôle du contrôleur — donc auth.jwt fonctionne et la trust policy accepte le SA de fastapi-dev ;
  2. ce rôle n'est pas autorisé sur fastapi-eks/prod — donc la policy scopée à l'ARN unique fait son travail ;
  3. 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.