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