04-hooks-and-events.md 19840 octets

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_logs mé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éthodeDéclencheurParamètres clésContexte d'appel
on_user_registeredAprès insertion en base d'un nouvel utilisateuruser_id, usernameAwaité dans le handler d'inscription
on_user_loginAprès vérification réussie du mot de passeuser_id, usernameAwaité dans le handler de login
on_user_logoutAprès invalidation de la sessionuser_idAwaité dans le handler de logout
on_password_changedAprès mise à jour du hash en baseuser_idAwaité dans le service de changement de mot de passe
on_email_verifiedAprès validation du token d'emailuser_idAwaité dans le handler de vérification
on_role_changedAprès mise à jour des rôles d'un utilisateuruser_id, roles[]Awaité dans le handler admin
on_resource_sharedAprès insertion d'un resource_shareresource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_idAwaité dans ResourceService::share_with
on_resource_unsharedAprès suppression d'un resource_shareresource_type, resource_id, user_idAwaité dans ResourceService::unshare
on_user_deletedAvant la suppression en cascade de l'utilisateuruser_id, usernameAwaité avant DELETE FROM users
on_totp_enabledAprès activation du TOTPuser_idAwaité dans TotpService
on_totp_disabledAprès désactivation du TOTPuser_idAwaité dans TotpService
on_oauth_loginAprès authentification OAuth réussieuser_id, provider, is_new_userAwaité dans le handler OAuth
on_resource_registeredAprès insertion dans la table resourcesresource_type, resource_id, owner_idAwaité dans ResourceService::register
on_resource_unregisteredAprès suppression du registreresource_type, resource_idAwaité dans ResourceService::unregister
on_team_createdAprès insertion de la team et du premier membreteam_id, owner_idAwaité dans TeamService::create
on_team_deletedAprès suppression de la teamteam_idAwaité dans TeamService::delete
on_team_member_addedAprès insertion dans team_membersteam_id, user_idAwaité dans TeamService::add_member
on_team_member_removedAprès suppression de team_membersteam_id, user_idAwaité dans TeamService::remove_member
on_audit_eventÉmis par AuthLoggingService ou par l'applicationAuditEvent { 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éristiqueRustwardenHooksFediverseEventObserver
PérimètreÉvénements lifecycle utilisateur/team/resourceActivités ActivityPub entrantes et sortantes
Définitionrustwarden-corerustwarden-fediverse
InvocationAwaité dans les services coreInvoqué par le pipeline ActivityPub
Gestion d'erreurErreur absorbée (warn log)Varie selon l'activité
Disponible sans fédérationOui, toujoursNon, 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é --> [*]