ban.rs
317 lignes · 10049 octets
//! Ban-decision engine on top of the [`GuardStore`]. //! //! Composition is: //! //! 1. ACL `Deny` rule → ban //! 2. Active `BanEntry` in store → ban //! 3. ACL `Allow` rule → never ban (defence in depth even if a //! detector forgets to check) //! 4. otherwise → allow //! //! Auto-ban requests pass through [`BanManager::auto_ban`], which //! honours the dry-run flag of [`crate::GuardConfig`] and refuses to //! ban allow-listed domains. use std::sync::Arc; use chrono::Utc; use rustwarden_core::error::AppError; use uuid::Uuid; use crate::config::GuardConfig; use crate::events::{GuardEvent, GuardEventKind, Severity}; use crate::store::{AclEntry, AclKind, BanEntry, GuardStore}; /// Effective decision for one (domain, actor_uri?) pair. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BanDecision { /// Allow the request (no ban, no deny rule). Allow, /// Deny the request (active ban or deny ACL). Deny, } /// Wraps a [`GuardStore`] with the [`GuardConfig`] runtime knobs and /// exposes a small surface used by handlers and detectors. #[derive(Clone)] pub struct BanManager { store: Arc<dyn GuardStore>, config: GuardConfig, } impl BanManager { /// Build a new manager. #[must_use] pub fn new(store: Arc<dyn GuardStore>, config: GuardConfig) -> Self { Self { store, config } } /// Borrow the underlying store. Useful for tests and read-only /// admin paths. #[must_use] pub fn store(&self) -> &Arc<dyn GuardStore> { &self.store } /// Borrow the active [`GuardConfig`]. #[must_use] pub fn config(&self) -> &GuardConfig { &self.config } /// Effective decision for `domain` right now. /// /// # Errors /// /// Propagates errors from the underlying store. pub async fn decide(&self, domain: &str) -> Result<BanDecision, AppError> { if !self.config.enabled { return Ok(BanDecision::Allow); } if let Some(acl) = self.store.acl_for_domain(domain).await? { match acl.kind { AclKind::Deny => return Ok(BanDecision::Deny), AclKind::Allow => return Ok(BanDecision::Allow), } } let now = Utc::now(); let active = self.store.active_bans_for_domain(domain, now).await?; if active.is_empty() { Ok(BanDecision::Allow) } else { Ok(BanDecision::Deny) } } /// Convenience around [`Self::decide`] returning `true` when the /// domain is banned. /// /// # Errors /// /// Propagates errors from the underlying store. pub async fn is_banned(&self, domain: &str) -> Result<bool, AppError> { Ok(matches!(self.decide(domain).await?, BanDecision::Deny)) } /// Persist a ban triggered by a detector. /// /// No-op when the guard is disabled, when `dry_run` is on, when /// the domain has an explicit allow ACL, or when an equivalent /// active ban already exists. /// /// # Errors /// /// Propagates errors from the underlying store. pub async fn auto_ban( &self, domain: &str, detector_name: &'static str, reason: impl Into<String>, ) -> Result<Option<BanEntry>, AppError> { if !self.config.enabled || self.config.dry_run { return Ok(None); } if let Some(acl) = self.store.acl_for_domain(domain).await? { if acl.kind == AclKind::Allow { tracing::debug!(domain, detector_name, "auto-ban skipped: ACL allow"); return Ok(None); } } let now = Utc::now(); if !self .store .active_bans_for_domain(domain, now) .await? .is_empty() { return Ok(None); } let expires_at = self.config.auto_ban_duration.map(|d| { now + chrono::Duration::from_std(d).unwrap_or_else(|_| chrono::Duration::hours(1)) }); let ban = BanEntry { id: Uuid::new_v4(), domain: domain.to_string(), actor_uri: None, reason: reason.into(), severity: "suspend".to_string(), triggered_by: format!("auto:{detector_name}"), banned_at: now, expires_at, lifted_at: None, lifted_by: None, }; self.store.insert_ban(&ban).await?; let evt = GuardEvent { id: Uuid::new_v4(), kind: GuardEventKind::Banned, severity: Severity::Critical, domain: domain.to_string(), actor_uri: None, remote_identity_id: None, http_status: None, details: serde_json::json!({ "detector": detector_name, "ban_id": ban.id, }), created_at: now, }; self.store.insert_event(&evt).await?; tracing::warn!( domain, detector_name, ban_id = %ban.id, "fediverse guard auto-ban triggered", ); Ok(Some(ban)) } /// Insert or update an ACL entry. /// /// # Errors /// /// Propagates errors from the underlying store. pub async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> { self.store.upsert_acl(entry).await } /// Lift a ban manually (admin action). /// /// # Errors /// /// Propagates errors from the underlying store. pub async fn lift(&self, ban_id: Uuid, by: Uuid) -> Result<(), AppError> { self.store.lift_ban(ban_id, Some(by), Utc::now()).await?; let evt = GuardEvent { id: Uuid::new_v4(), kind: GuardEventKind::Unbanned, severity: Severity::Info, domain: String::new(), // resolved by reader if needed actor_uri: None, remote_identity_id: None, http_status: None, details: serde_json::json!({"ban_id": ban_id, "lifted_by": by}), created_at: Utc::now(), }; self.store.insert_event(&evt).await } } #[cfg(test)] mod tests { use super::*; use crate::store::MemoryStore; fn manager() -> BanManager { BanManager::new(Arc::new(MemoryStore::new()), GuardConfig::for_tests()) } #[tokio::test] async fn unknown_domain_is_allowed() { let m = manager(); assert_eq!(m.decide("clean.tld").await.unwrap(), BanDecision::Allow); } #[tokio::test] async fn deny_acl_forces_ban() { let m = manager(); m.upsert_acl(&AclEntry { id: Uuid::new_v4(), kind: AclKind::Deny, domain: "evil.tld".into(), reason: Some("admin".into()), created_by: Uuid::new_v4(), created_at: Utc::now(), updated_at: Utc::now(), }) .await .unwrap(); assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Deny); } #[tokio::test] async fn allow_acl_blocks_auto_ban() { let m = manager(); m.upsert_acl(&AclEntry { id: Uuid::new_v4(), kind: AclKind::Allow, domain: "trusted.tld".into(), reason: Some("peer".into()), created_by: Uuid::new_v4(), created_at: Utc::now(), updated_at: Utc::now(), }) .await .unwrap(); let result = m .auto_ban("trusted.tld", "signature_failure", "test") .await .unwrap(); assert!(result.is_none(), "ACL allow must prevent auto-ban"); assert_eq!(m.decide("trusted.tld").await.unwrap(), BanDecision::Allow); } #[tokio::test] async fn auto_ban_inserts_active_row_and_emits_event() { let m = manager(); let ban = m .auto_ban("evil.tld", "signature_failure", "10 sig failures in 5 min") .await .unwrap() .expect("auto_ban must persist a ban"); assert_eq!(ban.domain, "evil.tld"); assert!(ban.triggered_by.starts_with("auto:")); assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Deny); } #[tokio::test] async fn auto_ban_is_idempotent_within_window() { let m = manager(); let first = m.auto_ban("evil.tld", "x", "r").await.unwrap(); let second = m.auto_ban("evil.tld", "x", "r").await.unwrap(); assert!(first.is_some()); assert!(second.is_none(), "second auto_ban must be a no-op"); } #[tokio::test] async fn dry_run_emits_nothing() { let mut cfg = GuardConfig::for_tests(); cfg.dry_run = true; let m = BanManager::new(Arc::new(MemoryStore::new()), cfg); let result = m.auto_ban("evil.tld", "x", "r").await.unwrap(); assert!(result.is_none()); assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow); } #[tokio::test] async fn disabled_guard_always_allows() { let mut cfg = GuardConfig::for_tests(); cfg.enabled = false; let m = BanManager::new(Arc::new(MemoryStore::new()), cfg); // Even with a deny ACL the guard is off. m.upsert_acl(&AclEntry { id: Uuid::new_v4(), kind: AclKind::Deny, domain: "evil.tld".into(), reason: None, created_by: Uuid::new_v4(), created_at: Utc::now(), updated_at: Utc::now(), }) .await .unwrap(); assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow); } #[tokio::test] async fn lift_marks_ban_inactive_and_emits_unbanned_event() { let m = manager(); let ban = m .auto_ban("evil.tld", "x", "r") .await .unwrap() .expect("ban persisted"); m.lift(ban.id, Uuid::new_v4()).await.unwrap(); assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow); } }
GitRust