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}
154
155impl<'a> LogEntry<'a> {
156 pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
160 Self {
161 user_id,
162 action_type,
163 model_name,
164 object_id,
165 ip_address: None,
166 summary: String::new(),
167 correlation_id: None,
168 session_id: None,
169 metadata: None,
170 }
171 }
172}
173
174pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
177 if entry.user_id <= 0 {
178 return Err(Error::Internal("admin audit: missing user_id".to_string()));
179 }
180 if entry.model_name.trim().is_empty() {
181 return Err(Error::Internal(
182 "admin audit: missing model_name".to_string(),
183 ));
184 }
185 if entry.object_id <= 0 {
186 return Err(Error::Internal(
187 "admin audit: missing object_id".to_string(),
188 ));
189 }
190
191 let now = Utc::now();
192 sqlx::query(
193 "INSERT INTO rustio_admin_actions
194 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
195 correlation_id, session_id, metadata)
196 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
197 )
198 .bind(entry.user_id)
199 .bind(entry.action_type.as_str())
200 .bind(entry.model_name)
201 .bind(entry.object_id)
202 .bind(now)
203 .bind(entry.ip_address)
204 .bind(&entry.summary)
205 .bind(entry.correlation_id)
206 .bind(entry.session_id)
207 .bind(entry.metadata.as_ref())
208 .execute(db.pool())
209 .await?;
210 Ok(())
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[allow(dead_code)] pub(crate) enum AuditEvent {
224 UserCreated,
225 UserUpdated,
226 UserDeleted,
227 GroupCreated,
228 GroupUpdated,
229 GroupDeleted,
230 PasswordResetSelfRequest,
231 PasswordResetSelfConsume,
232 PasswordResetByOther,
233 AccountLocked,
234 AccountUnlocked,
235 MfaEnabled,
236 MfaDisabled,
237 MfaResetByOther,
238 SessionsRevokedSelf,
239 SessionsRevokedByOther,
240 SessionLogout,
241 EmergencyRecovery,
242}
243
244impl AuditEvent {
245 #[allow(dead_code)]
251 pub(crate) const fn as_str(self) -> &'static str {
252 match self {
253 Self::UserCreated => "user_created",
254 Self::UserUpdated => "user_updated",
255 Self::UserDeleted => "user_deleted",
256 Self::GroupCreated => "group_created",
257 Self::GroupUpdated => "group_updated",
258 Self::GroupDeleted => "group_deleted",
259 Self::PasswordResetSelfRequest => "password_reset_self_request",
260 Self::PasswordResetSelfConsume => "password_reset_self_consume",
261 Self::PasswordResetByOther => "password_reset_by_other",
262 Self::AccountLocked => "account_locked",
263 Self::AccountUnlocked => "account_unlocked",
264 Self::MfaEnabled => "mfa_enabled",
265 Self::MfaDisabled => "mfa_disabled",
266 Self::MfaResetByOther => "mfa_reset_by_other",
267 Self::SessionsRevokedSelf => "sessions_revoked_self",
268 Self::SessionsRevokedByOther => "sessions_revoked_by_other",
269 Self::SessionLogout => "session_logout",
270 Self::EmergencyRecovery => "emergency_recovery",
271 }
272 }
273}
274
275pub async fn recent(
277 db: &Db,
278 limit: i64,
279 model_filter: Option<&str>,
280 action_filter: Option<&str>,
281) -> Result<Vec<AdminAction>> {
282 let mut sql = String::from(
283 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
284 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
285 FROM rustio_admin_actions a
286 LEFT JOIN rustio_users u ON u.id = a.user_id",
287 );
288 let mut clauses: Vec<String> = Vec::new();
289 let mut param_idx: usize = 1;
290 if model_filter.is_some() {
291 clauses.push(format!("a.model_name = ${param_idx}"));
292 param_idx += 1;
293 }
294 if action_filter.is_some() {
295 clauses.push(format!("a.action_type = ${param_idx}"));
296 param_idx += 1;
297 }
298 if !clauses.is_empty() {
299 sql.push_str(" WHERE ");
300 sql.push_str(&clauses.join(" AND "));
301 }
302 sql.push_str(&format!(
303 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
304 ));
305
306 let mut q = sqlx::query(&sql);
307 if let Some(m) = model_filter {
308 q = q.bind(m);
309 }
310 if let Some(a) = action_filter {
311 q = q.bind(a);
312 }
313 q = q.bind(limit);
314
315 let rows = q.fetch_all(db.pool()).await?;
316 rows.iter().map(row_to_action).collect()
317}
318
319pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
321 let rows = sqlx::query(
322 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
323 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
324 FROM rustio_admin_actions a
325 LEFT JOIN rustio_users u ON u.id = a.user_id
326 WHERE a.model_name = $1 AND a.object_id = $2
327 ORDER BY a.timestamp DESC, a.id DESC",
328 )
329 .bind(model_name)
330 .bind(object_id)
331 .fetch_all(db.pool())
332 .await?;
333 rows.iter().map(row_to_action).collect()
334}
335
336fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
337 Ok(AdminAction {
338 id: r.try_get("id")?,
339 user_id: r.try_get("user_id")?,
340 user_email: r.try_get("user_email")?,
341 action_type: r.try_get("action_type")?,
342 model_name: r.try_get("model_name")?,
343 object_id: r.try_get("object_id")?,
344 timestamp: r.try_get("timestamp")?,
345 ip_address: r.try_get("ip_address")?,
346 summary: r.try_get("summary")?,
347 })
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
361 fn audit_event_strings_are_unique() {
362 let events = [
363 AuditEvent::UserCreated,
364 AuditEvent::UserUpdated,
365 AuditEvent::UserDeleted,
366 AuditEvent::GroupCreated,
367 AuditEvent::GroupUpdated,
368 AuditEvent::GroupDeleted,
369 AuditEvent::PasswordResetSelfRequest,
370 AuditEvent::PasswordResetSelfConsume,
371 AuditEvent::PasswordResetByOther,
372 AuditEvent::AccountLocked,
373 AuditEvent::AccountUnlocked,
374 AuditEvent::MfaEnabled,
375 AuditEvent::MfaDisabled,
376 AuditEvent::MfaResetByOther,
377 AuditEvent::SessionsRevokedSelf,
378 AuditEvent::SessionsRevokedByOther,
379 AuditEvent::SessionLogout,
380 AuditEvent::EmergencyRecovery,
381 ];
382 let mut set = std::collections::HashSet::new();
383 for e in events {
384 assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
385 }
386 assert_eq!(set.len(), events.len());
387 }
388
389 #[test]
393 fn audit_event_strings_are_snake_case() {
394 let events = [
395 AuditEvent::UserCreated,
396 AuditEvent::UserUpdated,
397 AuditEvent::UserDeleted,
398 AuditEvent::GroupCreated,
399 AuditEvent::GroupUpdated,
400 AuditEvent::GroupDeleted,
401 AuditEvent::PasswordResetSelfRequest,
402 AuditEvent::PasswordResetSelfConsume,
403 AuditEvent::PasswordResetByOther,
404 AuditEvent::AccountLocked,
405 AuditEvent::AccountUnlocked,
406 AuditEvent::MfaEnabled,
407 AuditEvent::MfaDisabled,
408 AuditEvent::MfaResetByOther,
409 AuditEvent::SessionsRevokedSelf,
410 AuditEvent::SessionsRevokedByOther,
411 AuditEvent::SessionLogout,
412 AuditEvent::EmergencyRecovery,
413 ];
414 for e in events {
415 let s = e.as_str();
416 assert!(!s.is_empty(), "{e:?} as_str is empty");
417 assert!(
418 s.chars()
419 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
420 "{e:?}.as_str() = {s:?} is not snake_case"
421 );
422 }
423 }
424}