1use chrono::{DateTime, Utc};
16use sqlx::Row as _;
17
18use crate::error::{Error, Result};
19use crate::orm::Db;
20
21pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
22 id BIGSERIAL PRIMARY KEY,
23 user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
24 action_type TEXT NOT NULL,
25 model_name TEXT NOT NULL,
26 object_id BIGINT NOT NULL,
27 timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28 ip_address TEXT,
29 summary TEXT NOT NULL DEFAULT ''
30)";
31
32pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
33 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
34 ON rustio_admin_actions(model_name, object_id)";
35
36pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
37 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
38 ON rustio_admin_actions(timestamp DESC)";
39
40pub async fn ensure_table(db: &Db) -> Result<()> {
47 sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
48 sqlx::query(CREATE_MODEL_INDEX_SQL)
49 .execute(db.pool())
50 .await?;
51 sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
52 .execute(db.pool())
53 .await?;
54
55 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS metadata JSONB")
57 .execute(db.pool())
58 .await?;
59 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS correlation_id TEXT")
60 .execute(db.pool())
61 .await?;
62 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS session_id BIGINT")
63 .execute(db.pool())
64 .await?;
65 sqlx::query(
66 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_correlation_idx \
67 ON rustio_admin_actions (correlation_id) WHERE correlation_id IS NOT NULL",
68 )
69 .execute(db.pool())
70 .await?;
71 sqlx::query(
72 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_session_idx \
73 ON rustio_admin_actions (session_id) WHERE session_id IS NOT NULL",
74 )
75 .execute(db.pool())
76 .await?;
77
78 Ok(())
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ActionType {
83 Create,
84 Update,
85 Delete,
86}
87
88impl ActionType {
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Self::Create => "create",
92 Self::Update => "update",
93 Self::Delete => "delete",
94 }
95 }
96
97 pub fn parse(s: &str) -> Option<Self> {
98 match s {
99 "create" => Some(Self::Create),
100 "update" => Some(Self::Update),
101 "delete" => Some(Self::Delete),
102 _ => None,
103 }
104 }
105
106 pub fn label(self) -> &'static str {
107 match self {
108 Self::Create => "Created",
109 Self::Update => "Updated",
110 Self::Delete => "Deleted",
111 }
112 }
113
114 pub fn pill_class(self) -> &'static str {
115 match self {
116 Self::Create => "badge-success",
117 Self::Update => "badge-neutral",
118 Self::Delete => "badge-danger",
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
124pub struct AdminAction {
125 pub id: i64,
126 pub user_id: i64,
127 pub user_email: Option<String>,
128 pub action_type: String,
129 pub model_name: String,
130 pub object_id: i64,
131 pub timestamp: DateTime<Utc>,
132 pub ip_address: Option<String>,
133 pub summary: String,
134}
135
136pub struct LogEntry<'a> {
137 pub user_id: i64,
138 pub action_type: ActionType,
139 pub model_name: &'a str,
140 pub object_id: i64,
141 pub ip_address: Option<&'a str>,
142 pub summary: String,
143 pub correlation_id: Option<&'a str>,
148 pub session_id: Option<i64>,
151 pub metadata: Option<serde_json::Value>,
158 pub actor_user_id: Option<i64>,
178 pub event: Option<AuditEvent>,
186}
187
188impl<'a> LogEntry<'a> {
189 pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
193 Self {
194 user_id,
195 action_type,
196 model_name,
197 object_id,
198 ip_address: None,
199 summary: String::new(),
200 correlation_id: None,
201 session_id: None,
202 metadata: None,
203 actor_user_id: None,
204 event: None,
205 }
206 }
207
208 pub fn with_actor(mut self, actor_user_id: i64) -> Self {
224 self.actor_user_id = Some(actor_user_id);
225 self
226 }
227
228 pub fn with_event(mut self, event: AuditEvent) -> Self {
245 self.event = Some(event);
246 self
247 }
248
249 pub(crate) fn resolved_action_type(&self) -> &'static str {
255 match self.event {
256 Some(e) => e.as_str(),
257 None => self.action_type.as_str(),
258 }
259 }
260}
261
262fn build_persisted_metadata(
280 metadata: Option<serde_json::Value>,
281 actor_user_id: Option<i64>,
282) -> Option<serde_json::Value> {
283 let actor = match actor_user_id {
284 None => return metadata,
285 Some(id) => id,
286 };
287
288 match metadata {
289 None => Some(serde_json::json!({ "actor_user_id": actor })),
290 Some(mut value) => {
291 if let Some(obj) = value.as_object_mut() {
292 obj.insert("actor_user_id".to_string(), serde_json::json!(actor));
293 Some(value)
294 } else {
295 log::warn!(
296 "audit::record: actor_user_id={} set but metadata is not a JSON object \
297 ({:?}); writing row without merging actor — fix the call site",
298 actor,
299 value
300 );
301 Some(value)
302 }
303 }
304 }
305}
306
307pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
310 if entry.user_id <= 0 {
311 return Err(Error::Internal("admin audit: missing user_id".to_string()));
312 }
313 if entry.model_name.trim().is_empty() {
314 return Err(Error::Internal(
315 "admin audit: missing model_name".to_string(),
316 ));
317 }
318 if entry.object_id <= 0 {
319 return Err(Error::Internal(
320 "admin audit: missing object_id".to_string(),
321 ));
322 }
323
324 let now = Utc::now();
325 let action_type_str = entry.resolved_action_type();
326 let metadata = build_persisted_metadata(entry.metadata, entry.actor_user_id);
327 sqlx::query(
328 "INSERT INTO rustio_admin_actions
329 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
330 correlation_id, session_id, metadata)
331 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
332 )
333 .bind(entry.user_id)
334 .bind(action_type_str)
335 .bind(entry.model_name)
336 .bind(entry.object_id)
337 .bind(now)
338 .bind(entry.ip_address)
339 .bind(&entry.summary)
340 .bind(entry.correlation_id)
341 .bind(entry.session_id)
342 .bind(metadata.as_ref())
343 .execute(db.pool())
344 .await?;
345 Ok(())
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
380#[non_exhaustive]
381pub enum AuditEvent {
382 UserCreated,
384 UserUpdated,
385 UserDeleted,
386 GroupCreated,
387 GroupUpdated,
388 GroupDeleted,
389 PasswordChangedSelf,
393 PasswordResetSelfRequest,
396 PasswordResetSelfConsume,
400 PasswordResetByOther,
404 ForcedPasswordChangeCompleted,
413 AccountLocked,
415 AccountUnlocked,
416 MfaEnabled,
418 MfaDisabled,
419 MfaResetByOther,
420 SessionsRevokedSelf,
422 SessionsRevokedByOther,
423 SessionLogout,
424 EmergencyRecovery,
426}
427
428impl AuditEvent {
429 pub const fn as_str(self) -> &'static str {
441 match self {
442 Self::UserCreated => "user_created",
443 Self::UserUpdated => "user_updated",
444 Self::UserDeleted => "user_deleted",
445 Self::GroupCreated => "group_created",
446 Self::GroupUpdated => "group_updated",
447 Self::GroupDeleted => "group_deleted",
448 Self::PasswordChangedSelf => "password_changed_self",
449 Self::PasswordResetSelfRequest => "password_reset_self_request",
450 Self::PasswordResetSelfConsume => "password_reset_self_consume",
451 Self::PasswordResetByOther => "password_reset_by_other",
452 Self::ForcedPasswordChangeCompleted => "forced_password_change_completed",
453 Self::AccountLocked => "account_locked",
454 Self::AccountUnlocked => "account_unlocked",
455 Self::MfaEnabled => "mfa_enabled",
456 Self::MfaDisabled => "mfa_disabled",
457 Self::MfaResetByOther => "mfa_reset_by_other",
458 Self::SessionsRevokedSelf => "sessions_revoked_self",
459 Self::SessionsRevokedByOther => "sessions_revoked_by_other",
460 Self::SessionLogout => "session_logout",
461 Self::EmergencyRecovery => "emergency_recovery",
462 }
463 }
464}
465
466pub async fn recent(
468 db: &Db,
469 limit: i64,
470 model_filter: Option<&str>,
471 action_filter: Option<&str>,
472) -> Result<Vec<AdminAction>> {
473 let mut sql = String::from(
474 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
475 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
476 FROM rustio_admin_actions a
477 LEFT JOIN rustio_users u ON u.id = a.user_id",
478 );
479 let mut clauses: Vec<String> = Vec::new();
480 let mut param_idx: usize = 1;
481 if model_filter.is_some() {
482 clauses.push(format!("a.model_name = ${param_idx}"));
483 param_idx += 1;
484 }
485 if action_filter.is_some() {
486 clauses.push(format!("a.action_type = ${param_idx}"));
487 param_idx += 1;
488 }
489 if !clauses.is_empty() {
490 sql.push_str(" WHERE ");
491 sql.push_str(&clauses.join(" AND "));
492 }
493 sql.push_str(&format!(
494 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
495 ));
496
497 let mut q = sqlx::query(&sql);
498 if let Some(m) = model_filter {
499 q = q.bind(m);
500 }
501 if let Some(a) = action_filter {
502 q = q.bind(a);
503 }
504 q = q.bind(limit);
505
506 let rows = q.fetch_all(db.pool()).await?;
507 rows.iter().map(row_to_action).collect()
508}
509
510pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
512 let rows = sqlx::query(
513 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
514 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
515 FROM rustio_admin_actions a
516 LEFT JOIN rustio_users u ON u.id = a.user_id
517 WHERE a.model_name = $1 AND a.object_id = $2
518 ORDER BY a.timestamp DESC, a.id DESC",
519 )
520 .bind(model_name)
521 .bind(object_id)
522 .fetch_all(db.pool())
523 .await?;
524 rows.iter().map(row_to_action).collect()
525}
526
527fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
528 Ok(AdminAction {
529 id: r.try_get("id")?,
530 user_id: r.try_get("user_id")?,
531 user_email: r.try_get("user_email")?,
532 action_type: r.try_get("action_type")?,
533 model_name: r.try_get("model_name")?,
534 object_id: r.try_get("object_id")?,
535 timestamp: r.try_get("timestamp")?,
536 ip_address: r.try_get("ip_address")?,
537 summary: r.try_get("summary")?,
538 })
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
550 AuditEvent::UserCreated,
551 AuditEvent::UserUpdated,
552 AuditEvent::UserDeleted,
553 AuditEvent::GroupCreated,
554 AuditEvent::GroupUpdated,
555 AuditEvent::GroupDeleted,
556 AuditEvent::PasswordChangedSelf,
557 AuditEvent::PasswordResetSelfRequest,
558 AuditEvent::PasswordResetSelfConsume,
559 AuditEvent::PasswordResetByOther,
560 AuditEvent::ForcedPasswordChangeCompleted,
561 AuditEvent::AccountLocked,
562 AuditEvent::AccountUnlocked,
563 AuditEvent::MfaEnabled,
564 AuditEvent::MfaDisabled,
565 AuditEvent::MfaResetByOther,
566 AuditEvent::SessionsRevokedSelf,
567 AuditEvent::SessionsRevokedByOther,
568 AuditEvent::SessionLogout,
569 AuditEvent::EmergencyRecovery,
570 ];
571
572 #[test]
577 fn audit_event_strings_are_unique() {
578 let mut set = std::collections::HashSet::new();
579 for &e in ALL_AUDIT_EVENTS {
580 assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
581 }
582 assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
583 }
584
585 #[test]
588 fn audit_event_strings_are_snake_case() {
589 for &e in ALL_AUDIT_EVENTS {
590 let s = e.as_str();
591 assert!(!s.is_empty(), "{e:?} as_str is empty");
592 assert!(
593 s.chars()
594 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
595 "{e:?}.as_str() = {s:?} is not snake_case"
596 );
597 }
598 }
599
600 #[test]
604 fn audit_event_password_changed_self_maps_correctly() {
605 assert_eq!(
606 AuditEvent::PasswordChangedSelf.as_str(),
607 "password_changed_self"
608 );
609 }
610
611 #[test]
619 fn audit_event_existing_variants_have_stable_strings() {
620 assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
621 assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
622 assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
623 assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
624 assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
625 assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
626 assert_eq!(
627 AuditEvent::PasswordChangedSelf.as_str(),
628 "password_changed_self"
629 );
630 assert_eq!(
631 AuditEvent::PasswordResetSelfRequest.as_str(),
632 "password_reset_self_request"
633 );
634 assert_eq!(
635 AuditEvent::PasswordResetSelfConsume.as_str(),
636 "password_reset_self_consume"
637 );
638 assert_eq!(
639 AuditEvent::PasswordResetByOther.as_str(),
640 "password_reset_by_other"
641 );
642 assert_eq!(
643 AuditEvent::ForcedPasswordChangeCompleted.as_str(),
644 "forced_password_change_completed"
645 );
646 assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
647 assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
648 assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
649 assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
650 assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
651 assert_eq!(
652 AuditEvent::SessionsRevokedSelf.as_str(),
653 "sessions_revoked_self"
654 );
655 assert_eq!(
656 AuditEvent::SessionsRevokedByOther.as_str(),
657 "sessions_revoked_by_other"
658 );
659 assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
660 assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
661 }
662
663 #[test]
671 fn action_type_and_audit_event_vocabularies_dont_collide() {
672 let action_type_strs = [
673 ActionType::Create.as_str(),
674 ActionType::Update.as_str(),
675 ActionType::Delete.as_str(),
676 ];
677 let mut set = std::collections::HashSet::new();
678 for s in action_type_strs {
679 assert!(set.insert(s), "duplicate ActionType string {s:?}");
680 }
681 for &e in ALL_AUDIT_EVENTS {
682 assert!(
683 set.insert(e.as_str()),
684 "AuditEvent::{:?} ({:?}) collides with ActionType",
685 e,
686 e.as_str()
687 );
688 }
689 assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
690 }
691
692 #[test]
695 fn log_entry_with_event_overrides_action_type_persistence() {
696 let entry = LogEntry::new(1, ActionType::Update, "user", 1);
698 assert_eq!(entry.resolved_action_type(), "update");
699
700 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
702 .with_event(AuditEvent::PasswordChangedSelf);
703 assert_eq!(entry.resolved_action_type(), "password_changed_self");
704
705 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
707 .with_event(AuditEvent::PasswordResetSelfRequest);
708 assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
709
710 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
711 .with_event(AuditEvent::PasswordResetSelfConsume);
712 assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
713 }
714
715 #[test]
716 fn log_entry_default_event_is_none() {
717 let entry = LogEntry::new(1, ActionType::Create, "post", 99);
719 assert!(entry.event.is_none());
720 assert_eq!(entry.resolved_action_type(), "create");
721 }
722
723 #[test]
726 fn log_entry_with_actor_sets_field() {
727 let entry = LogEntry::new(1, ActionType::Update, "user", 1).with_actor(7);
728 assert_eq!(entry.actor_user_id, Some(7));
729 }
730
731 #[test]
732 fn log_entry_default_actor_user_id_is_none() {
733 let entry = LogEntry::new(1, ActionType::Update, "user", 1);
736 assert!(entry.actor_user_id.is_none());
737 }
738
739 #[test]
740 fn merge_returns_metadata_unchanged_when_no_actor() {
741 let original = serde_json::json!({"reason": "x", "actor_user_id": 99});
744 let out = build_persisted_metadata(Some(original.clone()), None);
745 assert_eq!(out.unwrap(), original);
746
747 assert!(build_persisted_metadata(None, None).is_none());
749 }
750
751 #[test]
752 fn merge_synthesizes_object_when_metadata_is_none() {
753 let out = build_persisted_metadata(None, Some(7)).unwrap();
754 assert_eq!(out, serde_json::json!({"actor_user_id": 7}));
755 }
756
757 #[test]
758 fn merge_inserts_into_existing_object() {
759 let input = serde_json::json!({"reason": "support ticket", "mode": "email"});
760 let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
761 assert_eq!(
762 out,
763 serde_json::json!({
764 "reason": "support ticket",
765 "mode": "email",
766 "actor_user_id": 7
767 })
768 );
769 }
770
771 #[test]
772 fn merge_typed_actor_wins_over_existing_metadata_key() {
773 let input = serde_json::json!({"actor_user_id": 999, "extra": "x"});
780 let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
781 assert_eq!(out, serde_json::json!({"actor_user_id": 7, "extra": "x"}));
782 }
783
784 #[test]
785 fn merge_passes_through_non_object_metadata_with_warning() {
786 let input = serde_json::json!(42);
792 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
793 assert_eq!(out, input);
794
795 let input = serde_json::json!(["a", "b"]);
796 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
797 assert_eq!(out, input);
798
799 let input = serde_json::json!("scalar");
800 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
801 assert_eq!(out, input);
802 }
803
804 #[test]
813 fn legacy_action_type_parser_returns_none_on_unknown_strings() {
814 assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
816 assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
817 assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
818
819 for &e in ALL_AUDIT_EVENTS {
823 assert!(
824 ActionType::parse(e.as_str()).is_none(),
825 "ActionType::parse should not recognise AuditEvent string {:?}",
826 e.as_str()
827 );
828 }
829
830 assert!(ActionType::parse("garbage").is_none());
832 assert!(ActionType::parse("").is_none());
833 assert!(ActionType::parse("CREATE").is_none()); }
835}