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