0038-email-change-revalidation.md 17164 octets

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é.

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 siteComportement aujourd’hui
rustwarden-ui PUT /settings/profileupdate_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 headlessmê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_required est vrai (voulu — voir Breaking).
  • Gitrust n’a plus à patcher update_profile localement.

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_changes en 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_user pose déjà email_verified: false).
  • Pas de réécriture OAuth (OAuthService pose email_verified selon le provider — hors scope).
  • Pas de signature breaking de update_profile (reste db, 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 :

  1. Si new_email (trim) égal à user.email : ne pas toucher email_verified / email_verified_at (no-op e-mail ; un rename username seul ne revalide pas).
  2. Si new_email diffé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.
  3. Persister (updated_user.update(db)).
  4. Enqueue validation vers la nouvelle adresse : EmailValidationService::new(cfg).send_validation_email(db, user_id) — lit user.email déjà mis à jour, invalide les anciens tokens (invalidate_previous_tokens), type de queue "email_validation". Même helper que register (~L1488).
  5. Enqueue alerte vers l’ancienne adresse (EmailQueueService::enqueue, to_email = old_email, type "email_changed" ou réutiliser "account_modified" avec AccountModifiedTemplate champ Email). 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).
  6. tracing::warn! (user_id, old/new masqués) pour SIEM. Si un handler a des RustwardenHooks, il peut émettre on_audit_event (kind: "user.email_changed") — UserService ne prend pas hooks (signature stable). on_email_verified inchangé.

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_profile inchangé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=false dè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: false aprè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")) → row email=new@example.com, email_verified=false, email_verified_at=NULL ; une entrée email_queue email_validation vers 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, tracing warn, queue vide (pas de panic).
  • No-op : email: Some(old) identique → email_verified reste true ; pas d’enqueue validation.
  • Username seul : email: None → e-mail/flags intacts.

Tests d’intégration :

  • Consommer un token EmailValidationService après v1 → email_verified=true, on_email_verified appelé (handler verify_email inchangé).
  • Admin update_user : même un-verify (test service suffit si le handler délègue).
  • Non-régression : create_user toujours email_verified=false.

Tests de non-régression :

  • Code consommateur compile.
  • PasswordChangeService::invalidate_on_email_change toujours 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

  1. Bump rustwarden-core à la version qui livre RFC 0038.
  2. Aucune migration SQL.
  3. rustwarden-ui : ne plus enqueue account_modified pour le seul changement d'e-mail (le core s'en charge) ; garder invalidate_on_email_change.
  4. 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).
  5. Vérifier SMTP / worker email_queue en prod avant le bump (sinon users unverified sans mail — resend-verification reste 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) |