postgres.rs 267 lignes · 8527 octets
//! Durable [`GuardStore`] backend backed by SeaORM + PostgreSQL.
//!
//! Every method translates to a single SeaORM operation against the
//! three tables created by `m20260514_000001_fediverse_guard_schema`.
//! No internal caching here — wrap with [`crate::store::HybridStore`]
//! to add an in-memory hot path.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rustwarden_core::error::AppError;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
    Set,
};
use uuid::Uuid;

use super::entities::{acl, ban, event};
use super::{AclEntry, AclKind, BanEntry, GuardStore};
use crate::events::{GuardEvent, GuardEventKind, Severity};

/// PostgreSQL-backed durable store.
#[derive(Clone)]
pub struct PostgresStore {
    db: DatabaseConnection,
}

impl PostgresStore {
    /// Build a new store over an established connection.
    #[must_use]
    pub fn new(db: DatabaseConnection) -> Self {
        Self { db }
    }

    /// Borrow the underlying connection (used by admin endpoints that
    /// need to run cross-cutting analytics queries).
    #[must_use]
    pub fn db(&self) -> &DatabaseConnection {
        &self.db
    }
}

#[async_trait]
impl GuardStore for PostgresStore {
    async fn insert_event(&self, evt: &GuardEvent) -> Result<(), AppError> {
        let am = event::ActiveModel {
            id: Set(evt.id),
            event_type: Set(serialize_kind(evt.kind).to_string()),
            domain: Set(evt.domain.clone()),
            actor_uri: Set(evt.actor_uri.clone()),
            remote_identity_id: Set(evt.remote_identity_id),
            severity: Set(serialize_severity(evt.severity).to_string()),
            http_status: Set(evt.http_status),
            details: Set(Some(evt.details.clone())),
            created_at: Set(evt.created_at),
        };
        am.insert(&self.db).await?;
        Ok(())
    }

    async fn count_events_since(
        &self,
        domain: &str,
        kind: GuardEventKind,
        since: DateTime<Utc>,
    ) -> Result<u64, AppError> {
        let count = event::Entity::find()
            .filter(event::Column::Domain.eq(domain))
            .filter(event::Column::EventType.eq(serialize_kind(kind)))
            .filter(event::Column::CreatedAt.gte(since))
            .count(&self.db)
            .await?;
        Ok(count)
    }

    async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError> {
        let am = ban::ActiveModel {
            id: Set(ban.id),
            domain: Set(ban.domain.clone()),
            actor_uri: Set(ban.actor_uri.clone()),
            reason: Set(ban.reason.clone()),
            severity: Set(ban.severity.clone()),
            triggered_by: Set(ban.triggered_by.clone()),
            banned_at: Set(ban.banned_at),
            expires_at: Set(ban.expires_at),
            lifted_at: Set(ban.lifted_at),
            lifted_by: Set(ban.lifted_by),
        };
        am.insert(&self.db).await?;
        Ok(())
    }

    async fn active_bans_for_domain(
        &self,
        domain: &str,
        now: DateTime<Utc>,
    ) -> Result<Vec<BanEntry>, AppError> {
        let rows = ban::Entity::find()
            .filter(ban::Column::Domain.eq(domain))
            .filter(ban::Column::LiftedAt.is_null())
            .all(&self.db)
            .await?;
        Ok(rows
            .into_iter()
            .map(model_to_ban)
            .filter(|b| b.is_active_at(now))
            .collect())
    }

    async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError> {
        let rows = ban::Entity::find()
            .filter(ban::Column::LiftedAt.is_null())
            .all(&self.db)
            .await?;
        Ok(rows
            .into_iter()
            .map(model_to_ban)
            .filter(|b| b.is_active_at(now))
            .collect())
    }

    async fn lift_ban(
        &self,
        ban_id: Uuid,
        by: Option<Uuid>,
        at: DateTime<Utc>,
    ) -> Result<(), AppError> {
        if let Some(row) = ban::Entity::find_by_id(ban_id).one(&self.db).await? {
            let mut am: ban::ActiveModel = row.into();
            am.lifted_at = Set(Some(at));
            am.lifted_by = Set(by);
            am.update(&self.db).await?;
        }
        Ok(())
    }

    async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> {
        match acl::Entity::find_by_id(entry.id).one(&self.db).await? {
            Some(row) => {
                let mut am: acl::ActiveModel = row.into();
                am.kind = Set(serialize_acl_kind(entry.kind).to_string());
                am.domain = Set(entry.domain.clone());
                am.reason = Set(entry.reason.clone());
                am.updated_at = Set(entry.updated_at);
                am.update(&self.db).await?;
            }
            None => {
                let am = acl::ActiveModel {
                    id: Set(entry.id),
                    kind: Set(serialize_acl_kind(entry.kind).to_string()),
                    domain: Set(entry.domain.clone()),
                    reason: Set(entry.reason.clone()),
                    created_by: Set(entry.created_by),
                    created_at: Set(entry.created_at),
                    updated_at: Set(entry.updated_at),
                };
                am.insert(&self.db).await?;
            }
        }
        Ok(())
    }

    async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError> {
        let row = acl::Entity::find()
            .filter(acl::Column::Domain.eq(domain))
            .one(&self.db)
            .await?;
        Ok(row.map(model_to_acl))
    }

    async fn delete_acl(&self, id: Uuid) -> Result<(), AppError> {
        acl::Entity::delete_by_id(id).exec(&self.db).await?;
        Ok(())
    }

    async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError> {
        let rows = acl::Entity::find().all(&self.db).await?;
        Ok(rows.into_iter().map(model_to_acl).collect())
    }
}

// ─── Conversions Model <-> public types ─────────────────────────────

fn model_to_ban(m: ban::Model) -> BanEntry {
    BanEntry {
        id: m.id,
        domain: m.domain,
        actor_uri: m.actor_uri,
        reason: m.reason,
        severity: m.severity,
        triggered_by: m.triggered_by,
        banned_at: m.banned_at,
        expires_at: m.expires_at,
        lifted_at: m.lifted_at,
        lifted_by: m.lifted_by,
    }
}

fn model_to_acl(m: acl::Model) -> AclEntry {
    AclEntry {
        id: m.id,
        kind: match m.kind.as_str() {
            "deny" => AclKind::Deny,
            _ => AclKind::Allow,
        },
        domain: m.domain,
        reason: m.reason,
        created_by: m.created_by,
        created_at: m.created_at,
        updated_at: m.updated_at,
    }
}

fn serialize_kind(kind: GuardEventKind) -> &'static str {
    match kind {
        GuardEventKind::SignatureFailure => "signature_failure",
        GuardEventKind::ReplayAttempt => "replay_attempt",
        GuardEventKind::FetchSsrf => "fetch_ssrf",
        GuardEventKind::InboxAbuse => "inbox_abuse",
        GuardEventKind::AuthSpam => "auth_spam",
        GuardEventKind::Banned => "banned",
        GuardEventKind::Unbanned => "unbanned",
    }
}

fn serialize_severity(sev: Severity) -> &'static str {
    match sev {
        Severity::Info => "info",
        Severity::Low => "low",
        Severity::Medium => "medium",
        Severity::High => "high",
        Severity::Critical => "critical",
    }
}

fn serialize_acl_kind(kind: AclKind) -> &'static str {
    match kind {
        AclKind::Allow => "allow",
        AclKind::Deny => "deny",
    }
}

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

    #[test]
    fn serialize_kind_is_stable_snake_case() {
        assert_eq!(
            serialize_kind(GuardEventKind::SignatureFailure),
            "signature_failure"
        );
        assert_eq!(serialize_kind(GuardEventKind::FetchSsrf), "fetch_ssrf");
        assert_eq!(serialize_kind(GuardEventKind::Unbanned), "unbanned");
    }

    #[test]
    fn serialize_severity_is_stable_lowercase() {
        assert_eq!(serialize_severity(Severity::Critical), "critical");
        assert_eq!(serialize_severity(Severity::Info), "info");
    }

    #[test]
    fn serialize_acl_kind_distinguishes_allow_deny() {
        assert_eq!(serialize_acl_kind(AclKind::Allow), "allow");
        assert_eq!(serialize_acl_kind(AclKind::Deny), "deny");
    }
}