fediverse_trust_service.rs
456 lignes · 16969 octets
//! Federation trust model service (RFC 0004). //! //! Implements the runtime-configurable trust check applied **before** any //! signature verification or fetch towards a remote Fediverse domain. The //! mode (allowlist / open + blocklist), the global enabled flag, and the //! interop scope are stored in `app_settings`; the lists themselves live //! in `fediverse_trusted_domains` and `fediverse_blocked_domains`. //! use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use crate::models::{fediverse_blocked_domain, fediverse_trusted_domain}; use rustwarden_core::error::AppError; use rustwarden_core::services::app_settings_service::AppSettingsService; /// Available federation trust modes (`app_settings.fediverse_trust_mode`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustMode { /// Zero-trust default: only domains explicitly added to the allowlist /// are accepted. Allowlist, /// Mastodon-style: any domain is accepted unless present in the /// blocklist. OpenWithBlocklist, } impl TrustMode { /// Parse the wire form stored in `app_settings`. pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "open_with_blocklist" | "open" | "blocklist" => Self::OpenWithBlocklist, _ => Self::Allowlist, } } } /// Available interop scopes (`app_settings.fediverse_interop_scope`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InteropScope { /// Authentication and invitations restricted to Rustwarden instances. RustwardenOnly, /// Auth restricted to Rustwarden, but profile reads accepted from any /// AP-compatible instance (Mastodon discovery use-case). RustwardenPlusUniversalRead, /// Anything goes (advanced). Open, } impl InteropScope { /// Parse the wire form stored in `app_settings`. pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "rustwarden_plus_universal_read" | "universal_read" => { Self::RustwardenPlusUniversalRead } "open" => Self::Open, _ => Self::RustwardenOnly, } } } /// Severity level of a [`fediverse_blocked_domain`] entry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlockSeverity { /// Reject all activities and auth requests from the domain. Suspend, /// Accept but flag activities silently. Silence, /// Reject auth specifically (allow read-only profile lookups). RejectAuth, } impl BlockSeverity { /// Parse the wire form. Falls back to [`BlockSeverity::Suspend`] for /// unknown values (conservative default). pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "silence" => Self::Silence, "reject_auth" => Self::RejectAuth, _ => Self::Suspend, } } } /// Normalise a domain to its canonical comparison form: trim whitespace, /// lowercase, strip a single leading `www.` segment. pub fn normalize_domain(raw: &str) -> String { let trimmed = raw.trim().to_ascii_lowercase(); trimmed .strip_prefix("www.") .map(|s| s.to_string()) .unwrap_or(trimmed) } /// Service exposing the central `is_domain_allowed` gate plus admin CRUD /// over the trust/block lists. #[derive(Clone)] pub struct FediverseTrustService { db: DatabaseConnection, } impl FediverseTrustService { /// Build a service bound to the given DB connection. pub fn new(db: DatabaseConnection) -> Self { Self { db } } /// Read `app_settings.fediverse_enabled` (default `false`). pub async fn is_enabled(&self) -> Result<bool, AppError> { AppSettingsService::get_bool(&self.db, "fediverse_enabled", false).await } /// Read `app_settings.fediverse_trust_mode` (default /// [`TrustMode::Allowlist`]). pub async fn trust_mode(&self) -> Result<TrustMode, AppError> { let raw = AppSettingsService::get_setting(&self.db, "fediverse_trust_mode").await?; Ok(raw .map(|m| TrustMode::parse(&m.value)) .unwrap_or(TrustMode::Allowlist)) } /// Read `app_settings.fediverse_interop_scope` (default /// [`InteropScope::RustwardenOnly`]). pub async fn interop_scope(&self) -> Result<InteropScope, AppError> { let raw = AppSettingsService::get_setting(&self.db, "fediverse_interop_scope").await?; Ok(raw .map(|m| InteropScope::parse(&m.value)) .unwrap_or(InteropScope::RustwardenOnly)) } /// Central trust check applied **before** any signature verification or /// fetch towards a remote domain. /// /// Returns `false` (deny) when: /// - `app_settings.fediverse_enabled = false` /// - mode = [`TrustMode::Allowlist`] and `domain` is **not** in /// `fediverse_trusted_domains` /// - mode = [`TrustMode::OpenWithBlocklist`] and `domain` is in /// `fediverse_blocked_domains` /// /// `domain` is normalised via [`normalize_domain`] before lookup. pub async fn is_domain_allowed(&self, domain: &str) -> Result<bool, AppError> { if !self.is_enabled().await? { return Ok(false); } let normalized = normalize_domain(domain); match self.trust_mode().await? { TrustMode::Allowlist => self.is_in_trusted(&normalized).await, TrustMode::OpenWithBlocklist => Ok(!self.is_in_blocked(&normalized).await?), } } /// F8 / RFC 0027 enforcement helper. Returns `Ok(())` when the /// origin domain of an inbound activity is allowed; otherwise /// returns [`AppError::Validation`] with a message safe to surface /// (no leakage of internal state beyond the domain name). /// /// Intended call sites: top of every protocolar inbox / route that /// accepts data from outside. Cheap (2 SELECTs at most) — caches /// can be added later if needed. pub async fn assert_domain_allowed(&self, domain: &str) -> Result<(), AppError> { if self.is_domain_allowed(domain).await? { Ok(()) } else { Err(AppError::Validation(format!( "domain `{}` is not allowed by this server's trust policy", normalize_domain(domain) ))) } } async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> { Ok(fediverse_trusted_domain::Entity::find() .filter(fediverse_trusted_domain::Column::Domain.eq(domain)) .one(&self.db) .await? .is_some()) } async fn is_in_blocked(&self, domain: &str) -> Result<bool, AppError> { Ok(fediverse_blocked_domain::Entity::find() .filter(fediverse_blocked_domain::Column::Domain.eq(domain)) .one(&self.db) .await? .is_some()) } /// Insert a new entry in the trusted-domains allowlist. pub async fn add_trusted_domain( &self, domain: &str, note: Option<&str>, added_by: Option<Uuid>, ) -> Result<fediverse_trusted_domain::Model, AppError> { let now = Utc::now(); let model = fediverse_trusted_domain::ActiveModel { id: Set(Uuid::new_v4()), domain: Set(normalize_domain(domain)), note: Set(note.map(str::to_string)), added_by_user_id: Set(added_by), created_at: Set(now), }; Ok(model.insert(&self.db).await?) } /// Remove an entry from the trusted-domains allowlist. /// Returns `true` if a row was deleted, `false` if no match was found. pub async fn remove_trusted_domain(&self, domain: &str) -> Result<bool, AppError> { let normalized = normalize_domain(domain); let result = fediverse_trusted_domain::Entity::delete_many() .filter(fediverse_trusted_domain::Column::Domain.eq(normalized)) .exec(&self.db) .await?; Ok(result.rows_affected > 0) } /// List all entries in the trusted-domains allowlist. pub async fn list_trusted_domains( &self, ) -> Result<Vec<fediverse_trusted_domain::Model>, AppError> { Ok(fediverse_trusted_domain::Entity::find() .all(&self.db) .await?) } /// Insert a new entry in the blocked-domains blocklist. pub async fn add_blocked_domain( &self, domain: &str, severity: BlockSeverity, reason: Option<&str>, added_by: Option<Uuid>, ) -> Result<fediverse_blocked_domain::Model, AppError> { let now = Utc::now(); let severity_str = match severity { BlockSeverity::Suspend => "suspend", BlockSeverity::Silence => "silence", BlockSeverity::RejectAuth => "reject_auth", }; let model = fediverse_blocked_domain::ActiveModel { id: Set(Uuid::new_v4()), domain: Set(normalize_domain(domain)), severity: Set(severity_str.to_string()), reason: Set(reason.map(str::to_string)), added_by_user_id: Set(added_by), created_at: Set(now), }; Ok(model.insert(&self.db).await?) } /// Remove an entry from the blocked-domains blocklist. /// Returns `true` if a row was deleted. pub async fn remove_blocked_domain(&self, domain: &str) -> Result<bool, AppError> { let normalized = normalize_domain(domain); let result = fediverse_blocked_domain::Entity::delete_many() .filter(fediverse_blocked_domain::Column::Domain.eq(normalized)) .exec(&self.db) .await?; Ok(result.rows_affected > 0) } /// List all entries in the blocked-domains blocklist. pub async fn list_blocked_domains( &self, ) -> Result<Vec<fediverse_blocked_domain::Model>, AppError> { Ok(fediverse_blocked_domain::Entity::find() .all(&self.db) .await?) } } #[cfg(test)] mod tests { use super::*; use rustwarden_core::models::app_settings; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn enabled_setting(value: &str) -> app_settings::Model { let now = Utc::now(); app_settings::Model { id: Uuid::new_v4(), key: "fediverse_enabled".to_string(), value: value.to_string(), description: None, updated_by: None, updated_at: now, } } fn mode_setting(value: &str) -> app_settings::Model { let now = Utc::now(); app_settings::Model { id: Uuid::new_v4(), key: "fediverse_trust_mode".to_string(), value: value.to_string(), description: None, updated_by: None, updated_at: now, } } fn trusted_row(domain: &str) -> fediverse_trusted_domain::Model { let now = Utc::now(); fediverse_trusted_domain::Model { id: Uuid::new_v4(), domain: domain.to_string(), note: None, added_by_user_id: None, created_at: now, } } fn blocked_row(domain: &str) -> fediverse_blocked_domain::Model { let now = Utc::now(); fediverse_blocked_domain::Model { id: Uuid::new_v4(), domain: domain.to_string(), severity: "suspend".to_string(), reason: None, added_by_user_id: None, created_at: now, } } // ─── Pure helpers ──────────────────────────────────────────────── #[test] fn trust_mode_parses_known_values() { assert_eq!(TrustMode::parse("allowlist"), TrustMode::Allowlist); assert_eq!( TrustMode::parse("open_with_blocklist"), TrustMode::OpenWithBlocklist ); assert_eq!(TrustMode::parse("open"), TrustMode::OpenWithBlocklist); } #[test] fn trust_mode_falls_back_to_allowlist_for_unknown() { assert_eq!(TrustMode::parse("garbage"), TrustMode::Allowlist); assert_eq!(TrustMode::parse(""), TrustMode::Allowlist); } #[test] fn interop_scope_parses_known_values() { assert_eq!( InteropScope::parse("rustwarden_only"), InteropScope::RustwardenOnly ); assert_eq!( InteropScope::parse("rustwarden_plus_universal_read"), InteropScope::RustwardenPlusUniversalRead ); assert_eq!(InteropScope::parse("open"), InteropScope::Open); } #[test] fn block_severity_parses_known_values() { assert_eq!(BlockSeverity::parse("suspend"), BlockSeverity::Suspend); assert_eq!(BlockSeverity::parse("silence"), BlockSeverity::Silence); assert_eq!( BlockSeverity::parse("reject_auth"), BlockSeverity::RejectAuth ); } #[test] fn block_severity_falls_back_to_suspend_for_unknown() { assert_eq!(BlockSeverity::parse("nope"), BlockSeverity::Suspend); } #[test] fn normalize_domain_lowercases_and_strips_www() { assert_eq!(normalize_domain("Example.ORG"), "example.org"); assert_eq!(normalize_domain("www.example.org"), "example.org"); assert_eq!( normalize_domain(" www.Mastodon.SOCIAL "), "mastodon.social" ); assert_eq!(normalize_domain("example.org"), "example.org"); } // ─── DB-mocked tests ──────────────────────────────────────────── #[tokio::test] async fn is_domain_allowed_returns_false_when_disabled() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![enabled_setting("false")]]) .into_connection(); let svc = FediverseTrustService::new(db); assert!(!svc.is_domain_allowed("trusted.tld").await.unwrap()); } #[tokio::test] async fn is_domain_allowed_returns_true_when_in_allowlist() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![enabled_setting("true")]]) .append_query_results([vec![mode_setting("allowlist")]]) .append_query_results([vec![trusted_row("partner.tld")]]) .into_connection(); let svc = FediverseTrustService::new(db); assert!(svc.is_domain_allowed("Partner.TLD").await.unwrap()); } #[tokio::test] async fn is_domain_allowed_returns_false_when_not_in_allowlist() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![enabled_setting("true")]]) .append_query_results([vec![mode_setting("allowlist")]]) .append_query_results([Vec::<fediverse_trusted_domain::Model>::new()]) .into_connection(); let svc = FediverseTrustService::new(db); assert!(!svc.is_domain_allowed("stranger.tld").await.unwrap()); } #[tokio::test] async fn is_domain_allowed_returns_true_in_open_mode_when_not_blocked() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![enabled_setting("true")]]) .append_query_results([vec![mode_setting("open_with_blocklist")]]) .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) .into_connection(); let svc = FediverseTrustService::new(db); assert!(svc.is_domain_allowed("anybody.tld").await.unwrap()); } #[tokio::test] async fn is_domain_allowed_returns_false_in_open_mode_when_blocked() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![enabled_setting("true")]]) .append_query_results([vec![mode_setting("open_with_blocklist")]]) .append_query_results([vec![blocked_row("evil.tld")]]) .into_connection(); let svc = FediverseTrustService::new(db); assert!(!svc.is_domain_allowed("evil.tld").await.unwrap()); } #[tokio::test] async fn add_trusted_domain_inserts_record() { let row = trusted_row("partner.tld"); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let svc = FediverseTrustService::new(db); let inserted = svc .add_trusted_domain("Partner.TLD", Some("partnership"), None) .await .unwrap(); assert_eq!(inserted.domain, "partner.tld"); } }
GitRust