RFC 0038 — Revalidation e-mail à UserService::update_profile
- Auteur : EBO (origine audit Gitrust / patch 1.0.1)
- Date : 2026-09-18
- Statut : Accepted (2026-09-18) — Superseded by 0041
Remplacée par RFC 0041 (2026-09-19) : la v1 décrite ici n'a jamais été diffusée ; voir 0041 pour le flux livré.
- Apps consommatrices concernées : Gitrust ;
rustwarden-ui(PUT /settings/profile,PUT /admin/users/:id) ; tout consommateur qui appelleUserService::update_profile - Crate cible :
rustwarden-core - Origine : plan
docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md(Task 15)
Summary
Quand UserService::update_profile écrit une nouvelle adresse e-mail, basculer email_verified=false / email_verified_at=NULL, enqueuer un mail de validation vers la nouvelle adresse et une alerte vers l’ancienne, sans table pending_* (v1 YAGNI).
Motivation
Problème concret
UserService::update_profile (~L157–217) accepte email: Option<String>, vérifie un format minimal (@ + .) et l’unicité, puis :
updated_user.email = Set(new_email.clone());
email_verified et email_verified_at ne sont pas touchés. Un compte déjà vérifié qui passe à attacker@evil.example reste email_verified=true.
Call sites actuels :
| Call site | Comportement aujourd’hui |
|---|---|
rustwarden-ui PUT /settings/profile | update_profile puis, si e-mail changé : invalide les password-change pending ; enqueue account_modified vers nouvelle et ancienne adresses. Pas de send_validation_email. Pas de reset email_verified. |
admin_handler::update_user (~L1286) | update_profile(..., None, Some(new_email)) sans mail de validation ni alerte. |
| Gitrust / apps headless | même trou dès qu’elles appellent le service. |
Conséquence : prise de contrôle silencieuse de l’identité e-mail (reset password, notifications, recovery) tout en conservant le flag vérifié. Couplé à RFC 0040 (validation_email_required dans authenticate_user), garder email_verified=true après un changement contourne aussi le blocage login.
Qui est impacté
- Tout utilisateur qui change d’e-mail via profil ou admin.
- Les opérateurs : un user « verified » qui change d’adresse perd l’accès tant que
validation_email_requiredest vrai (voulu — voir Breaking). - Gitrust n’a plus à patcher
update_profilelocalement.
Si on ne fait rien
Le flag vérifié suit l’ancienne preuve vers une nouvelle boîte. L’UI envoie déjà un account_modified (incomplet, et seulement sur le chemin profil, pas admin). Divergence consommateurs.
Urgence
Pas un patch 1.0.1. Durcissement de politique d’identité (comportement par défaut de update_profile). Pas de nouvelle table en v1 → RFC tout de même : changement de comportement par défaut + impact login (processus §2).
Non-goals
- Pas de table
pending_email_changesen v1 (YAGNI). L’e-mail en base devient la nouvelle adresse immédiatement. v2 est évaluée ci-dessous comme follow-up. - Pas de charset e-mail (hors RFC 0036 usernames).
- Pas de modification du hook
RustwardenHooks::on_email_verified(~L91) : il reste appelé uniquement après un token de validation consommé (EmailValidationService). - Pas de changement du flow register (
create_userpose déjàemail_verified: false). - Pas de réécriture OAuth (
OAuthServiceposeemail_verifiedselon le provider — hors scope). - Pas de signature breaking de
update_profile(restedb, user_id, username: Option<String>, email: Option<String>). - Pas d’échec dur si SMTP n’est pas configuré : même contrat que
register(EmailConfig::from_env()best-effort +tracing::warn).
API proposée
v1 (recommandée) — flags + deux enqueues, pas de table
Signature aujourd’hui, à conserver :
// crates/rustwarden-core/src/services/user_service.rs impl UserService { pub async fn update_profile( db: &DatabaseConnection, user_id: Uuid, username: Option<String>, email: Option<String>, ) -> Result<user::Model, AppError> { /* ... */ } }
Comportement après Accept, branche if let Some(new_email) = &email (~L190–205), après les validations format/unicité, avant update :
- Si
new_email(trim) égal àuser.email: ne pas toucheremail_verified/email_verified_at(no-op e-mail ; un rename username seul ne revalide pas). - Si
new_emaildifférent :updated_user.email = Set(new_email.clone())(inchangé).updated_user.email_verified = Set(false).updated_user.email_verified_at = Set(None).- Mémoriser
old_email = user.email.clone()pour l’alerte.
- Persister (
updated_user.update(db)). - Enqueue validation vers la nouvelle adresse :
EmailValidationService::new(cfg).send_validation_email(db, user_id)— lituser.emaildéjà mis à jour, invalide les anciens tokens (invalidate_previous_tokens), type de queue"email_validation". Même helper queregister(~L1488). - Enqueue alerte vers l’ancienne adresse (
EmailQueueService::enqueue,to_email = old_email, type"email_changed"ou réutiliser"account_modified"avecAccountModifiedTemplatechampEmail). Message du type « votre adresse e-mail a été changée » ; ne pas y coller le lien de validation (ce lien va uniquement à la nouvelle boîte). tracing::warn!(user_id, old/new masqués) pour SIEM. Si un handler a desRustwardenHooks, il peut émettreon_audit_event(kind: "user.email_changed") —UserServicene prend pashooks(signature stable).on_email_verifiedinchangé.
EmailConfig::from_env() : si Err, skip 4–5 + tracing::warn (comme register). L’update DB réussit quand même : l’utilisateur peut POST /api/v1/auth/resend-verification.
Schéma users inchangé (email_verified: bool, email_verified_at: Option<DateTime<Utc>> déjà présents).
Usage consommateur
use rustwarden_core::error::AppError; use rustwarden_core::services::user_service::UserService; use sea_orm::DatabaseConnection; use uuid::Uuid; async fn change_email( db: &DatabaseConnection, user_id: Uuid, new_email: String, ) -> Result<(), AppError> { // Same signature. After this RFC, a previously verified user // becomes email_verified=false until they click the new link. let _user = UserService::update_profile(db, user_id, None, Some(new_email)).await?; Ok(()) }
rustwarden-ui profil : retirer le double enqueue account_modified pour le champ e-mail (évite 4 mails). Conserver l’invalidation PasswordChangeService::invalidate_on_email_change (déjà là). Conserver account_modified pour un rename username seul. Admin : aucun extra — le service couvre le trou.
v2 (follow-up, hors implémentation de cette RFC)
Table pending_email_changes (user_id, new_email, token_hash, expires_at) : l’e-mail courant reste vérifié jusqu’au clic sur un lien envoyé à la nouvelle boîte ; l’ancienne reçoit l’alerte. Plus sûr contre un attaquant session-volée qui change l’e-mail pour lock-out. Coût : migration, tokens, courses admin vs user, expiration, UI « pending ».
Décision Draft : v1 score proposé 28/35 (≥ 25) → v2 marquée follow-up, pas dans le PR d’implémentation. Réévaluer si un incident de prise de contrôle de session + changement d’e-mail le justifie.
Analyse de compatibilité
Checklist :
-
API existante non modifiée → signatures
update_profileinchangées (compile). - API existante étendue avec défaut → N/A.
- API existante modifiée ou supprimée → breaking comportemental : un user vérifié qui change d’e-mail n’est plus vérifié.
-
Nouveau comportement par défaut →
email_verified=falsedès que l’adresse change. Feature-flag : non (sécurité). Les apps qui s’appuyaient sur « changer l’e-mail sans revalider » cassent volontairement. - Changement de schéma DB → non en v1.
-
Changement de format sérialisé → le JSON profil peut renvoyer
email_verified: falseaprès update (si le DTO l’expose).
Breaking (voulu)
Si AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true) est vrai (défaut), l’utilisateur perd le login jusqu’au clic sur le lien (RFC 0040 une fois livrée ; aujourd’hui seulement le handler HTTP login). Session JWT déjà émise : hors scope (pas de révocation globale ici). Password reset ira à la nouvelle adresse non encore prouvée — acceptable en v1 ; v2 retarderait l’écriture.
Admin qui change l’e-mail d’un user : même un-verify. L’admin doit prévenir l’utilisateur (alerte ancienne boîte + validation nouvelle).
Alternatives considérées
Minimum 3 alternatives avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
Alternative A (recommandée) : v1 YAGNI (un-verify + validation new + alerte old)
Pros : ferme le bug sur tous les call sites (update_profile unique porte). Pas de migration. Réutilise EmailValidationService::send_validation_email et EmailQueueService. Aligné register. Score ≥ 25 → v2 plus tard.
Cons : window où la nouvelle adresse est en base sans preuve (lock-out + reset vers une boîte non possédée si session volée). Breaking login. L’UI profil doit dédupliquer ses mails.
Alternative B : v2 pending_email_changes dès maintenant
Pros : l’ancienne adresse reste vérifiée jusqu’à confirmation ; résiste mieux au vol de session ; pas de lock-out immédiat.
Cons : migration + service + UI « pending » + courses admin ; YAGNI pour le bug actuel (flag qui ne bascule pas). Retarde la fermeture M. Score estimé : Généralité 4, Stabilité 3 (nouvelle table + API), Perf 4, Sécu 5, Complexité 2, Maintenance 3, Doc 4 → 25/35 pile, plus risqué à livrer en même temps que 0037/0040.
Alternative C (ne rien faire / garder le workaround UI)
Pros : zéro coût upstream. L’UI profil notifie déjà old+new (account_modified) sans retirer le flag.
Cons : email_verified reste vrai ; chemin admin et Gitrust non couverts ; reset password / login restent attachés à une preuve obsolète. Contredit l’audit.
Testing plan
Tests unitaires / service (MockDatabase ou DB de test existante de UserService) :
- Happy path : user
email_verified=true,update_profile(..., email: Some("new@example.com"))→ rowemail=new@example.com,email_verified=false,email_verified_at=NULL; une entréeemail_queueemail_validationvers new ; une entrée alerte vers old. - Erreur 1 — input invalide :
"not-an-email"(pas de@/.) →AppError::Validation("Invalid email format"); flags inchangés ; aucune queue. - Erreur 2 — contrainte unicité : e-mail déjà pris par un autre user →
Validation("Email '…' already exists"); pas d’un-verify du caller. - Erreur 3 — SMTP absent :
EmailConfig::from_env()fail → update réussit, flags un-verify,tracingwarn, queue vide (pas de panic). - No-op :
email: Some(old)identique →email_verifiedrestetrue; pas d’enqueue validation. - Username seul :
email: None→ e-mail/flags intacts.
Tests d’intégration :
- Consommer un token
EmailValidationServiceaprès v1 →email_verified=true,on_email_verifiedappelé (handlerverify_emailinchangé). - Admin
update_user: même un-verify (test service suffit si le handler délègue). - Non-régression :
create_usertoujoursemail_verified=false.
Tests de non-régression :
- Code consommateur compile.
PasswordChangeService::invalidate_on_email_changetoujours appelé par l’UI profil (hors core).
Doctests :
- Rustdoc
update_profile: exemple copie/collable « changing email clears verification ». Exécutable seulement avec DB →```rust,ignore+ un paragraphe rustdoc anglais. Si un test unitaire documente le contrat, le doctest ignore est acceptable (même pattern RFC 0036 pour les services I/O).
Benchs : non (path profil, pas bcrypt/JWT). Deux INSERT queue, négligeables.
Les snippets change_email ne s’exécutent pas tant que le comportement n’est pas livré.
Migration guide
Section à copier telle quelle dans le CHANGELOG.md au moment du merge.
### API changes **Before (v1.0.x)** : `UserService::update_profile` écrivait `users.email` et **conservait** `email_verified` / `email_verified_at`. Seul le handler UI profil envoyait `account_modified` (old+new), sans lien de validation. **After (vX.Y.Z)** : Si la nouvelle adresse (trim) diffère de l'ancienne : `email_verified=false`, `email_verified_at=NULL`, enqueue validation vers la **nouvelle** boîte (`EmailValidationService::send_validation_email`), alerte vers l'**ancienne**. `on_email_verified` inchangé. Signature `update_profile` inchangée. Pas de table `pending_email_changes` (follow-up v2). Breaking : un utilisateur vérifié qui change d'e-mail ne peut plus se connecter tant que `validation_email_required` / `validation_email_requise` est vrai (défaut `true`) et qu'il n'a pas cliqué le lien. ```rust UserService::update_profile(db, user_id, None, Some(new_email)).await?; // user.email_verified == false until verify-email token is consumed
Migration steps
- Bump
rustwarden-coreà la version qui livre RFC 0038. - Aucune migration SQL.
rustwarden-ui: ne plus enqueueaccount_modifiedpour le seul changement d'e-mail (le core s'en charge) ; garderinvalidate_on_email_change.- Gitrust / autres : supprimer tout patch local qui re-vérifiait à la main ; prévenir le support (lock-out attendu après changement d'e-mail).
- Vérifier SMTP / worker
email_queueen prod avant le bump (sinon users unverified sans mail —resend-verificationreste disponible).
## Open questions Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : - [x] **Type de queue de l’alerte** : `"email_changed"` dédié vs `"account_modified"` existant (`AccountModifiedTemplate`) ? **Résolu (2026-09-18) : réutiliser `account_modified` / `AccountModifiedTemplate` (champ Email), déjà traduit — pas de nouveau template.** - [x] **Trim / casse** : comparaison exacte (recommandé, aligné unicité PostgreSQL sensible à la casse) vs fold ASCII ? **Résolu (2026-09-18) : comparaison exacte après trim, pas de fold ASCII (aligné unicité PostgreSQL sensible à la casse).** - [x] **Admin un-verify** : confirmer le même contrat (recommandé) plutôt qu’un bypass `email_verified=true` réservé admin. **Résolu (2026-09-18) : même contrat pour le chemin admin — pas de bypass `email_verified=true` réservé admin.** - [x] **v2** : confirmer follow-up (cette RFC, score v1 ≥ 25) plutôt que fusionner les deux. **Résolu (2026-09-18) : v1 livrée seule ; `pending_email_changes` (v2) confirmée en follow-up hors de ce train.** - [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** ## Tracking issue À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 15 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis. --- ## Score d'évaluation (rempli par les reviewers) Notes **proposées par l’auteur** (Draft) pour **l’alternative A (v1)** ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. v2 hors merge si ce total tient. | Critère | Note (1-5) | Commentaire | |---|---|---| | Généralité | 5 | Tous les call sites `update_profile` (UI, admin, Gitrust, headless). | | Stabilité API | 3 | Signature identique mais breaking comportemental + lock-out login (voulu). | | Performance | 5 | Deux inserts queue hors hot path login ; pas de table extra. | | Sécurité | 5 | Coupe le flag vérifié d’une adresse non prouvée ; alerte l’ancienne boîte. | | Complexité d'implémentation | 4 | Branche ~20 lignes + enqueue best-effort ; pas de migration. | | Charge de maintenance | 4 | Réutilise validation/queue existants ; v2 évité pour l’instant. | | Documentation | 4 | RFC + CHANGELOG + rustdoc ; UI à dédupliquer. | | **Total** | **30/35** | (≥ 25 → v2 en follow-up, pas dans ce PR) |
GitRust