04 — Hooks et événements
Rustwarden expose un mécanisme de hooks qui permet d'étendre le comportement du framework sans modifier son code source. Ce document décrit le trait RustwardenHooks, tous ses callbacks, leur cycle de vie, et comment les implémenter dans une application consommatrice.
1. Pourquoi des hooks
Le framework gère l'authentification, les rôles, les équipes et les ressources. Une application métier a souvent besoin de réagir à ces événements sans forker le framework :
- Email transactionnel : envoyer un email de bienvenue à l'inscription, un email de confirmation de changement de mot de passe.
- Audit log : écrire dans une table
audit_logsmétier chaque connexion ou suppression d'utilisateur. - Synchronisation CRM : pousser un nouveau profil utilisateur vers HubSpot ou Salesforce à l'inscription.
- Webhook sortant : notifier Slack ou Discord quand une équipe est créée.
- Initialisation de données : créer un espace de travail par défaut pour chaque nouvel utilisateur.
- Nettoyage : libérer des ressources filesystem ou des indices de recherche avant la suppression d'un utilisateur.
Le trait RustwardenHooks fournit des points d'injection à tous ces moments sans que l'application ait à toucher aux handlers ou services du core.
2. Le trait RustwardenHooks
Défini dans crates/rustwarden-core/src/hooks.rs.
#[async_trait] pub trait RustwardenHooks: Send + Sync { async fn on_user_registered( &self, _db: &DatabaseConnection, _user_id: Uuid, _username: &str, ) -> anyhow::Result<()> { Ok(()) } async fn on_user_login( &self, _db: &DatabaseConnection, _user_id: Uuid, _username: &str, ) -> anyhow::Result<()> { Ok(()) } async fn on_user_logout( &self, _db: &DatabaseConnection, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_password_changed( &self, _db: &DatabaseConnection, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_email_verified( &self, _db: &DatabaseConnection, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_role_changed( &self, _db: &DatabaseConnection, _user_id: Uuid, _roles: &[String], ) -> anyhow::Result<()> { Ok(()) } async fn on_resource_shared( &self, _db: &DatabaseConnection, _resource_type: &str, _resource_id: Uuid, _shared_with_user_id: Uuid, _permission_level: &str, _shared_by_user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_resource_unshared( &self, _db: &DatabaseConnection, _resource_type: &str, _resource_id: Uuid, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_user_deleted( &self, _db: &DatabaseConnection, _user_id: Uuid, _username: &str, ) -> anyhow::Result<()> { Ok(()) } async fn on_totp_enabled( &self, _db: &DatabaseConnection, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_totp_disabled( &self, _db: &DatabaseConnection, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_oauth_login( &self, _db: &DatabaseConnection, _user_id: Uuid, _provider: &str, _is_new_user: bool, ) -> anyhow::Result<()> { Ok(()) } async fn on_resource_registered( &self, _db: &DatabaseConnection, _resource_type: &str, _resource_id: Uuid, _owner_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_resource_unregistered( &self, _db: &DatabaseConnection, _resource_type: &str, _resource_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_team_created( &self, _db: &DatabaseConnection, _team_id: Uuid, _owner_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_team_deleted( &self, _db: &DatabaseConnection, _team_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_team_member_added( &self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_team_member_removed( &self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid, ) -> anyhow::Result<()> { Ok(()) } async fn on_audit_event( &self, _db: &DatabaseConnection, _event: AuditEvent<'_>, ) -> anyhow::Result<()> { Ok(()) } }
Toutes les méthodes ont une implémentation par défaut Ok(()). Une implémentation concrète ne surcharge que les callbacks qui la concernent.
Le trait exige Send + Sync car l'instance est partagée derrière un Arc<dyn RustwardenHooks> accessible depuis plusieurs threads Tokio simultanément.
3. NoopHooks — implémentation par défaut
NoopHooks est l'implémentation zéro-overhead utilisée quand aucun hook n'est configuré :
pub struct NoopHooks; #[async_trait] impl RustwardenHooks for NoopHooks {}
Le builder utilise NoopHooks par défaut :
// app.rs — Default impl de RustwardenBuilder hooks: Arc::new(NoopHooks),
4. Tableau des hooks
| Méthode | Déclencheur | Paramètres clés | Contexte d'appel |
|---|---|---|---|
on_user_registered | Après insertion en base d'un nouvel utilisateur | user_id, username | Awaité dans le handler d'inscription |
on_user_login | Après vérification réussie du mot de passe | user_id, username | Awaité dans le handler de login |
on_user_logout | Après invalidation de la session | user_id | Awaité dans le handler de logout |
on_password_changed | Après mise à jour du hash en base | user_id | Awaité dans le service de changement de mot de passe |
on_email_verified | Après validation du token d'email | user_id | Awaité dans le handler de vérification |
on_role_changed | Après mise à jour des rôles d'un utilisateur | user_id, roles[] | Awaité dans le handler admin |
on_resource_shared | Après insertion d'un resource_share | resource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_id | Awaité dans ResourceService::share_with |
on_resource_unshared | Après suppression d'un resource_share | resource_type, resource_id, user_id | Awaité dans ResourceService::unshare |
on_user_deleted | Avant la suppression en cascade de l'utilisateur | user_id, username | Awaité avant DELETE FROM users |
on_totp_enabled | Après activation du TOTP | user_id | Awaité dans TotpService |
on_totp_disabled | Après désactivation du TOTP | user_id | Awaité dans TotpService |
on_oauth_login | Après authentification OAuth réussie | user_id, provider, is_new_user | Awaité dans le handler OAuth |
on_resource_registered | Après insertion dans la table resources | resource_type, resource_id, owner_id | Awaité dans ResourceService::register |
on_resource_unregistered | Après suppression du registre | resource_type, resource_id | Awaité dans ResourceService::unregister |
on_team_created | Après insertion de la team et du premier membre | team_id, owner_id | Awaité dans TeamService::create |
on_team_deleted | Après suppression de la team | team_id | Awaité dans TeamService::delete |
on_team_member_added | Après insertion dans team_members | team_id, user_id | Awaité dans TeamService::add_member |
on_team_member_removed | Après suppression de team_members | team_id, user_id | Awaité dans TeamService::remove_member |
on_audit_event | Émis par AuthLoggingService ou par l'application | AuditEvent { kind, actor_id, target, metadata } | Awaité — peut bloquer si l'impl est lente |
Le payload AuditEvent
// hooks.rs:37 pub struct AuditEvent<'a> { pub kind: &'a str, // ex: "user.login_success" pub actor_id: Option<Uuid>, // None pour les événements système pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) pub metadata: &'a serde_json::Value, // données libres }
5. Cycle de vie d'un hook
Les hooks sont awaités directement dans le contexte de la requête ou du service qui les déclenche. Ils ne sont pas fire-and-forget par défaut. Si un hook est lent ou échoue, il affecte le temps de réponse de la requête.
Le pattern observable dans TeamService::create est représentatif :
if let Some(hooks) = hooks { if let Err(e) = hooks.on_team_created(db, team_id, owner_id).await { tracing::warn!(team_id = %team_id, "on_team_created hook failed: {}", e); } }
L'erreur retournée par le hook est absorbée : elle est loguée comme warning mais n'interrompt pas le flux principal. Le hook ne peut pas annuler une opération déjà commitée en base.
sequenceDiagram
participant H as Handler HTTP
participant S as TeamService
participant DB as PostgreSQL
participant HK as RustwardenHooks
H->>S: TeamService::create(db, owner_id, slug, name, hooks)
S->>DB: INSERT INTO teams ...
DB-->>S: team::Model
S->>DB: INSERT INTO team_members (owner)
DB-->>S: team_member::Model
S->>HK: hooks.on_team_created(db, team_id, owner_id).await
alt hook OK
HK-->>S: Ok(())
else hook échoue
HK-->>S: Err(e)
S->>S: tracing::warn!("on_team_created hook failed")
end
S-->>H: Ok(team::Model)
Conséquence : les hooks ne sont pas transactionnels
La mutation en base est commitée avant l'appel au hook. Si le hook envoie un email et que la connexion SMTP échoue, l'utilisateur est quand même créé. C'est un choix délibéré : la robustesse du core ne dépend pas de la fiabilité d'un système externe.
Pour les opérations critiques qui doivent être atomiques avec la mutation, utiliser une transaction applicative dans le hook lui-même ou une file de tâches avec retry.
6. Implémenter un hook custom
L'exemple complet est disponible dans app/examples/custom_hooks.rs.
Structure minimale
use async_trait::async_trait; use rustwarden_core::prelude::*; use sea_orm::DatabaseConnection; use uuid::Uuid; struct MyAppHooks; #[async_trait] impl RustwardenHooks for MyAppHooks { async fn on_user_registered( &self, _db: &DatabaseConnection, user_id: Uuid, username: &str, ) -> anyhow::Result<()> { tracing::info!("Nouvel utilisateur : {} ({})", username, user_id); // Créer un espace de travail par défaut, envoyer un email welcome, etc. Ok(()) } async fn on_user_login( &self, _db: &DatabaseConnection, _user_id: Uuid, username: &str, ) -> anyhow::Result<()> { tracing::info!("Connexion : {}", username); Ok(()) } }
Enregistrement dans le builder
#[tokio::main] async fn main() -> anyhow::Result<()> { RustwardenApp::builder() .from_env() .with_ui(rustwarden_ui::default_routes) .hooks(MyAppHooks) // <-- ici .auto_migrate(true) .build() .await? .run() .await }
La méthode hooks accepte n'importe quelle valeur qui implémente RustwardenHooks + 'static et l'emballe dans un Arc<dyn RustwardenHooks>.
Hook avec état partagé
Si l'implémentation a besoin d'un client HTTP ou d'une connexion à un système externe, les stocker dans la struct et dériver Clone si nécessaire :
use std::sync::Arc; struct MyAppHooks { http_client: reqwest::Client, webhook_url: Arc<str>, } impl MyAppHooks { fn new(webhook_url: &str) -> Self { Self { http_client: reqwest::Client::new(), webhook_url: webhook_url.into(), } } } #[async_trait] impl RustwardenHooks for MyAppHooks { async fn on_team_created( &self, _db: &DatabaseConnection, team_id: Uuid, _owner_id: Uuid, ) -> anyhow::Result<()> { let payload = serde_json::json!({ "text": format!("Nouvelle équipe créée : {}", team_id) }); self.http_client .post(self.webhook_url.as_ref()) .json(&payload) .send() .await?; Ok(()) } } // Dans main() : RustwardenApp::builder() .hooks(MyAppHooks::new("https://hooks.slack.com/services/T00/B00/xxx")) // ...
7. Hooks vs Observer (fédération)
Le système de hooks du core et le système d'observateurs de rustwarden-fediverse coexistent avec des périmètres distincts.
flowchart LR
subgraph core["rustwarden-core"]
HS[RustwardenHooks\ntrait]
EV[Événements lifecycle\nuser, team, resource, audit]
HS --- EV
end
subgraph fediverse["rustwarden-fediverse"]
OB[FediverseEventObserver\ntrait]
FEV[Événements AP\nFollow, Accept, Announce...]
OB --- FEV
end
APP[Application consommatrice] --> HS
APP --> OB
| Caractéristique | RustwardenHooks | FediverseEventObserver |
|---|---|---|
| Périmètre | Événements lifecycle utilisateur/team/resource | Activités ActivityPub entrantes et sortantes |
| Définition | rustwarden-core | rustwarden-fediverse |
| Invocation | Awaité dans les services core | Invoqué par le pipeline ActivityPub |
| Gestion d'erreur | Erreur absorbée (warn log) | Varie selon l'activité |
| Disponible sans fédération | Oui, toujours | Non, uniquement avec la crate fediverse |
8. Anti-patterns
Blocking I/O synchrone dans un hook async
// MAUVAIS — bloque le thread Tokio async fn on_user_registered(&self, ...) -> anyhow::Result<()> { std::thread::sleep(std::time::Duration::from_secs(2)); // bloque Ok(()) } // CORRECT — utiliser les primitives async async fn on_user_registered(&self, ...) -> anyhow::Result<()> { tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; Ok(()) }
Propager des erreurs métier pour annuler l'action principale
// MAUVAIS — l'erreur est absorbée par le framework, l'annulation n'est pas garantie async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid, _username: &str) -> anyhow::Result<()> { if self.crm_client.create_contact(user_id).await.is_err() { return Err(anyhow::anyhow!("CRM indisponible")); // n'annule PAS l'inscription } Ok(()) } // CORRECT — gérer l'échec localement, ne pas tenter d'annuler async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid, _username: &str) -> anyhow::Result<()> { if let Err(e) = self.crm_client.create_contact(user_id).await { tracing::error!(user_id = %user_id, "Échec sync CRM : {}", e); // Envoyer dans une file de retry si nécessaire } Ok(()) }
Dépendre de l'ordre d'exécution entre hooks
Le framework appelle les hooks séquentiellement dans l'ordre de leur définition. Cet ordre est un détail d'implémentation non documenté. Ne pas supposer qu'on_team_created est toujours appelé avant on_team_member_added lors d'une création d'équipe.
Appels DB lourds sans timeout
Les hooks reçoivent la DatabaseConnection du core. Un hook qui exécute une requête longue allonge le temps de réponse de la requête originale. Utiliser des timeouts explicites ou déléguer à une tâche de fond.
9. Use cases courants
Email transactionnel — welcome email
async fn on_user_registered( &self, db: &DatabaseConnection, user_id: Uuid, username: &str, ) -> anyhow::Result<()> { // Récupérer l'email depuis la base use rustwarden_core::services::user_service::UserService; if let Some(user) = UserService::get_by_id(db, user_id).await? { self.mailer .send_welcome(&user.email, &user.username) .await .ok(); // Ne pas propager — l'inscription reste valide même si l'email échoue } Ok(()) }
Audit log — écrire dans une table métier
async fn on_audit_event( &self, db: &DatabaseConnection, event: AuditEvent<'_>, ) -> anyhow::Result<()> { // Insérer dans la table audit_logs de l'application consommatrice sqlx::query!( "INSERT INTO audit_logs (kind, actor_id, created_at) VALUES ($1, $2, NOW())", event.kind, event.actor_id, ) .execute(&self.pool) .await?; Ok(()) }
Note : l'application consommatrice peut utiliser son propre pool (sqlx, diesel…) en parallèle du pool SeaORM du framework.
Webhook sortant vers Slack
async fn on_team_created( &self, _db: &DatabaseConnection, team_id: Uuid, owner_id: Uuid, ) -> anyhow::Result<()> { let body = serde_json::json!({ "text": format!(":busts_in_silhouette: Nouvelle équipe `{}` créée par `{}`", team_id, owner_id) }); // Erreur non propagée — le webhook Slack ne doit pas bloquer la création if let Err(e) = self.http.post(&self.slack_url).json(&body).send().await { tracing::warn!("Slack webhook failed: {}", e); } Ok(()) }
Sync vers CRM externe avec retry asynchrone
async fn on_user_registered( &self, _db: &DatabaseConnection, user_id: Uuid, _username: &str, ) -> anyhow::Result<()> { // Enqueue pour retry — ne pas bloquer la requête let tx = self.task_queue.clone(); tokio::spawn(async move { if let Err(e) = tx.send(CrmTask::CreateContact(user_id)).await { tracing::error!("Impossible d'enqueuer la sync CRM : {}", e); } }); Ok(()) }
10. Diagramme d'état d'un utilisateur et hooks associés
stateDiagram-v2
[*] --> Enregistré : POST /register
note right of Enregistré : on_user_registered
Enregistré --> EmailVérifié : GET /verify-email?token=...
note right of EmailVérifié : on_email_verified
EmailVérifié --> Connecté : POST /login (mdp)
Enregistré --> Connecté : POST /login (mdp, email non vérifié)
note right of Connecté : on_user_login
Connecté --> Connecté : POST /login (OAuth)
note right of Connecté : on_oauth_login
Connecté --> Déconnecté : POST /logout
note right of Déconnecté : on_user_logout
Déconnecté --> Connecté : POST /login
Connecté --> MdpModifié : PUT /profile/password
MdpModifié --> Connecté : (session maintenue)
note right of MdpModifié : on_password_changed
Connecté --> TOTPActivé : POST /totp/enable
note right of TOTPActivé : on_totp_enabled
TOTPActivé --> Connecté : POST /totp/disable
note right of Connecté : on_totp_disabled
Connecté --> RôlesModifiés : PATCH /admin/users/:id/roles
RôlesModifiés --> Connecté : (session maintenue)
note right of RôlesModifiés : on_role_changed
Connecté --> Supprimé : DELETE /admin/users/:id
note right of Supprimé : on_user_deleted (avant cascade)
Supprimé --> [*]
GitRust