ajout de methodes

EBO <eric.bouhana@softalys.com> committé le 2026-03-26 16:10

52aa055d9a661cb9063a1646ef9a6f1b66f73547

1 parent(s)

5 fichiers modifiés +337 -43
M crates/rustwarden-core/src/hooks.rs
+14 -0
@@ -111,6 +111,20 @@ pub trait RustwardenHooks: Send + Sync {
111 111 Ok(())
112 112 }
113 113
114 + /// Called before a user is deleted.
115 + ///
116 + /// Use this hook to clean up business data owned by the user
117 + /// (walls, repos, workspaces, etc.) before the cascade delete
118 + /// removes framework-managed data.
119 + async fn on_user_deleted(
120 + &self,
121 + _db: &DatabaseConnection,
122 + _user_id: Uuid,
123 + _username: &str,
124 + ) -> anyhow::Result<()> {
125 + Ok(())
126 + }
127 +
114 128 /// Called after a successful OAuth login (new or existing user).
115 129 async fn on_oauth_login(
116 130 &self,
M crates/rustwarden-core/src/services/user_service.rs
+171 -1
@@ -1,8 +1,11 @@
1 1 use crate::error::AppError;
2 2 use crate::models::{role, user, user_role};
3 +use crate::services::password_service::PasswordService;
4 +use crate::services::refresh_token_service::RefreshTokenService;
3 5 use chrono::Utc;
4 6 use sea_orm::{
5 - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
7 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, NotSet,
8 + QueryFilter, Set,
6 9 };
7 10 use uuid::Uuid;
8 11
@@ -305,4 +308,171 @@ impl UserService {
305 308
306 309 Ok(())
307 310 }
311 +
312 + /// Remove a role from a user.
313 + ///
314 + /// Prevents removing the `admin` role from the last remaining admin
315 + /// to avoid locking out the application.
316 + ///
317 + /// # Errors
318 + ///
319 + /// * `AppError::Validation` if this would remove the last admin
320 + pub async fn unassign_role_from_user(
321 + db: &DatabaseConnection,
322 + user_id: Uuid,
323 + role_id: Uuid,
324 + ) -> Result<(), AppError> {
325 + // Protect last admin
326 + let role = role::Entity::find_by_id(role_id)
327 + .one(db)
328 + .await?
329 + .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?;
330 +
331 + if role.name == "admin" {
332 + let admin_count = Self::get_all_admin_users(db).await?.len();
333 + if admin_count <= 1 {
334 + return Err(AppError::Validation(
335 + "Cannot remove the admin role from the last administrator".to_string(),
336 + ));
337 + }
338 + }
339 +
340 + user_role::Entity::delete_many()
341 + .filter(user_role::Column::UserId.eq(user_id))
342 + .filter(user_role::Column::RoleId.eq(role_id))
343 + .exec(db)
344 + .await?;
345 +
346 + tracing::info!("Role '{}' removed from user {}", role.name, user_id);
347 + Ok(())
348 + }
349 +
350 + /// Delete a user and clean up all related data.
351 + ///
352 + /// This method:
353 + /// 1. Calls the `on_user_deleted` hook (so consumers can clean up business data)
354 + /// 2. Revokes all refresh tokens for the user
355 + /// 3. Deletes the user (cascade handles user_roles, resources, shares, oauth_accounts)
356 + ///
357 + /// # Errors
358 + ///
359 + /// * `AppError::NotFound` if user does not exist
360 + /// * `AppError::Validation` if user is the last admin
361 + pub async fn delete_user(
362 + db: &DatabaseConnection,
363 + user_id: Uuid,
364 + hooks: Option<&dyn crate::hooks::RustwardenHooks>,
365 + ) -> Result<(), AppError> {
366 + let user = Self::get_user_by_id(db, user_id)
367 + .await?
368 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
369 +
370 + // Protect last admin
371 + let roles = Self::get_user_roles(db, user_id).await?;
372 + if roles.contains(&"admin".to_string()) {
373 + let admin_count = Self::get_all_admin_users(db).await?.len();
374 + if admin_count <= 1 {
375 + return Err(AppError::Validation(
376 + "Cannot delete the last administrator".to_string(),
377 + ));
378 + }
379 + }
380 +
381 + // Hook: let consumers clean up their business data before cascade
382 + if let Some(hooks) = hooks {
383 + if let Err(e) = hooks.on_user_deleted(db, user_id, &user.username).await {
384 + tracing::warn!("on_user_deleted hook error for user {}: {}", user_id, e);
385 + }
386 + }
387 +
388 + // Revoke all refresh tokens (invalidates active sessions)
389 + RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?;
390 +
391 + // Delete user (cascade handles user_roles, resources, shares, oauth_accounts)
392 + user.delete(db).await?;
393 +
394 + tracing::info!("User {} deleted", user_id);
395 + Ok(())
396 + }
397 +
398 + /// Admin password reset: set a new password directly without email confirmation.
399 + ///
400 + /// Sets `must_change_password = true` so the user is forced to choose
401 + /// their own password at next login. Revokes all existing sessions (SEC-M7).
402 + ///
403 + /// # Arguments
404 + ///
405 + /// * `db` - Database connection
406 + /// * `user_id` - Target user ID
407 + /// * `new_password` - New plaintext password (will be hashed)
408 + pub async fn admin_reset_password(
409 + db: &DatabaseConnection,
410 + user_id: Uuid,
411 + new_password: &str,
412 + ) -> Result<(), AppError> {
413 + let hash = PasswordService::hash_password(new_password).await?;
414 + let user = Self::get_user_by_id(db, user_id)
415 + .await?
416 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
417 +
418 + let mut active: user::ActiveModel = user.into();
419 + active.password_hash = Set(hash);
420 + active.must_change_password = Set(true);
421 + active.password_changed_at = Set(Some(Utc::now()));
422 + active.updated_at = Set(Utc::now());
423 + active.update(db).await?;
424 +
425 + // SEC-M7: invalidate all sessions
426 + RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?;
427 +
428 + tracing::info!("Admin reset password for user {}", user_id);
429 + Ok(())
430 + }
431 +
432 + /// Change password with old password verification (for authenticated users).
433 + ///
434 + /// Simpler than `PasswordChangeService` (no email confirmation required).
435 + /// Validates password complexity, verifies old password, hashes new password,
436 + /// and revokes all sessions (SEC-M7).
437 + ///
438 + /// # Arguments
439 + ///
440 + /// * `db` - Database connection
441 + /// * `user_id` - Authenticated user ID
442 + /// * `old_password` - Current password for verification
443 + /// * `new_password` - New plaintext password
444 + pub async fn change_password_direct(
445 + db: &DatabaseConnection,
446 + user_id: Uuid,
447 + old_password: &str,
448 + new_password: &str,
449 + ) -> Result<(), AppError> {
450 + let user = Self::get_user_by_id(db, user_id)
451 + .await?
452 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
453 +
454 + if !PasswordService::verify_password(old_password, &user.password_hash).await? {
455 + return Err(AppError::Validation(
456 + "Current password is incorrect".to_string(),
457 + ));
458 + }
459 +
460 + crate::middleware::password_complexity::validate_password_complexity(db, new_password)
461 + .await?;
462 +
463 + let hash = PasswordService::hash_password(new_password).await?;
464 +
465 + let mut active: user::ActiveModel = user.into();
466 + active.password_hash = Set(hash);
467 + active.must_change_password = Set(false);
468 + active.password_changed_at = Set(Some(Utc::now()));
469 + active.updated_at = Set(Utc::now());
470 + active.update(db).await?;
471 +
472 + // SEC-M7: invalidate all sessions
473 + RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?;
474 +
475 + tracing::info!("Password changed directly for user {}", user_id);
476 + Ok(())
477 + }
308 478 }
M docs/MIGRATION_GUIDE.md
+56 -0
@@ -7,6 +7,62 @@ Les entrees sont classees par date, de la plus recente a la plus ancienne.
7 7
8 8 ---
9 9
10 +## 2026-03-26 — `UserService` : nouvelles methodes + hook `on_user_deleted`
11 +
12 +### Changement
13 +
14 +4 methodes ajoutees a `UserService` et 1 hook au trait `RustwardenHooks` :
15 +
16 +| Methode | Description |
17 +|---------|-------------|
18 +| `unassign_role_from_user(db, user_id, role_id)` | Retire un role. Protege le dernier admin. |
19 +| `delete_user(db, user_id, hooks)` | Supprime un utilisateur avec nettoyage complet. |
20 +| `admin_reset_password(db, user_id, new_password)` | Reset admin sans confirmation email. Force `must_change_password`. |
21 +| `change_password_direct(db, user_id, old, new)` | Changement direct avec verification de l'ancien mot de passe. |
22 +
23 +**Hook ajoute** : `on_user_deleted(db, user_id, username)` — appele avant la suppression
24 +pour permettre le nettoyage des donnees metier (walls, repos, etc.).
25 +
26 +### Migration
27 +
28 +**Aucune migration requise.** Toutes les methodes sont additives. Le hook a une
29 +implementation par defaut (no-op).
30 +
31 +### Remplacement des contournements
32 +
33 +Si votre projet manipulait directement les entites SeaORM pour ces operations,
34 +vous pouvez maintenant utiliser les methodes du framework :
35 +
36 +```rust
37 +// AVANT — suppression manuelle
38 +user::Entity::delete_by_id(user_id).exec(&db).await?;
39 +
40 +// APRES — nettoyage complet (tokens, hook, cascade)
41 +UserService::delete_user(&db, user_id, Some(&my_hooks)).await?;
42 +```
43 +
44 +```rust
45 +// AVANT — retrait de role manuel
46 +user_role::Entity::delete_many()
47 + .filter(user_role::Column::UserId.eq(user_id))
48 + .filter(user_role::Column::RoleId.eq(role_id))
49 + .exec(&db).await?;
50 +
51 +// APRES — avec protection du dernier admin
52 +UserService::unassign_role_from_user(&db, user_id, role_id).await?;
53 +```
54 +
55 +```rust
56 +// AVANT — reset admin manuel
57 +let hash = PasswordService::hash_password(new_pwd).await?;
58 +UserService::update_password_hash(&db, user_id, &hash).await?;
59 +
60 +// APRES — hash + must_change + revocation sessions
61 +UserService::admin_reset_password(&db, user_id, new_pwd).await?;
62 +```
63 +
64 +---
65 +
10 66 ## 2026-03-26 — `PasswordService` : methodes rendues async
11 67
12 68 ### Changement
M docs/SERVICES_REFERENCE.md
+5 -1
@@ -112,8 +112,12 @@ Gestion CRUD des utilisateurs et de leurs roles.
112 112 | `get_all_admin_users` | `(db) → Result<Vec<user::Model>>` | Liste les utilisateurs avec le role `admin`. |
113 113 | `get_user_roles` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne les noms des roles d'un utilisateur (ex: `["admin", "user"]`). |
114 114 | `assign_role_to_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model>` | Assigne un role a un utilisateur. |
115 +| `unassign_role_from_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<()>` | Retire un role a un utilisateur. Empeche de retirer le role `admin` au dernier administrateur. |
115 116 | `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username et de l'email. |
116 -| `change_password` | `(db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)>` | Initie un changement de mot de passe (cree une demande pendante). Retourne `(request_id, plain_token)` pour confirmation. |
117 +| `change_password` | `(db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)>` | Initie un changement de mot de passe avec confirmation email (cree une demande pendante). Retourne `(request_id, plain_token)`. |
118 +| `change_password_direct` | `(db, user_id: Uuid, old_password: &str, new_password: &str) → Result<()>` | Change le mot de passe directement (sans confirmation email). Verifie l'ancien mot de passe, valide la complexite, revoque les sessions (SEC-M7). |
119 +| `admin_reset_password` | `(db, user_id: Uuid, new_password: &str) → Result<()>` | Reset admin : hash et applique le nouveau mot de passe. Force `must_change_password = true`. Revoque les sessions (SEC-M7). |
120 +| `delete_user` | `(db, user_id: Uuid, hooks: Option<&dyn RustwardenHooks>) → Result<()>` | Supprime un utilisateur. Appelle le hook `on_user_deleted`, revoque les tokens, cascade DB. Empeche de supprimer le dernier admin. |
117 121 | `update_password_hash` | `(db, user_id: Uuid, password_hash: &str) → Result<()>` | Met a jour directement le hash — usage interne (PasswordChangeService). |
118 122
119 123 #### Modele `user::Model`
M docs/UPSTREAM_RUSTWARDEN.md
+91 -41
@@ -1,62 +1,112 @@
1 1 # Notes d'amelioration pour RustWarden-core
2 2
3 -## 1. `PasswordService` : operations bcrypt bloquantes dans un contexte async
3 +## 1. `UserService::delete_user` — IMPLEMENTE
4 4
5 -**Statut : IMPLEMENTE** (approche 2 — methodes rendues directement async)
5 +**Statut : IMPLEMENTE**
6 6
7 -**Probleme :** `PasswordService::hash_password` et `PasswordService::verify_password` etaient des fonctions synchrones executant bcrypt (CPU-intensif, ~100-300ms selon le cost factor). Elles bloquaient le thread du runtime Tokio.
7 +Methode ajoutee avec :
8 +- Protection contre la suppression du dernier admin
9 +- Hook `on_user_deleted` appele avant la suppression (permet aux consommateurs de nettoyer leurs donnees metier)
10 +- Revocation de tous les refresh tokens
11 +- Cascade DB pour user_roles, resources, shares, oauth_accounts
8 12
9 -**Solution implementee :** Les methodes ont ete rendues `async` avec `tokio::task::spawn_blocking` interne. Aucun suffixe `_async`, aucune duplication d'API. Tous les call sites (deja en contexte async) ont ete migres mecaniquement avec `.await`.
13 +```rust
14 +pub async fn delete_user(
15 + db: &DatabaseConnection,
16 + user_id: Uuid,
17 + hooks: Option<&dyn RustwardenHooks>,
18 +) -> Result<(), AppError>
19 +```
10 20
11 -**Approches evaluees et rejetees :**
12 -- **Suffixe `_async`** (ex: `hash_password_async`) — rejete car duplication d'API et anti-pattern en Rust
13 -- **Trait polymorphe** (`PasswordHasher`) — rejete car over-engineering (un seul algorithme utilise, pas de besoin de substitution)
21 +Le parametre `hooks` est optionnel (`None` si pas de nettoyage metier necessaire).
14 22
15 -**Fichiers modifies :**
23 +---
16 24
17 -| Fichier | Modification |
18 -|---------|-------------|
19 -| `services/password_service.rs` | `hash_password` et `verify_password` rendues `async` avec `spawn_blocking` |
20 -| `services/auth_service.rs` | `.await` sur `verify_password` + dummy hash migre vers `PasswordService::verify_password` |
21 -| `services/user_service.rs` | `.await` sur `verify_password` |
22 -| `services/password_change_service.rs` | `.await` sur `hash_password` |
23 -| `services/password_reset_service.rs` | `.await` sur `hash_password` |
24 -| `services/default_admin_service.rs` | `.await` sur `hash_password` |
25 -| `auth/handlers.rs` | `.await` sur `hash_password` (handler register) |
26 -| `bin/seed.rs` | `.await` sur `hash_password` (deja `#[tokio::main]`) |
27 -| `bin/change_password.rs` | `.await` sur `hash_password` (deja `#[tokio::main]`) |
28 -| `rustwarden-ui: admin_handler.rs` | `.await` sur `hash_password` (2 call sites) |
29 -| `rustwarden-ui: settings_handler.rs` | `.await` sur `verify_password` et `hash_password` |
30 -| `rustwarden-ui: user_handler.rs` | `.await` sur `hash_password` (2 call sites) |
25 +## 2. `UserService::unassign_role_from_user` — IMPLEMENTE
31 26
32 -**Note :** `bin/hash_password.rs` n'est pas impacte — il appelle `bcrypt::hash` directement (CLI sync sans runtime Tokio).
27 +**Statut : IMPLEMENTE**
33 28
34 -**Impact sur les consommateurs :** Breaking change mineur — tout appel a `PasswordService::hash_password()` ou `verify_password()` doit ajouter `.await`. Comme tous les contextes d'appel sont deja async, la migration est mecanique.
29 +Methode ajoutee avec protection contre le retrait du role admin au dernier administrateur.
30 +
31 +```rust
32 +pub async fn unassign_role_from_user(
33 + db: &DatabaseConnection,
34 + user_id: Uuid,
35 + role_id: Uuid,
36 +) -> Result<(), AppError>
37 +```
35 38
36 39 ---
37 40
38 -## 2. `UserService::create_user` : demande un hash pre-calcule
41 +## 3. `UserService::admin_reset_password` — IMPLEMENTE
42 +
43 +**Statut : IMPLEMENTE**
44 +
45 +Methode ajoutee avec :
46 +- Hash bcrypt async (via `spawn_blocking`)
47 +- `must_change_password = true` force au prochain login
48 +- `password_changed_at` mis a jour
49 +- SEC-M7 : revocation de tous les refresh tokens
50 +
51 +```rust
52 +pub async fn admin_reset_password(
53 + db: &DatabaseConnection,
54 + user_id: Uuid,
55 + new_password: &str,
56 +) -> Result<(), AppError>
57 +```
58 +
59 +---
39 60
40 -**Statut : A FAIRE** (depend de §1, qui est maintenant implemente)
61 +## 4. `UserService::change_password_direct` — IMPLEMENTE
41 62
42 -**Observation :** `create_user(db, username, email, password_hash, must_change_password)` attend un hash deja calcule. L'appelant doit connaitre l'implementation du hashing (bcrypt) et appeler `hash_password().await` lui-meme.
63 +**Statut : IMPLEMENTE**
43 64
44 -**Suggestion :** Ajouter une methode de convenance :
65 +Methode ajoutee avec :
66 +- Verification de l'ancien mot de passe
67 +- Validation de complexite via `validate_password_complexity`
68 +- Hash bcrypt async
69 +- `must_change_password = false`, `password_changed_at` mis a jour
70 +- SEC-M7 : revocation de tous les refresh tokens
45 71
46 72 ```rust
47 -impl UserService {
48 - /// Create a user from a plaintext password (hashes on a blocking thread).
49 - pub async fn create_user_with_password(
50 - db: &DatabaseConnection,
51 - username: String,
52 - email: String,
53 - password: &str,
54 - must_change_password: bool,
55 - ) -> Result<user::Model, AppError> {
56 - let hash = PasswordService::hash_password(password).await?;
57 - Self::create_user(db, username, email, hash, must_change_password).await
58 - }
73 +pub async fn change_password_direct(
74 + db: &DatabaseConnection,
75 + user_id: Uuid,
76 + old_password: &str,
77 + new_password: &str,
78 +) -> Result<(), AppError>
79 +```
80 +
81 +---
82 +
83 +## Hook ajoute : `on_user_deleted`
84 +
85 +**Trait `RustwardenHooks`** — nouveau hook :
86 +
87 +```rust
88 +async fn on_user_deleted(
89 + &self,
90 + _db: &DatabaseConnection,
91 + _user_id: Uuid,
92 + _username: &str,
93 +) -> anyhow::Result<()> {
94 + Ok(())
59 95 }
60 96 ```
61 97
62 -La methode existante reste disponible pour les cas ou le hash est deja calcule (import, migration).
98 +Appele par `delete_user` avant la suppression en base. Permet aux consommateurs de
99 +nettoyer leurs donnees metier (walls, repos, workspaces, etc.).
100 +
101 +---
102 +
103 +## Contournements obsoletes dans les projets consommateurs
104 +
105 +Les contournements suivants peuvent etre remplaces par les methodes du framework :
106 +
107 +| Contournement | Remplacer par |
108 +|--------------|---------------|
109 +| `Entity::delete_by_id()` direct | `UserService::delete_user(db, user_id, Some(&hooks))` |
110 +| `user_role::Entity::delete_many()` direct | `UserService::unassign_role_from_user(db, user_id, role_id)` |
111 +| `hash_password()` + `update_password_hash()` manuels | `UserService::admin_reset_password(db, user_id, new_password)` |
112 +| `verify_password()` + `hash_password()` + `update_password_hash()` | `UserService::change_password_direct(db, user_id, old, new)` |