use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "suspicious_activity_type", rename_all = "snake_case")]
pub enum SuspiciousActivityType {
MultipleFailedLogins,
LoginFromNewLocation,
RapidApiTokenCreation,
UnusualApiUsage,
RapidSettingsChanges,
UnusualBillingActivity,
MultipleIpAddresses,
AccountTakeoverAttempt,
BruteForceAttempt,
UnusualDeploymentPattern,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct SuspiciousActivity {
pub id: Uuid,
pub org_id: Option<Uuid>, pub user_id: Option<Uuid>,
pub activity_type: SuspiciousActivityType,
pub severity: String, pub description: String,
pub metadata: Option<serde_json::Value>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub resolved: bool,
pub resolved_at: Option<DateTime<Utc>>,
pub resolved_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[cfg(feature = "postgres")]
impl SuspiciousActivity {
#[allow(clippy::too_many_arguments)]
pub async fn create(
pool: &sqlx::PgPool,
org_id: Option<Uuid>,
user_id: Option<Uuid>,
activity_type: SuspiciousActivityType,
severity: &str,
description: String,
metadata: Option<serde_json::Value>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> sqlx::Result<Self> {
sqlx::query_as::<_, Self>(
r#"
INSERT INTO suspicious_activities (org_id, user_id, activity_type, severity, description, metadata, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
"#,
)
.bind(org_id)
.bind(user_id)
.bind(activity_type)
.bind(severity)
.bind(description)
.bind(metadata)
.bind(ip_address)
.bind(user_agent)
.fetch_one(pool)
.await
}
pub async fn get_unresolved(
pool: &sqlx::PgPool,
org_id: Option<Uuid>,
user_id: Option<Uuid>,
severity: Option<&str>,
limit: Option<i64>,
) -> sqlx::Result<Vec<Self>> {
let mut query =
sqlx::QueryBuilder::new("SELECT * FROM suspicious_activities WHERE resolved = FALSE");
if let Some(org_id) = org_id {
query.push(" AND org_id = ");
query.push_bind(org_id);
}
if let Some(user_id) = user_id {
query.push(" AND user_id = ");
query.push_bind(user_id);
}
if let Some(severity) = severity {
query.push(" AND severity = ");
query.push_bind(severity);
}
query.push(" ORDER BY created_at DESC");
if let Some(limit) = limit {
query.push(" LIMIT ");
query.push_bind(limit);
}
query.build_query_as::<Self>().fetch_all(pool).await
}
pub async fn resolve(
pool: &sqlx::PgPool,
org_id: Uuid,
activity_id: Uuid,
resolved_by: Uuid,
) -> sqlx::Result<u64> {
let result = sqlx::query(
"UPDATE suspicious_activities SET resolved = TRUE, resolved_at = NOW(), resolved_by = $1 WHERE id = $2 AND org_id = $3"
)
.bind(resolved_by)
.bind(activity_id)
.bind(org_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn cleanup_old(pool: &sqlx::PgPool, days: i64) -> sqlx::Result<u64> {
let cutoff = Utc::now() - chrono::Duration::days(days);
let result = sqlx::query(
"DELETE FROM suspicious_activities WHERE resolved = TRUE AND resolved_at < $1",
)
.bind(cutoff)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}
#[cfg(feature = "postgres")]
#[allow(clippy::too_many_arguments)]
pub async fn record_suspicious_activity(
pool: &sqlx::PgPool,
org_id: Option<Uuid>,
user_id: Option<Uuid>,
activity_type: SuspiciousActivityType,
severity: &str,
description: String,
metadata: Option<serde_json::Value>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) {
if let Err(e) = SuspiciousActivity::create(
pool,
org_id,
user_id,
activity_type,
severity,
description,
metadata,
ip_address,
user_agent,
)
.await
{
tracing::warn!("Failed to record suspicious activity: {}", e);
}
}