Tout le monde est d'accord pour dire que le mot de passe de la base de données devrait tourner. Presque personne ne le fait, et la raison n'est pas la paresse. C'est que la première fois que vous essayez, quelque chose casse : un pool de connexions qui garde l'ancien mot de passe, une Lambda qui l'a mis en cache au démarrage à froid, un job d'arrière-plan qui l'a lu dans une variable d'environnement posée au déploiement. Alors la rotation est annulée, un ticket est ouvert, et le mot de passe reste le même pendant deux ans.
Le nôtre tourne tous les trente jours, automatiquement, et l'application ne s'en aperçoit pas. Voici le mécanisme, qui est surtout celui d'AWS, et les trois changements dans l'application qui l'ont rendu sûr, qui sont les nôtres.
Pourquoi la rotation naïve casse des choses
La rotation avec un seul utilisateur de base de données se déroule ainsi : générer un nouveau mot de passe, ALTER USER app PASSWORD 'new', mettre à jour le secret. Entre le ALTER et le moment où chaque consommateur a relu le secret, toute nouvelle connexion avec l'ancien mot de passe est refusée. Les connexions existantes survivent, parce que Postgres authentifie à la connexion, mais un pool qui ouvre une nouvelle connexion pendant cette fenêtre échoue, et un service qui ne lit le secret qu'au démarrage échoue à chaque reconnexion jusqu'à ce qu'il soit redémarré.
La fenêtre peut durer des secondes si tout relit rapidement. Elle peut durer des heures si quelque chose met en cache. En pratique, c'est « jusqu'au prochain déploiement », parce que c'est là que les variables d'environnement sont rafraîchies, et c'est ça la panne.
Utilisateurs alternés : la rotation qui n'invalide jamais un mot de passe en usage
La stratégie de rotation multi-utilisateurs de Secrets Manager utilise deux utilisateurs de base de données, app et app_clone, avec des droits identiques. À tout moment, l'un est l'utilisateur courant dans le secret et l'autre est inactif. La rotation change le mot de passe de l'inactif, le teste, puis bascule le secret vers lui. L'utilisateur qui était courant, dont chaque consommateur pourrait tenir le mot de passe, n'est pas touché jusqu'à la rotation suivante trente jours plus tard, et d'ici là chaque consommateur a relu le secret de nombreuses fois.
En CDK, sur un cluster dont les identifiants viennent d'un secret généré, ce sont quelques lignes :
cluster.addRotationMultiUser('Rotation', {
secret: appUserSecret, // le secret de l'utilisateur 'app', avec masterarn défini
automaticallyAfter: Duration.days(30),
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
CDK déploie la Lambda de rotation d'AWS depuis le Serverless Application Repository dans le VPC, la relie au secret et la planifie. La Lambda doit atteindre à la fois la base de données (elle est dans le VPC) et Secrets Manager (un interface endpoint ou un NAT ; nous utilisons l'endpoint). L'utilisateur app_clone est créé par la Lambda à la première rotation s'il n'existe pas, avec les mêmes droits qu'app, et c'est la partie qui mérite d'être vérifiée à la main : si app reçoit des droits ajoutés plus tard par une migration, app_clone en a besoin aussi. Nous gérons cela en accordant les droits à un rôle dont les deux utilisateurs sont membres, de sorte que les grants sont faits une seule fois.
Les trois changements dans l'application
Le mécanisme de rotation est sûr par construction. L'application doit tout de même lire le nouveau secret à un moment donné, et la façon dont elle le fait décide si la rotation est invisible ou une panne au ralenti.
1. Lire le secret à la connexion, pas au démarrage. La fabrique de connexions du pool appelle Secrets Manager (via un cache avec un TTL de cinq minutes) chaque fois qu'elle ouvre une connexion. Un service à longue durée de vie récupère un nouveau secret dans les cinq minutes suivant une bascule, sans redémarrage. Les runtimeEnvironmentSecrets d'App Runner injectent la valeur au démarrage de l'instance, ce qui convient pour un service redéployé chaque semaine et ne convient pas pour un qui tourne un mois, donc pour les identifiants de base de données spécifiquement, nous lisons depuis Secrets Manager dans le code plutôt que depuis l'environnement.
2. Sur une erreur d'authentification, relire une fois et réessayer. Ceinture et bretelles : si une tentative de connexion échoue avec 28P01 (mot de passe invalide), invalider le cache, récupérer à nouveau le secret et réessayer une fois. Cela couvre le cas où le TTL du cache n'a pas expiré au moment exact de la bascule. Ce sont dix lignes et elles se sont déclenchées en production exactement autant de fois que nous avons fait tourner : environ une fois par mois, en silence, dans les logs.
async function connect(): Promise<Client> {
try {
return await open(await creds.get());
} catch (e) {
if (isAuthError(e)) { creds.invalidate(); return open(await creds.get()); }
throw e;
}
}
3. Laisser RDS Proxy s'en charger pour les Lambdas. Les fonctions dans le VPC se connectent via RDS Proxy, et le proxy s'authentifie auprès de la base avec le secret lui-même : il surveille Secrets Manager et suit les rotations tout seul. Les fonctions s'authentifient auprès du proxy avec IAM, donc elles ne détiennent jamais de mot de passe de base de données. La rotation, pour elles, est un non-événement par conception. Cela règle aussi le problème des tempêtes de connexions que les Lambdas ont avec Postgres, qui est la raison pour laquelle nous recommanderions le proxy de toute façon.
La première rotation, en staging, chronomètre en main
Nous ne l'avons pas activée en production d'abord. En staging, nous avons déclenché une rotation manuellement (aws secretsmanager rotate-secret), regardé le log de la Lambda, et fait tourner un générateur de charge contre l'API pendant tout ce temps. Résultats du premier essai :
| Moment | Ce qui s'est passé |
|---|---|
| T+0 s | La rotation démarre ; la Lambda définit le mot de passe d'app_clone, le teste, bascule le secret |
| T+4 s | Bascule terminée. Chaque connexion existante toujours OK (elles tiennent app) |
| T+0 à T+300 s | Les nouvelles connexions du pool App Runner utilisent encore les identifiants app en cache. Toujours OK, parce qu'app est encore valide |
| T+300 s | Le cache expire ; la prochaine nouvelle connexion récupère app_clone. Ça marche |
| T+5 min à T+30 jours | Les deux utilisateurs valides. Aucun consommateur ne peut être pris de court |
Zéro erreur dans le générateur de charge. La seule chose qui a échoué, c'est un cron dans un autre compte qui avait le mot de passe dans une variable d'environnement : il a continué de fonctionner trente jours (son utilisateur était encore valide) puis a cassé à la deuxième rotation, ce qui est exactement l'échec différé que la stratégie alternée produit pour les consommateurs qui ne relisent pas. C'est tout l'intérêt de le faire en staging d'abord. Le cron lit maintenant le secret.
La checklist
- Rotation multi-utilisateurs, jamais mono-utilisateur. L'utilisateur de base de données supplémentaire est gratuit et fait toute la différence.
- Les deux utilisateurs reçoivent leurs droits via un rôle partagé, pour qu'une migration qui accorde des droits au rôle couvre les deux.
- La Lambda de rotation vit dans le VPC et a besoin d'un chemin vers Secrets Manager : interface endpoint ou NAT.
- Chaque consommateur lit le secret à la connexion via un cache à TTL court, ou passe par RDS Proxy avec IAM.
- Relire une fois sur
28P01. - Première rotation en staging, déclenchée manuellement, sous charge, avec le log ouvert.
- Puis grepper chaque variable d'environnement,
.env, cron et script à la recherche de l'ancien mot de passe. Ce qui l'a encore cassera dans trente à soixante jours. Mieux vaut le trouver maintenant.
Rotation à trente jours, six mois, six rotations, zéro incident. Le mot de passe que personne n'a lu a changé six fois et personne ne s'en est aperçu, ce qui est exactement ce à quoi un secret est censé ressembler.
Si le mot de passe de votre base de données a une date d'anniversaire, nous pouvons le faire tourner en une journée, staging d'abord.