Skip to main content

pebble_cms/services/
audit.rs

1use crate::db::Database;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum AuditAction {
9    Create,
10    Update,
11    Delete,
12    Publish,
13    Unpublish,
14    Schedule,
15    Restore,
16    Login,
17    LoginFailed,
18    Logout,
19    UserCreate,
20    UserUpdate,
21    UserDelete,
22    RoleChange,
23    PasswordChange,
24    Upload,
25    MediaDelete,
26    TagCreate,
27    TagDelete,
28    SettingsUpdate,
29    Cleanup,
30    Export,
31}
32
33impl AuditAction {
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Create => "create",
37            Self::Update => "update",
38            Self::Delete => "delete",
39            Self::Publish => "publish",
40            Self::Unpublish => "unpublish",
41            Self::Schedule => "schedule",
42            Self::Restore => "restore",
43            Self::Login => "login",
44            Self::LoginFailed => "login_failed",
45            Self::Logout => "logout",
46            Self::UserCreate => "user_create",
47            Self::UserUpdate => "user_update",
48            Self::UserDelete => "user_delete",
49            Self::RoleChange => "role_change",
50            Self::PasswordChange => "password_change",
51            Self::Upload => "upload",
52            Self::MediaDelete => "media_delete",
53            Self::TagCreate => "tag_create",
54            Self::TagDelete => "tag_delete",
55            Self::SettingsUpdate => "settings_update",
56            Self::Cleanup => "cleanup",
57            Self::Export => "export",
58        }
59    }
60
61    pub fn from_str(s: &str) -> Option<Self> {
62        match s {
63            "create" => Some(Self::Create),
64            "update" => Some(Self::Update),
65            "delete" => Some(Self::Delete),
66            "publish" => Some(Self::Publish),
67            "unpublish" => Some(Self::Unpublish),
68            "schedule" => Some(Self::Schedule),
69            "restore" => Some(Self::Restore),
70            "login" => Some(Self::Login),
71            "login_failed" => Some(Self::LoginFailed),
72            "logout" => Some(Self::Logout),
73            "user_create" => Some(Self::UserCreate),
74            "user_update" => Some(Self::UserUpdate),
75            "user_delete" => Some(Self::UserDelete),
76            "role_change" => Some(Self::RoleChange),
77            "password_change" => Some(Self::PasswordChange),
78            "upload" => Some(Self::Upload),
79            "media_delete" => Some(Self::MediaDelete),
80            "tag_create" => Some(Self::TagCreate),
81            "tag_delete" => Some(Self::TagDelete),
82            "settings_update" => Some(Self::SettingsUpdate),
83            "cleanup" => Some(Self::Cleanup),
84            "export" => Some(Self::Export),
85            _ => None,
86        }
87    }
88
89    pub fn display_name(&self) -> &'static str {
90        match self {
91            Self::Create => "Create",
92            Self::Update => "Update",
93            Self::Delete => "Delete",
94            Self::Publish => "Publish",
95            Self::Unpublish => "Unpublish",
96            Self::Schedule => "Schedule",
97            Self::Restore => "Restore",
98            Self::Login => "Login",
99            Self::LoginFailed => "Login Failed",
100            Self::Logout => "Logout",
101            Self::UserCreate => "User Create",
102            Self::UserUpdate => "User Update",
103            Self::UserDelete => "User Delete",
104            Self::RoleChange => "Role Change",
105            Self::PasswordChange => "Password Change",
106            Self::Upload => "Upload",
107            Self::MediaDelete => "Media Delete",
108            Self::TagCreate => "Tag Create",
109            Self::TagDelete => "Tag Delete",
110            Self::SettingsUpdate => "Settings Update",
111            Self::Cleanup => "Cleanup",
112            Self::Export => "Export",
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum AuditCategory {
120    Content,
121    Auth,
122    User,
123    Media,
124    Tag,
125    Settings,
126    System,
127}
128
129impl AuditCategory {
130    pub fn as_str(&self) -> &'static str {
131        match self {
132            Self::Content => "content",
133            Self::Auth => "auth",
134            Self::User => "user",
135            Self::Media => "media",
136            Self::Tag => "tag",
137            Self::Settings => "settings",
138            Self::System => "system",
139        }
140    }
141
142    pub fn from_str(s: &str) -> Option<Self> {
143        match s {
144            "content" => Some(Self::Content),
145            "auth" => Some(Self::Auth),
146            "user" => Some(Self::User),
147            "media" => Some(Self::Media),
148            "tag" => Some(Self::Tag),
149            "settings" => Some(Self::Settings),
150            "system" => Some(Self::System),
151            _ => None,
152        }
153    }
154
155    pub fn display_name(&self) -> &'static str {
156        match self {
157            Self::Content => "Content",
158            Self::Auth => "Authentication",
159            Self::User => "User Management",
160            Self::Media => "Media",
161            Self::Tag => "Tags",
162            Self::Settings => "Settings",
163            Self::System => "System",
164        }
165    }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AuditEntry {
170    pub id: i64,
171    pub timestamp: String,
172    pub user_id: Option<i64>,
173    pub username: Option<String>,
174    pub user_role: Option<String>,
175    pub action: String,
176    pub category: String,
177    pub entity_type: Option<String>,
178    pub entity_id: Option<i64>,
179    pub entity_title: Option<String>,
180    pub ip_address: Option<String>,
181    pub user_agent: Option<String>,
182    pub status: String,
183    pub error_message: Option<String>,
184    pub changes: Option<serde_json::Value>,
185    pub metadata: serde_json::Value,
186}
187
188impl AuditEntry {
189    pub fn action_enum(&self) -> Option<AuditAction> {
190        AuditAction::from_str(&self.action)
191    }
192
193    pub fn category_enum(&self) -> Option<AuditCategory> {
194        AuditCategory::from_str(&self.category)
195    }
196
197    pub fn is_failure(&self) -> bool {
198        self.status == "failure"
199    }
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct AuditContext {
204    pub user_id: Option<i64>,
205    pub username: Option<String>,
206    pub user_role: Option<String>,
207    pub ip_address: Option<String>,
208    pub user_agent: Option<String>,
209}
210
211impl AuditContext {
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    pub fn with_user(mut self, id: i64, username: &str, role: &str) -> Self {
217        self.user_id = Some(id);
218        self.username = Some(username.to_string());
219        self.user_role = Some(role.to_string());
220        self
221    }
222
223    pub fn with_request(mut self, ip: Option<String>, user_agent: Option<String>) -> Self {
224        self.ip_address = ip;
225        self.user_agent = user_agent;
226        self
227    }
228}
229
230#[derive(Debug, Clone)]
231pub struct AuditLogBuilder {
232    action: AuditAction,
233    category: AuditCategory,
234    entity_type: Option<String>,
235    entity_id: Option<i64>,
236    entity_title: Option<String>,
237    changes: Option<serde_json::Value>,
238    metadata: serde_json::Value,
239    status: String,
240    error_message: Option<String>,
241}
242
243impl AuditLogBuilder {
244    pub fn new(action: AuditAction, category: AuditCategory) -> Self {
245        Self {
246            action,
247            category,
248            entity_type: None,
249            entity_id: None,
250            entity_title: None,
251            changes: None,
252            metadata: json!({}),
253            status: "success".to_string(),
254            error_message: None,
255        }
256    }
257
258    pub fn entity(mut self, entity_type: &str, id: i64, title: Option<&str>) -> Self {
259        self.entity_type = Some(entity_type.to_string());
260        self.entity_id = Some(id);
261        self.entity_title = title.map(|s| s.to_string());
262        self
263    }
264
265    pub fn entity_type_only(mut self, entity_type: &str) -> Self {
266        self.entity_type = Some(entity_type.to_string());
267        self
268    }
269
270    pub fn changes(mut self, changes: serde_json::Value) -> Self {
271        self.changes = Some(changes);
272        self
273    }
274
275    pub fn metadata_value(mut self, key: &str, value: serde_json::Value) -> Self {
276        if let serde_json::Value::Object(ref mut map) = self.metadata {
277            map.insert(key.to_string(), value);
278        }
279        self
280    }
281
282    pub fn failure(mut self, error: &str) -> Self {
283        self.status = "failure".to_string();
284        self.error_message = Some(error.to_string());
285        self
286    }
287}
288
289#[derive(Debug, Clone, Serialize)]
290pub struct AuditSummary {
291    pub total_events: i64,
292    pub events_today: i64,
293    pub failed_events: i64,
294    pub active_users_today: i64,
295    pub recent_failures: Vec<AuditEntry>,
296    pub actions_breakdown: Vec<ActionCount>,
297}
298
299#[derive(Debug, Clone, Serialize)]
300pub struct ActionCount {
301    pub action: String,
302    pub count: i64,
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct AuditFilter {
307    pub user_id: Option<i64>,
308    pub username: Option<String>,
309    pub action: Option<String>,
310    pub category: Option<String>,
311    pub entity_type: Option<String>,
312    pub status: Option<String>,
313    pub search: Option<String>,
314    pub from_date: Option<String>,
315    pub to_date: Option<String>,
316}
317
318pub fn log(db: &Database, ctx: &AuditContext, builder: AuditLogBuilder) -> Result<i64> {
319    let conn = db.get()?;
320
321    let changes_json = builder.changes.map(|c| c.to_string());
322    let metadata_json = builder.metadata.to_string();
323
324    conn.execute(
325        r#"
326        INSERT INTO audit_logs (
327            user_id, username, user_role, action, category,
328            entity_type, entity_id, entity_title,
329            ip_address, user_agent, status, error_message,
330            changes, metadata
331        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
332        "#,
333        rusqlite::params![
334            ctx.user_id,
335            ctx.username,
336            ctx.user_role,
337            builder.action.as_str(),
338            builder.category.as_str(),
339            builder.entity_type,
340            builder.entity_id,
341            builder.entity_title,
342            ctx.ip_address,
343            ctx.user_agent,
344            builder.status,
345            builder.error_message,
346            changes_json,
347            metadata_json,
348        ],
349    )?;
350
351    Ok(conn.last_insert_rowid())
352}
353
354pub fn list_logs(
355    db: &Database,
356    filter: &AuditFilter,
357    limit: usize,
358    offset: usize,
359) -> Result<Vec<AuditEntry>> {
360    let conn = db.get()?;
361
362    let (where_clause, params) = build_filter_clause(filter);
363
364    let sql = format!(
365        r#"
366        SELECT id, timestamp, user_id, username, user_role, action, category,
367               entity_type, entity_id, entity_title, ip_address, user_agent,
368               status, error_message, changes, metadata
369        FROM audit_logs
370        {}
371        ORDER BY timestamp DESC
372        LIMIT ?{} OFFSET ?{}
373        "#,
374        where_clause,
375        params.len() + 1,
376        params.len() + 2
377    );
378
379    let mut stmt = conn.prepare(&sql)?;
380
381    let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params
382        .into_iter()
383        .map(|s| Box::new(s) as Box<dyn rusqlite::ToSql>)
384        .collect();
385    all_params.push(Box::new(limit as i64));
386    all_params.push(Box::new(offset as i64));
387
388    let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
389
390    let entries = stmt
391        .query_map(param_refs.as_slice(), |row| {
392            Ok(AuditEntry {
393                id: row.get(0)?,
394                timestamp: row.get(1)?,
395                user_id: row.get(2)?,
396                username: row.get(3)?,
397                user_role: row.get(4)?,
398                action: row.get(5)?,
399                category: row.get(6)?,
400                entity_type: row.get(7)?,
401                entity_id: row.get(8)?,
402                entity_title: row.get(9)?,
403                ip_address: row.get(10)?,
404                user_agent: row.get(11)?,
405                status: row.get(12)?,
406                error_message: row.get(13)?,
407                changes: row
408                    .get::<_, Option<String>>(14)?
409                    .and_then(|s| serde_json::from_str(&s).ok()),
410                metadata: row
411                    .get::<_, String>(15)
412                    .ok()
413                    .and_then(|s| serde_json::from_str(&s).ok())
414                    .unwrap_or(json!({})),
415            })
416        })?
417        .collect::<Result<Vec<_>, _>>()?;
418
419    Ok(entries)
420}
421
422pub fn count_logs(db: &Database, filter: &AuditFilter) -> Result<i64> {
423    let conn = db.get()?;
424
425    let (where_clause, params) = build_filter_clause(filter);
426
427    let sql = format!("SELECT COUNT(*) FROM audit_logs {}", where_clause);
428
429    let mut stmt = conn.prepare(&sql)?;
430    let param_refs: Vec<&dyn rusqlite::ToSql> =
431        params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
432
433    let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
434
435    Ok(count)
436}
437
438pub fn get_log(db: &Database, id: i64) -> Result<Option<AuditEntry>> {
439    let conn = db.get()?;
440
441    let entry = conn.query_row(
442        r#"
443        SELECT id, timestamp, user_id, username, user_role, action, category,
444               entity_type, entity_id, entity_title, ip_address, user_agent,
445               status, error_message, changes, metadata
446        FROM audit_logs
447        WHERE id = ?1
448        "#,
449        [id],
450        |row| {
451            Ok(AuditEntry {
452                id: row.get(0)?,
453                timestamp: row.get(1)?,
454                user_id: row.get(2)?,
455                username: row.get(3)?,
456                user_role: row.get(4)?,
457                action: row.get(5)?,
458                category: row.get(6)?,
459                entity_type: row.get(7)?,
460                entity_id: row.get(8)?,
461                entity_title: row.get(9)?,
462                ip_address: row.get(10)?,
463                user_agent: row.get(11)?,
464                status: row.get(12)?,
465                error_message: row.get(13)?,
466                changes: row
467                    .get::<_, Option<String>>(14)?
468                    .and_then(|s| serde_json::from_str(&s).ok()),
469                metadata: row
470                    .get::<_, String>(15)
471                    .ok()
472                    .and_then(|s| serde_json::from_str(&s).ok())
473                    .unwrap_or(json!({})),
474            })
475        },
476    );
477
478    match entry {
479        Ok(e) => Ok(Some(e)),
480        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
481        Err(e) => Err(e.into()),
482    }
483}
484
485pub fn get_summary(db: &Database, days: u32) -> Result<AuditSummary> {
486    let conn = db.get()?;
487
488    let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
489    let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%S").to_string();
490
491    let today_start = chrono::Utc::now().format("%Y-%m-%d").to_string();
492
493    let total_events: i64 =
494        conn.query_row("SELECT COUNT(*) FROM audit_logs", [], |row| row.get(0))?;
495
496    let events_today: i64 = conn.query_row(
497        "SELECT COUNT(*) FROM audit_logs WHERE timestamp >= ?1",
498        [&today_start],
499        |row| row.get(0),
500    )?;
501
502    let failed_events: i64 = conn.query_row(
503        "SELECT COUNT(*) FROM audit_logs WHERE status = 'failure' AND timestamp >= ?1",
504        [&cutoff_str],
505        |row| row.get(0),
506    )?;
507
508    let active_users_today: i64 = conn.query_row(
509        "SELECT COUNT(DISTINCT user_id) FROM audit_logs WHERE user_id IS NOT NULL AND timestamp >= ?1",
510        [&today_start],
511        |row| row.get(0),
512    )?;
513
514    let mut stmt = conn.prepare(
515        r#"
516        SELECT id, timestamp, user_id, username, user_role, action, category,
517               entity_type, entity_id, entity_title, ip_address, user_agent,
518               status, error_message, changes, metadata
519        FROM audit_logs
520        WHERE status = 'failure'
521        ORDER BY timestamp DESC
522        LIMIT 5
523        "#,
524    )?;
525
526    let recent_failures = stmt
527        .query_map([], |row| {
528            Ok(AuditEntry {
529                id: row.get(0)?,
530                timestamp: row.get(1)?,
531                user_id: row.get(2)?,
532                username: row.get(3)?,
533                user_role: row.get(4)?,
534                action: row.get(5)?,
535                category: row.get(6)?,
536                entity_type: row.get(7)?,
537                entity_id: row.get(8)?,
538                entity_title: row.get(9)?,
539                ip_address: row.get(10)?,
540                user_agent: row.get(11)?,
541                status: row.get(12)?,
542                error_message: row.get(13)?,
543                changes: row
544                    .get::<_, Option<String>>(14)?
545                    .and_then(|s| serde_json::from_str(&s).ok()),
546                metadata: row
547                    .get::<_, String>(15)
548                    .ok()
549                    .and_then(|s| serde_json::from_str(&s).ok())
550                    .unwrap_or(json!({})),
551            })
552        })?
553        .collect::<Result<Vec<_>, _>>()?;
554
555    let mut stmt = conn.prepare(
556        r#"
557        SELECT action, COUNT(*) as count
558        FROM audit_logs
559        WHERE timestamp >= ?1
560        GROUP BY action
561        ORDER BY count DESC
562        LIMIT 10
563        "#,
564    )?;
565
566    let actions_breakdown = stmt
567        .query_map([&cutoff_str], |row| {
568            Ok(ActionCount {
569                action: row.get(0)?,
570                count: row.get(1)?,
571            })
572        })?
573        .collect::<Result<Vec<_>, _>>()?;
574
575    Ok(AuditSummary {
576        total_events,
577        events_today,
578        failed_events,
579        active_users_today,
580        recent_failures,
581        actions_breakdown,
582    })
583}
584
585pub fn export_logs(db: &Database, filter: &AuditFilter, format: &str) -> Result<String> {
586    let logs = list_logs(db, filter, 10000, 0)?;
587
588    match format {
589        "csv" => {
590            let mut csv = String::from("timestamp,user,action,category,entity_type,entity_id,entity_title,status,ip_address\n");
591            for log in logs {
592                csv.push_str(&format!(
593                    "{},{},{},{},{},{},{},{},{}\n",
594                    log.timestamp,
595                    log.username.unwrap_or_default(),
596                    log.action,
597                    log.category,
598                    log.entity_type.unwrap_or_default(),
599                    log.entity_id.map(|i| i.to_string()).unwrap_or_default(),
600                    log.entity_title.unwrap_or_default().replace(',', ";"),
601                    log.status,
602                    log.ip_address.unwrap_or_default(),
603                ));
604            }
605            Ok(csv)
606        }
607        _ => Ok(serde_json::to_string_pretty(&logs)?),
608    }
609}
610
611pub fn cleanup_old_logs(db: &Database, retention_days: u32) -> Result<usize> {
612    if retention_days == 0 {
613        return Ok(0);
614    }
615
616    let conn = db.get()?;
617
618    let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
619    let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%S").to_string();
620
621    let deleted = conn.execute("DELETE FROM audit_logs WHERE timestamp < ?1", [&cutoff_str])?;
622
623    if deleted > 0 {
624        tracing::info!("Cleaned up {} old audit log entries", deleted);
625    }
626
627    Ok(deleted)
628}
629
630pub fn get_audit_users(db: &Database) -> Result<Vec<(i64, String)>> {
631    let conn = db.get()?;
632
633    let mut stmt = conn.prepare(
634        r#"
635        SELECT DISTINCT user_id, username
636        FROM audit_logs
637        WHERE user_id IS NOT NULL AND username IS NOT NULL
638        ORDER BY username
639        "#,
640    )?;
641
642    let users = stmt
643        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
644        .collect::<Result<Vec<_>, _>>()?;
645
646    Ok(users)
647}
648
649fn build_filter_clause(filter: &AuditFilter) -> (String, Vec<String>) {
650    let mut conditions = Vec::new();
651    let mut params = Vec::new();
652
653    if let Some(ref user_id) = filter.user_id {
654        params.push(user_id.to_string());
655        conditions.push(format!("user_id = ?{}", params.len()));
656    }
657
658    if let Some(ref username) = filter.username {
659        params.push(username.clone());
660        conditions.push(format!("username = ?{}", params.len()));
661    }
662
663    if let Some(ref action) = filter.action {
664        params.push(action.clone());
665        conditions.push(format!("action = ?{}", params.len()));
666    }
667
668    if let Some(ref category) = filter.category {
669        params.push(category.clone());
670        conditions.push(format!("category = ?{}", params.len()));
671    }
672
673    if let Some(ref entity_type) = filter.entity_type {
674        params.push(entity_type.clone());
675        conditions.push(format!("entity_type = ?{}", params.len()));
676    }
677
678    if let Some(ref status) = filter.status {
679        params.push(status.clone());
680        conditions.push(format!("status = ?{}", params.len()));
681    }
682
683    if let Some(ref search) = filter.search {
684        let search_pattern = format!("%{}%", search);
685        params.push(search_pattern.clone());
686        params.push(search_pattern.clone());
687        params.push(search_pattern);
688        conditions.push(format!(
689            "(username LIKE ?{} OR entity_title LIKE ?{} OR action LIKE ?{})",
690            params.len() - 2,
691            params.len() - 1,
692            params.len()
693        ));
694    }
695
696    if let Some(ref from_date) = filter.from_date {
697        params.push(from_date.clone());
698        conditions.push(format!("timestamp >= ?{}", params.len()));
699    }
700
701    if let Some(ref to_date) = filter.to_date {
702        params.push(format!("{}T23:59:59", to_date));
703        conditions.push(format!("timestamp <= ?{}", params.len()));
704    }
705
706    let where_clause = if conditions.is_empty() {
707        String::new()
708    } else {
709        format!("WHERE {}", conditions.join(" AND "))
710    };
711
712    (where_clause, params)
713}
714
715pub fn get_all_actions() -> Vec<(&'static str, &'static str)> {
716    vec![
717        ("create", "Create"),
718        ("update", "Update"),
719        ("delete", "Delete"),
720        ("publish", "Publish"),
721        ("unpublish", "Unpublish"),
722        ("schedule", "Schedule"),
723        ("restore", "Restore"),
724        ("login", "Login"),
725        ("login_failed", "Login Failed"),
726        ("logout", "Logout"),
727        ("user_create", "User Create"),
728        ("user_update", "User Update"),
729        ("user_delete", "User Delete"),
730        ("role_change", "Role Change"),
731        ("password_change", "Password Change"),
732        ("upload", "Upload"),
733        ("media_delete", "Media Delete"),
734        ("tag_create", "Tag Create"),
735        ("tag_delete", "Tag Delete"),
736        ("settings_update", "Settings Update"),
737        ("cleanup", "Cleanup"),
738        ("export", "Export"),
739    ]
740}
741
742pub fn get_all_categories() -> Vec<(&'static str, &'static str)> {
743    vec![
744        ("content", "Content"),
745        ("auth", "Authentication"),
746        ("user", "User Management"),
747        ("media", "Media"),
748        ("tag", "Tags"),
749        ("settings", "Settings"),
750        ("system", "System"),
751    ]
752}