hooks.rs 500 lignes · 14965 octets
//! Extension hooks for Rustwarden
//!
//! Implement `RustwardenHooks` to add custom behavior at key lifecycle events
//! (user registration, login, password change, etc.).
//!
//! # Example
//!
//! ```rust,ignore
//! use rustwarden_core::hooks::RustwardenHooks;
//! use async_trait::async_trait;
//!
//! struct MyHooks;
//!
//! #[async_trait]
//! impl RustwardenHooks for MyHooks {
//!     async fn on_user_registered(&self, db: &DatabaseConnection, user_id: Uuid) -> anyhow::Result<()> {
//!         // Create a default workspace for the new user
//!         Ok(())
//!     }
//! }
//! ```

use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use uuid::Uuid;

/// Payload handed to [`RustwardenHooks::on_audit_event`].
///
/// Generic escape hatch for framework-level audit wiring: business events that
/// don't warrant a dedicated trait method (issue created, PR merged, wall
/// shared, etc.) are dispatched here by the core, so consumer apps can plug
/// monitoring / SIEM indexing / compliance logging in one place without
/// forking the trait.
///
/// Borrowed fields are cheap — hook implementations should copy what they
/// need before returning (e.g. clone the metadata JSON into storage).
#[derive(Debug, Clone, Copy)]
pub struct AuditEvent<'a> {
    /// Short namespaced identifier (`"user.login_success"`, `"repo.created"`).
    pub kind: &'a str,
    /// User who triggered the event, if known (system events may leave it `None`).
    pub actor_id: Option<Uuid>,
    /// `(resource_type, resource_id)` of the event's target, if any.
    pub target: Option<(&'a str, Uuid)>,
    /// Free-form structured metadata — serialize-friendly; consumers decide
    /// the shape per `kind`.
    pub metadata: &'a serde_json::Value,
}

/// Lifecycle hooks for extending Rustwarden behavior.
///
/// All methods have default no-op implementations, so you only need to
/// override the ones you care about.
#[async_trait]
pub trait RustwardenHooks: Send + Sync {
    /// Called after a new user has been registered.
    async fn on_user_registered(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
        _username: &str,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a successful user login.
    async fn on_user_login(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
        _username: &str,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user logs out.
    async fn on_user_logout(&self, _db: &DatabaseConnection, _user_id: Uuid) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user's password has been changed.
    async fn on_password_changed(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user's email has been verified.
    async fn on_email_verified(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user's roles have been changed.
    async fn on_role_changed(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
        _roles: &[String],
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a resource has been shared with a user.
    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(())
    }

    /// Called after a resource share has been revoked.
    async fn on_resource_unshared(
        &self,
        _db: &DatabaseConnection,
        _resource_type: &str,
        _resource_id: Uuid,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called before a user is deleted.
    ///
    /// Use this hook to clean up business data owned by the user
    /// (walls, repos, workspaces, etc.) before the cascade delete
    /// removes framework-managed data.
    async fn on_user_deleted(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
        _username: &str,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user enables TOTP two-factor authentication.
    async fn on_totp_enabled(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user disables TOTP two-factor authentication.
    async fn on_totp_disabled(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a successful OAuth login (new or existing user).
    async fn on_oauth_login(
        &self,
        _db: &DatabaseConnection,
        _user_id: Uuid,
        _provider: &str,
        _is_new_user: bool,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a resource (repo, wall, workspace…) has been registered
    /// with [`ResourceService`](crate::services::resource_service::ResourceService).
    ///
    /// Replaces ad-hoc filtering by `resource_type == "…"` in custom hook
    /// implementations (see gitrust-hooks in the consumer ecosystem).
    async fn on_resource_registered(
        &self,
        _db: &DatabaseConnection,
        _resource_type: &str,
        _resource_id: Uuid,
        _owner_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a resource has been unregistered. Use this to release
    /// filesystem / external resources tied to the entity.
    async fn on_resource_unregistered(
        &self,
        _db: &DatabaseConnection,
        _resource_type: &str,
        _resource_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a team has been created. Prerequisite for Phase 5
    /// (`TeamService`) of the upstream evolution plan — consumers can pre-wire
    /// auditing today.
    async fn on_team_created(
        &self,
        _db: &DatabaseConnection,
        _team_id: Uuid,
        _owner_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a team has been deleted.
    async fn on_team_deleted(
        &self,
        _db: &DatabaseConnection,
        _team_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user has been added to a team.
    async fn on_team_member_added(
        &self,
        _db: &DatabaseConnection,
        _team_id: Uuid,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Called after a user has been removed from a team.
    async fn on_team_member_removed(
        &self,
        _db: &DatabaseConnection,
        _team_id: Uuid,
        _user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    /// Generic audit hook for events that do not warrant a dedicated trait
    /// method. Invoked by the framework's logging services (see
    /// [`AuthLoggingService`](crate::services::auth_logging::AuthLoggingService))
    /// and available for apps to emit their own events (issue created, PR
    /// merged, resource-specific operations…).
    ///
    /// Errors returned here are propagated to the caller — use with care; a
    /// panicking or slow hook affects the request path.
    async fn on_audit_event(
        &self,
        _db: &DatabaseConnection,
        _event: AuditEvent<'_>,
    ) -> anyhow::Result<()> {
        Ok(())
    }
}

/// Default no-op implementation of hooks.
pub struct NoopHooks;

#[async_trait]
impl RustwardenHooks for NoopHooks {}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    /// Collect every hook invocation so tests can assert dispatch.
    #[derive(Default)]
    struct RecordingHooks {
        events: Arc<Mutex<Vec<String>>>,
    }

    #[async_trait]
    impl RustwardenHooks for RecordingHooks {
        async fn on_resource_registered(
            &self,
            _db: &DatabaseConnection,
            resource_type: &str,
            resource_id: Uuid,
            owner_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events.lock().unwrap().push(format!(
                "registered:{resource_type}:{resource_id}:{owner_id}"
            ));
            Ok(())
        }

        async fn on_resource_unregistered(
            &self,
            _db: &DatabaseConnection,
            resource_type: &str,
            resource_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events
                .lock()
                .unwrap()
                .push(format!("unregistered:{resource_type}:{resource_id}"));
            Ok(())
        }

        async fn on_team_created(
            &self,
            _db: &DatabaseConnection,
            team_id: Uuid,
            owner_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events
                .lock()
                .unwrap()
                .push(format!("team_created:{team_id}:{owner_id}"));
            Ok(())
        }

        async fn on_team_deleted(
            &self,
            _db: &DatabaseConnection,
            team_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events
                .lock()
                .unwrap()
                .push(format!("team_deleted:{team_id}"));
            Ok(())
        }

        async fn on_team_member_added(
            &self,
            _db: &DatabaseConnection,
            team_id: Uuid,
            user_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events
                .lock()
                .unwrap()
                .push(format!("member_added:{team_id}:{user_id}"));
            Ok(())
        }

        async fn on_team_member_removed(
            &self,
            _db: &DatabaseConnection,
            team_id: Uuid,
            user_id: Uuid,
        ) -> anyhow::Result<()> {
            self.events
                .lock()
                .unwrap()
                .push(format!("member_removed:{team_id}:{user_id}"));
            Ok(())
        }

        async fn on_audit_event(
            &self,
            _db: &DatabaseConnection,
            event: AuditEvent<'_>,
        ) -> anyhow::Result<()> {
            let target = event
                .target
                .map(|(rt, rid)| format!("{rt}:{rid}"))
                .unwrap_or_else(|| "-".into());
            let actor = event
                .actor_id
                .map(|a| a.to_string())
                .unwrap_or_else(|| "-".into());
            self.events
                .lock()
                .unwrap()
                .push(format!("audit:{}:{}:{}", event.kind, actor, target));
            Ok(())
        }
    }

    /// Per-test connection — see personal_token_service::tests for rationale
    /// (a static OnceCell across `#[tokio::test]` runtimes causes
    /// "Tokio 1.x context being shutdown" errors).
    async fn db() -> DatabaseConnection {
        dotenvy::dotenv().ok();
        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for hook tests");
        sea_orm::Database::connect(&url)
            .await
            .expect("hook tests require a reachable DATABASE_URL")
    }

    #[tokio::test]
    async fn noop_hooks_accept_every_new_event_without_panicking() {
        let hooks = NoopHooks;
        let id = Uuid::new_v4();

        hooks
            .on_resource_registered(&db().await, "repo", id, id)
            .await
            .unwrap();
        hooks
            .on_resource_unregistered(&db().await, "repo", id)
            .await
            .unwrap();
        hooks.on_team_created(&db().await, id, id).await.unwrap();
        hooks.on_team_deleted(&db().await, id).await.unwrap();
        hooks
            .on_team_member_added(&db().await, id, id)
            .await
            .unwrap();
        hooks
            .on_team_member_removed(&db().await, id, id)
            .await
            .unwrap();
        hooks
            .on_audit_event(
                &db().await,
                AuditEvent {
                    kind: "noop.smoke",
                    actor_id: None,
                    target: None,
                    metadata: &serde_json::Value::Null,
                },
            )
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn custom_hooks_receive_each_event_with_correct_payload() {
        let hooks = RecordingHooks::default();
        let team = Uuid::new_v4();
        let owner = Uuid::new_v4();
        let member = Uuid::new_v4();
        let resource = Uuid::new_v4();

        hooks
            .on_resource_registered(&db().await, "repo", resource, owner)
            .await
            .unwrap();
        hooks
            .on_resource_unregistered(&db().await, "repo", resource)
            .await
            .unwrap();
        hooks
            .on_team_created(&db().await, team, owner)
            .await
            .unwrap();
        hooks
            .on_team_member_added(&db().await, team, member)
            .await
            .unwrap();
        hooks
            .on_team_member_removed(&db().await, team, member)
            .await
            .unwrap();
        hooks.on_team_deleted(&db().await, team).await.unwrap();

        let meta = serde_json::json!({"subject": "s"});
        hooks
            .on_audit_event(
                &db().await,
                AuditEvent {
                    kind: "pr.merged",
                    actor_id: Some(owner),
                    target: Some(("pr", resource)),
                    metadata: &meta,
                },
            )
            .await
            .unwrap();

        let events = hooks.events.lock().unwrap().clone();
        assert_eq!(
            events,
            vec![
                format!("registered:repo:{resource}:{owner}"),
                format!("unregistered:repo:{resource}"),
                format!("team_created:{team}:{owner}"),
                format!("member_added:{team}:{member}"),
                format!("member_removed:{team}:{member}"),
                format!("team_deleted:{team}"),
                format!("audit:pr.merged:{owner}:pr:{resource}"),
            ]
        );
    }

    #[test]
    fn audit_event_carries_structured_metadata() {
        let actor = Uuid::new_v4();
        let target_id = Uuid::new_v4();
        let meta = serde_json::json!({"ip": "127.0.0.1", "reason": "manual"});
        let event = AuditEvent {
            kind: "user.ban",
            actor_id: Some(actor),
            target: Some(("user", target_id)),
            metadata: &meta,
        };
        assert_eq!(event.kind, "user.ban");
        assert_eq!(event.actor_id, Some(actor));
        assert_eq!(event.target, Some(("user", target_id)));
        assert_eq!(event.metadata["ip"], "127.0.0.1");
    }
}