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>,
153 pub event: Option<AuditEvent>,
161}
162
163impl<'a> LogEntry<'a> {
164 pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
168 Self {
169 user_id,
170 action_type,
171 model_name,
172 object_id,
173 ip_address: None,
174 summary: String::new(),
175 correlation_id: None,
176 session_id: None,
177 metadata: None,
178 event: None,
179 }
180 }
181
182 pub fn with_event(mut self, event: AuditEvent) -> Self {
199 self.event = Some(event);
200 self
201 }
202
203 pub(crate) fn resolved_action_type(&self) -> &'static str {
209 match self.event {
210 Some(e) => e.as_str(),
211 None => self.action_type.as_str(),
212 }
213 }
214}
215
216pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
219 if entry.user_id <= 0 {
220 return Err(Error::Internal("admin audit: missing user_id".to_string()));
221 }
222 if entry.model_name.trim().is_empty() {
223 return Err(Error::Internal(
224 "admin audit: missing model_name".to_string(),
225 ));
226 }
227 if entry.object_id <= 0 {
228 return Err(Error::Internal(
229 "admin audit: missing object_id".to_string(),
230 ));
231 }
232
233 let now = Utc::now();
234 let action_type_str = entry.resolved_action_type();
235 sqlx::query(
236 "INSERT INTO rustio_admin_actions
237 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
238 correlation_id, session_id, metadata)
239 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
240 )
241 .bind(entry.user_id)
242 .bind(action_type_str)
243 .bind(entry.model_name)
244 .bind(entry.object_id)
245 .bind(now)
246 .bind(entry.ip_address)
247 .bind(&entry.summary)
248 .bind(entry.correlation_id)
249 .bind(entry.session_id)
250 .bind(entry.metadata.as_ref())
251 .execute(db.pool())
252 .await?;
253 Ok(())
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
288#[non_exhaustive]
289pub enum AuditEvent {
290 UserCreated,
292 UserUpdated,
293 UserDeleted,
294 GroupCreated,
295 GroupUpdated,
296 GroupDeleted,
297 PasswordChangedSelf,
301 PasswordResetSelfRequest,
304 PasswordResetSelfConsume,
308 PasswordResetByOther,
312 AccountLocked,
314 AccountUnlocked,
315 MfaEnabled,
317 MfaDisabled,
318 MfaResetByOther,
319 SessionsRevokedSelf,
321 SessionsRevokedByOther,
322 SessionLogout,
323 EmergencyRecovery,
325}
326
327impl AuditEvent {
328 pub const fn as_str(self) -> &'static str {
340 match self {
341 Self::UserCreated => "user_created",
342 Self::UserUpdated => "user_updated",
343 Self::UserDeleted => "user_deleted",
344 Self::GroupCreated => "group_created",
345 Self::GroupUpdated => "group_updated",
346 Self::GroupDeleted => "group_deleted",
347 Self::PasswordChangedSelf => "password_changed_self",
348 Self::PasswordResetSelfRequest => "password_reset_self_request",
349 Self::PasswordResetSelfConsume => "password_reset_self_consume",
350 Self::PasswordResetByOther => "password_reset_by_other",
351 Self::AccountLocked => "account_locked",
352 Self::AccountUnlocked => "account_unlocked",
353 Self::MfaEnabled => "mfa_enabled",
354 Self::MfaDisabled => "mfa_disabled",
355 Self::MfaResetByOther => "mfa_reset_by_other",
356 Self::SessionsRevokedSelf => "sessions_revoked_self",
357 Self::SessionsRevokedByOther => "sessions_revoked_by_other",
358 Self::SessionLogout => "session_logout",
359 Self::EmergencyRecovery => "emergency_recovery",
360 }
361 }
362}
363
364pub async fn recent(
366 db: &Db,
367 limit: i64,
368 model_filter: Option<&str>,
369 action_filter: Option<&str>,
370) -> Result<Vec<AdminAction>> {
371 let mut sql = String::from(
372 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
373 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
374 FROM rustio_admin_actions a
375 LEFT JOIN rustio_users u ON u.id = a.user_id",
376 );
377 let mut clauses: Vec<String> = Vec::new();
378 let mut param_idx: usize = 1;
379 if model_filter.is_some() {
380 clauses.push(format!("a.model_name = ${param_idx}"));
381 param_idx += 1;
382 }
383 if action_filter.is_some() {
384 clauses.push(format!("a.action_type = ${param_idx}"));
385 param_idx += 1;
386 }
387 if !clauses.is_empty() {
388 sql.push_str(" WHERE ");
389 sql.push_str(&clauses.join(" AND "));
390 }
391 sql.push_str(&format!(
392 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
393 ));
394
395 let mut q = sqlx::query(&sql);
396 if let Some(m) = model_filter {
397 q = q.bind(m);
398 }
399 if let Some(a) = action_filter {
400 q = q.bind(a);
401 }
402 q = q.bind(limit);
403
404 let rows = q.fetch_all(db.pool()).await?;
405 rows.iter().map(row_to_action).collect()
406}
407
408pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
410 let rows = sqlx::query(
411 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
412 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
413 FROM rustio_admin_actions a
414 LEFT JOIN rustio_users u ON u.id = a.user_id
415 WHERE a.model_name = $1 AND a.object_id = $2
416 ORDER BY a.timestamp DESC, a.id DESC",
417 )
418 .bind(model_name)
419 .bind(object_id)
420 .fetch_all(db.pool())
421 .await?;
422 rows.iter().map(row_to_action).collect()
423}
424
425fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
426 Ok(AdminAction {
427 id: r.try_get("id")?,
428 user_id: r.try_get("user_id")?,
429 user_email: r.try_get("user_email")?,
430 action_type: r.try_get("action_type")?,
431 model_name: r.try_get("model_name")?,
432 object_id: r.try_get("object_id")?,
433 timestamp: r.try_get("timestamp")?,
434 ip_address: r.try_get("ip_address")?,
435 summary: r.try_get("summary")?,
436 })
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
448 AuditEvent::UserCreated,
449 AuditEvent::UserUpdated,
450 AuditEvent::UserDeleted,
451 AuditEvent::GroupCreated,
452 AuditEvent::GroupUpdated,
453 AuditEvent::GroupDeleted,
454 AuditEvent::PasswordChangedSelf,
455 AuditEvent::PasswordResetSelfRequest,
456 AuditEvent::PasswordResetSelfConsume,
457 AuditEvent::PasswordResetByOther,
458 AuditEvent::AccountLocked,
459 AuditEvent::AccountUnlocked,
460 AuditEvent::MfaEnabled,
461 AuditEvent::MfaDisabled,
462 AuditEvent::MfaResetByOther,
463 AuditEvent::SessionsRevokedSelf,
464 AuditEvent::SessionsRevokedByOther,
465 AuditEvent::SessionLogout,
466 AuditEvent::EmergencyRecovery,
467 ];
468
469 #[test]
474 fn audit_event_strings_are_unique() {
475 let mut set = std::collections::HashSet::new();
476 for &e in ALL_AUDIT_EVENTS {
477 assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
478 }
479 assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
480 }
481
482 #[test]
485 fn audit_event_strings_are_snake_case() {
486 for &e in ALL_AUDIT_EVENTS {
487 let s = e.as_str();
488 assert!(!s.is_empty(), "{e:?} as_str is empty");
489 assert!(
490 s.chars()
491 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
492 "{e:?}.as_str() = {s:?} is not snake_case"
493 );
494 }
495 }
496
497 #[test]
501 fn audit_event_password_changed_self_maps_correctly() {
502 assert_eq!(
503 AuditEvent::PasswordChangedSelf.as_str(),
504 "password_changed_self"
505 );
506 }
507
508 #[test]
516 fn audit_event_existing_variants_have_stable_strings() {
517 assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
518 assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
519 assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
520 assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
521 assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
522 assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
523 assert_eq!(
524 AuditEvent::PasswordChangedSelf.as_str(),
525 "password_changed_self"
526 );
527 assert_eq!(
528 AuditEvent::PasswordResetSelfRequest.as_str(),
529 "password_reset_self_request"
530 );
531 assert_eq!(
532 AuditEvent::PasswordResetSelfConsume.as_str(),
533 "password_reset_self_consume"
534 );
535 assert_eq!(
536 AuditEvent::PasswordResetByOther.as_str(),
537 "password_reset_by_other"
538 );
539 assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
540 assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
541 assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
542 assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
543 assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
544 assert_eq!(
545 AuditEvent::SessionsRevokedSelf.as_str(),
546 "sessions_revoked_self"
547 );
548 assert_eq!(
549 AuditEvent::SessionsRevokedByOther.as_str(),
550 "sessions_revoked_by_other"
551 );
552 assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
553 assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
554 }
555
556 #[test]
564 fn action_type_and_audit_event_vocabularies_dont_collide() {
565 let action_type_strs = [
566 ActionType::Create.as_str(),
567 ActionType::Update.as_str(),
568 ActionType::Delete.as_str(),
569 ];
570 let mut set = std::collections::HashSet::new();
571 for s in action_type_strs {
572 assert!(set.insert(s), "duplicate ActionType string {s:?}");
573 }
574 for &e in ALL_AUDIT_EVENTS {
575 assert!(
576 set.insert(e.as_str()),
577 "AuditEvent::{:?} ({:?}) collides with ActionType",
578 e,
579 e.as_str()
580 );
581 }
582 assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
583 }
584
585 #[test]
588 fn log_entry_with_event_overrides_action_type_persistence() {
589 let entry = LogEntry::new(1, ActionType::Update, "user", 1);
591 assert_eq!(entry.resolved_action_type(), "update");
592
593 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
595 .with_event(AuditEvent::PasswordChangedSelf);
596 assert_eq!(entry.resolved_action_type(), "password_changed_self");
597
598 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
600 .with_event(AuditEvent::PasswordResetSelfRequest);
601 assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
602
603 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
604 .with_event(AuditEvent::PasswordResetSelfConsume);
605 assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
606 }
607
608 #[test]
609 fn log_entry_default_event_is_none() {
610 let entry = LogEntry::new(1, ActionType::Create, "post", 99);
612 assert!(entry.event.is_none());
613 assert_eq!(entry.resolved_action_type(), "create");
614 }
615
616 #[test]
625 fn legacy_action_type_parser_returns_none_on_unknown_strings() {
626 assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
628 assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
629 assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
630
631 for &e in ALL_AUDIT_EVENTS {
635 assert!(
636 ActionType::parse(e.as_str()).is_none(),
637 "ActionType::parse should not recognise AuditEvent string {:?}",
638 e.as_str()
639 );
640 }
641
642 assert!(ActionType::parse("garbage").is_none());
644 assert!(ActionType::parse("").is_none());
645 assert!(ActionType::parse("CREATE").is_none()); }
647}