mod.rs 218 lignes · 7431 octets
//! Persistence layer for the fediverse guard.
//!
//! Three interchangeable backends implement the [`GuardStore`] trait:
//!
//! - [`MemoryStore`] — in-process `DashMap`, O(1) hot-path, lost on restart.
//! - `PostgresStore` (forthcoming) — SeaORM-backed durable store.
//! - `HybridStore` (forthcoming) — memory cache + write-through Postgres
//!   plus boot-time rehydration.

pub mod entities;
mod hybrid;
mod memory;
mod postgres;

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rustwarden_core::error::AppError;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

pub use hybrid::HybridStore;
pub use memory::MemoryStore;
pub use postgres::PostgresStore;

use crate::events::{GuardEvent, GuardEventKind};

/// Active ban row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BanEntry {
    /// Surrogate UUID for the ban row.
    pub id: Uuid,
    /// Lower-cased domain the ban applies to (always populated).
    pub domain: String,
    /// Optional ActivityPub actor URI to narrow the ban to a single
    /// account on `domain`. `None` means the whole domain is banned.
    pub actor_uri: Option<String>,
    /// Human-readable reason recorded for the audit trail.
    pub reason: String,
    /// Severity: `"suspend"`, `"silence"`, or `"rate_limit"`.
    pub severity: String,
    /// `"auto:<detector_name>"` for detector-driven bans, or
    /// `"manual:<admin_uuid>"` for an admin's manual action.
    pub triggered_by: String,
    /// When the ban was first applied.
    pub banned_at: DateTime<Utc>,
    /// `None` means permanent.
    pub expires_at: Option<DateTime<Utc>>,
    /// Set when the ban was lifted (TTL or admin override).
    pub lifted_at: Option<DateTime<Utc>>,
    /// Admin user that lifted the ban, when applicable.
    pub lifted_by: Option<Uuid>,
}

impl BanEntry {
    /// Returns `true` if this row is still active at `now` (i.e. not
    /// lifted and either permanent or before `expires_at`).
    #[must_use]
    pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
        if self.lifted_at.is_some() {
            return false;
        }
        match self.expires_at {
            Some(exp) => now < exp,
            None => true,
        }
    }
}

/// Manual allow/deny list entry. Allow rules bypass auto-ban; deny
/// rules force ban even before a detector trips.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AclKind {
    /// The domain is whitelisted; auto-ban detectors must not target it.
    Allow,
    /// The domain is blacklisted; treated as permanently banned.
    Deny,
}

/// Manual ACL row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclEntry {
    /// Surrogate UUID for the ACL row.
    pub id: Uuid,
    /// Allow or deny.
    pub kind: AclKind,
    /// Lower-cased domain the entry applies to.
    pub domain: String,
    /// Free-form reason captured for audit.
    pub reason: Option<String>,
    /// FK to `users.id` of the admin that created the entry.
    pub created_by: Uuid,
    /// When the entry was first inserted.
    pub created_at: DateTime<Utc>,
    /// Last mutation timestamp.
    pub updated_at: DateTime<Utc>,
}

/// Abstract backend for guard state.
///
/// Implementations must be cheap to clone (`Arc`) and safe to share
/// across handlers and background workers.
#[async_trait]
pub trait GuardStore: Send + Sync {
    /// Persist one raw event.
    async fn insert_event(&self, event: &GuardEvent) -> Result<(), AppError>;

    /// Count events of `kind` for `domain` since `since`. Powers the
    /// sliding-window detectors.
    async fn count_events_since(
        &self,
        domain: &str,
        kind: GuardEventKind,
        since: DateTime<Utc>,
    ) -> Result<u64, AppError>;

    /// Insert a new ban row. Implementations may decline silently if
    /// an equivalent active ban already exists.
    async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError>;

    /// Return every active ban whose domain matches `domain` and that
    /// is still effective at `now`.
    async fn active_bans_for_domain(
        &self,
        domain: &str,
        now: DateTime<Utc>,
    ) -> Result<Vec<BanEntry>, AppError>;

    /// Return every ban still active at `now`, regardless of domain.
    /// Powers the `HybridStore::rehydrate` boot-time path that
    /// repopulates the in-memory cache from durable storage.
    async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError>;

    /// Mark a ban as lifted.
    async fn lift_ban(
        &self,
        ban_id: Uuid,
        by: Option<Uuid>,
        at: DateTime<Utc>,
    ) -> Result<(), AppError>;

    /// Insert or update an ACL row.
    async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError>;

    /// Look up the ACL kind for `domain`, if any.
    async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError>;

    /// Remove an ACL row by id.
    async fn delete_acl(&self, id: Uuid) -> Result<(), AppError>;

    /// Return every ACL row. Powers `HybridStore::rehydrate` at boot
    /// and the admin "list ACL" endpoint.
    async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError>;
}

/// Normalise a user-supplied domain string to the canonical form used
/// everywhere in this crate (lowercase, stripped of scheme/trailing
/// slash/whitespace).
#[must_use]
pub fn normalise_domain(input: &str) -> String {
    // Lowercase first so the prefix-strip is case-insensitive
    // (`HTTPS://` would otherwise survive intact).
    let lower = input.trim().to_ascii_lowercase();
    lower
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .trim_end_matches('/')
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalise_domain_strips_scheme_and_trailing_slash() {
        assert_eq!(normalise_domain("  https://Foo.TLD/  "), "foo.tld");
        assert_eq!(normalise_domain("http://bar.org"), "bar.org");
        assert_eq!(normalise_domain("baz.net"), "baz.net");
    }

    #[test]
    fn ban_entry_is_active_until_expires_at() {
        let now = Utc::now();
        let active = BanEntry {
            id: Uuid::new_v4(),
            domain: "evil.tld".into(),
            actor_uri: None,
            reason: "spam".into(),
            severity: "suspend".into(),
            triggered_by: "auto:signature_failure".into(),
            banned_at: now - chrono::Duration::minutes(1),
            expires_at: Some(now + chrono::Duration::hours(1)),
            lifted_at: None,
            lifted_by: None,
        };
        assert!(active.is_active_at(now));
        assert!(!active.is_active_at(now + chrono::Duration::hours(2)));
    }

    #[test]
    fn ban_entry_lifted_is_inactive_even_before_expiry() {
        let now = Utc::now();
        let lifted = BanEntry {
            id: Uuid::new_v4(),
            domain: "evil.tld".into(),
            actor_uri: None,
            reason: "spam".into(),
            severity: "suspend".into(),
            triggered_by: "manual:admin".into(),
            banned_at: now - chrono::Duration::minutes(10),
            expires_at: Some(now + chrono::Duration::hours(1)),
            lifted_at: Some(now - chrono::Duration::minutes(1)),
            lifted_by: Some(Uuid::new_v4()),
        };
        assert!(!lifted.is_active_at(now));
    }
}