Skip to content

Bases par environnement (la barrière est dans PostgreSQL)

Contexte : ADR 029 Décision 3, issue #137. Cette page explique comment les trois databases sont créées et pourquoi ce mécanisme-là. Le quoi (1 RDS, 3 databases, users scopés) est tranché dans l'ADR.

Le problème que ça résout

Les trois environnements partagent une seule instance RDS — compromis de coût assumé. Elle est managée, hors cluster, et tous les envs joignent le même endpoint : une NetworkPolicy ne peut donc pas distinguer dev de prod au niveau réseau, ils parlent tous à la même adresse.

La seule barrière possible est dans le moteur : une database par env, un user par env, et aucun droit croisé. C'est PostgreSQL qui refuse, pas le réseau.

Pourquoi ce n'est pas Terraform qui crée les bases

C'est le réflexe naturel — il existe un provider cyrilgdn/postgresql — et il ne marche pas ici, pour deux raisons cumulées :

  • Le RDS n'est joignable que depuis le VPC. Il est en subnet privé (publicly_accessible = false) et son security group n'ouvre le 5432 qu'au CIDR 10.0.0.0/16. Terraform tourne sur le runner self-hosted, chez Youssef, hors VPC : le provider n'a aucune route vers l'instance tant qu'il n'y a pas de bastion.
  • Le RDS est éphémère. Il vit dans terraform/ephemeral/, donc il est détruit et recréé vide à chaque cycle infra-stop / infra-start. Une création faite à la main une fois pour toutes ne survivrait pas à la première nuit.

D'où le choix : le SQL s'exécute depuis le cluster, qui lui est dans le VPC, et il est rejoué à chaque montée.

Le mécanisme

terraform/persistent  ──> 3 secrets ASM  fastapi-eks/{dev,staging,prod}
   (survit au teardown)      DB_PASSWORD généré par Terraform
                                    │
                                    │ ESO (IRSA)
                                    ▼
        ns fastapi (plateforme)   Secret K8s  db-bootstrap
                                    │  master + les 3 mots de passe
                                    ▼
                              Job db-bootstrap  ──psql──> RDS
                              (hook Sync ArgoCD)

Les fichiers : k8s/platform/db-bootstrap/ (SecretStore, ExternalSecret, Job) et l'Application ArgoCD k8s/platform/argocd-apps/db-bootstrap.yaml.

Le Job vit dans le namespace plateforme fastapi, pas dans les overlays d'env. C'est le point qui compte : seul ce Job a besoin du mot de passe master RDS, le seul habilité à CREATE DATABASE et CREATE ROLE. Le descendre dans fastapi-dev / -staging / -prod reviendrait à donner à chaque env le pouvoir de supprimer les bases des autres — la barrière n'aurait plus aucune valeur.

sync-wave: -1 : les bases doivent exister avant que les pods applicatifs ne démarrent, puisque leur initContainer lance alembic upgrade head.

Convergence plutôt que création

Le hook Sync rejoue le Job à chaque synchronisation. Le SQL est donc écrit pour converger, pas pour créer :

  • CREATE ROLE / CREATE DATABASE seulement si absents (via \gexec, seul moyen propre de conditionner ces deux commandes, qui ne supportent pas IF NOT EXISTS ni les blocs DO) ;
  • puis un ALTER ROLE ... PASSWORD à chaque passage.

Ce ALTER n'est pas cosmétique : les mots de passe vivent dans le stack persistent alors que le RDS renaît vide à chaque montée. C'est lui qui fait se rejoindre les deux cycles de vie après un infra-start.

Ce qui produit réellement l'isolation

Deux gestes, et le second est le moins intuitif :

CREATE DATABASE app_dev OWNER app_dev;
REVOKE CONNECT ON DATABASE app_dev FROM PUBLIC;
GRANT  CONNECT ON DATABASE app_dev TO app_dev;
  • OWNER : depuis PostgreSQL 15, le schéma public appartient à pg_database_owner. Faire du user l'owner de sa base lui donne le droit d'y créer ses tables — ce dont alembic a besoin — sans lui accorder quoi que ce soit ailleurs. Pas besoin de GRANT ALL ON SCHEMA public.
  • REVOKE CONNECT ... FROM PUBLIC : c'est la ligne qui isole. Par défaut PostgreSQL accorde CONNECT à PUBLIC sur toute base créée. Sans ce REVOKE, les trois users existent bien séparément mais peuvent tous se connecter aux trois bases. Créer des users distincts ne suffit pas ; il faut retirer le droit implicite.

Le même REVOKE est appliqué à fastapi_db, la base historique qui porte encore la prod tant que #138 n'a pas donné à chaque env son propre ExternalSecret. Sans lui, les nouveaux users pouvaient y ouvrir une session (sans lire ses tables, qui appartiennent au master, ni en créer, PostgreSQL 15+ retirant CREATE sur public à PUBLIC — mais une session de dev sur la base de prod n'a pas lieu d'être).

L'application n'est pas affectée : elle se connecte avec le user master, propriétaire des bases, dont le CONNECT est inscrit dans l'ACL de la base (fastapi=CTc/fastapi) et non hérité de PUBLIC. Vérifié sur un owner non-superuser, le cas d'un master RDS.

Ce que #137 ne faisait PAS — et que #138 a livré

À la livraison de #137, les applications n'étaient pas branchées sur ces bases : les trois envs lisaient encore fastapi_db avec le user master, ConfigMap et ExternalSecret communs. Autrement dit, dev et staging écrivaient toujours dans la base de prod.

Ce que #137 livrait, c'était la barrière : les bases et les users existent, et PostgreSQL refuse tout accès croisé.

#138 s'y est branché — un ConfigMap et un ExternalSecret par env, un rôle IRSA scopé par env, et les hosts DNS correspondants. La prod a basculé au passage de fastapi_db vers app_prod, quittant le user master.

Un point mérite d'être relevé, parce qu'il n'était pas visible en écrivant #137 : le cloisonnement PostgreSQL décrit ci-dessus supposait que chaque pod n'obtienne que le mot de passe de son environnement. Rien ne le garantissait, un ExternalSecret pouvant nommer le chemin ASM d'un autre env. C'est ce que le rôle IRSA par environnement ferme.

Détail du mécanisme : Identités et secrets par environnement.

Vérifier

Depuis un pod du cluster (le RDS n'est pas joignable depuis le poste) :

# Le Job a-t-il convergé ?
kubectl -n fastapi get job db-bootstrap          # absent = succès, ArgoCD l'a ramassé
kubectl -n fastapi logs job/db-bootstrap         # s'il est encore là, c'est qu'il a échoué

# Les trois bases existent, avec le bon owner
kubectl -n fastapi run psql --rm -it --restart=Never \
  --image=postgres:16-alpine --env=PGPASSWORD=<master> -- \
  psql -h <endpoint> -U fastapi -d fastapi_db \
  -c "\l app_*"

# LA vérification qui compte : dev ne joint pas prod
kubectl -n fastapi run psql --rm -it --restart=Never \
  --image=postgres:16-alpine --env=PGPASSWORD=<mot de passe dev> -- \
  psql -h <endpoint> -U app_dev -d app_prod -c "select 1"
# attendu : FATAL: permission denied for database "app_prod"
#           DETAIL: User does not have CONNECT privilege.

Les mots de passe se lisent dans le Secret db-bootstrap du namespace fastapi.