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"); } }
GitRust