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