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