1use chrono::{DateTime, Utc};
29use sqlx::Row as _;
30
31use crate::error::Error;
32use crate::orm::Db;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ActionType {
39 Create,
40 Update,
41 Delete,
42}
43
44impl ActionType {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Create => "create",
48 Self::Update => "update",
49 Self::Delete => "delete",
50 }
51 }
52
53 pub fn parse(s: &str) -> Option<Self> {
57 match s {
58 "create" => Some(Self::Create),
59 "update" => Some(Self::Update),
60 "delete" => Some(Self::Delete),
61 _ => None,
62 }
63 }
64
65 pub fn label(self) -> &'static str {
67 match self {
68 Self::Create => "Created",
69 Self::Update => "Updated",
70 Self::Delete => "Deleted",
71 }
72 }
73
74 pub fn pill_class(self) -> &'static str {
77 match self {
78 Self::Create => "rio-pill rio-pill-emerald",
79 Self::Update => "rio-pill rio-pill-indigo",
80 Self::Delete => "rio-pill rio-pill-rose",
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
89pub struct AdminAction {
90 pub id: i64,
91 pub user_id: i64,
92 pub user_email: Option<String>,
93 pub action_type: String,
94 pub model_name: String,
95 pub object_id: i64,
96 pub timestamp: DateTime<Utc>,
97 pub ip_address: Option<String>,
98 pub summary: String,
99}
100
101pub struct LogEntry<'a> {
104 pub user_id: i64,
105 pub action_type: ActionType,
106 pub model_name: &'a str,
107 pub object_id: i64,
108 pub ip_address: Option<&'a str>,
109 pub summary: String,
110}
111
112pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<(), Error> {
120 if entry.user_id <= 0 {
121 return Err(Error::Internal("admin audit: missing user_id".to_string()));
122 }
123 if entry.model_name.trim().is_empty() {
124 return Err(Error::Internal(
125 "admin audit: missing model_name".to_string(),
126 ));
127 }
128 if entry.object_id <= 0 {
129 return Err(Error::Internal(
130 "admin audit: missing object_id".to_string(),
131 ));
132 }
133
134 let now = Utc::now();
135 sqlx::query(
136 "INSERT INTO rustio_admin_actions
137 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
138 VALUES (?, ?, ?, ?, ?, ?, ?)",
139 )
140 .bind(entry.user_id)
141 .bind(entry.action_type.as_str())
142 .bind(entry.model_name)
143 .bind(entry.object_id)
144 .bind(now)
145 .bind(entry.ip_address)
146 .bind(&entry.summary)
147 .execute(db.pool())
148 .await?;
149 Ok(())
150}
151
152pub async fn recent(
157 db: &Db,
158 limit: i64,
159 model_filter: Option<&str>,
160 action_filter: Option<&str>,
161) -> Result<Vec<AdminAction>, Error> {
162 let mut sql = String::from(
166 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
167 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
168 FROM rustio_admin_actions a
169 LEFT JOIN rustio_users u ON u.id = a.user_id",
170 );
171 let mut clauses: Vec<&'static str> = Vec::new();
172 if model_filter.is_some() {
173 clauses.push("a.model_name = ?");
174 }
175 if action_filter.is_some() {
176 clauses.push("a.action_type = ?");
177 }
178 if !clauses.is_empty() {
179 sql.push_str(" WHERE ");
180 sql.push_str(&clauses.join(" AND "));
181 }
182 sql.push_str(" ORDER BY a.timestamp DESC, a.id DESC LIMIT ?");
183
184 let mut q = sqlx::query(&sql);
185 if let Some(m) = model_filter {
186 q = q.bind(m);
187 }
188 if let Some(a) = action_filter {
189 q = q.bind(a);
190 }
191 q = q.bind(limit);
192
193 let rows = q.fetch_all(db.pool()).await?;
194 rows.iter().map(row_to_action).collect()
195}
196
197pub async fn for_object(
199 db: &Db,
200 model_name: &str,
201 object_id: i64,
202) -> Result<Vec<AdminAction>, Error> {
203 let rows = sqlx::query(
204 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
205 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
206 FROM rustio_admin_actions a
207 LEFT JOIN rustio_users u ON u.id = a.user_id
208 WHERE a.model_name = ? AND a.object_id = ?
209 ORDER BY a.timestamp DESC, a.id DESC",
210 )
211 .bind(model_name)
212 .bind(object_id)
213 .fetch_all(db.pool())
214 .await?;
215 rows.iter().map(row_to_action).collect()
216}
217
218fn row_to_action(r: &sqlx::sqlite::SqliteRow) -> Result<AdminAction, Error> {
219 Ok(AdminAction {
220 id: r.try_get("id")?,
221 user_id: r.try_get("user_id")?,
222 user_email: r.try_get("user_email")?,
223 action_type: r.try_get("action_type")?,
224 model_name: r.try_get("model_name")?,
225 object_id: r.try_get("object_id")?,
226 timestamp: r.try_get("timestamp")?,
227 ip_address: r.try_get("ip_address")?,
228 summary: r.try_get("summary")?,
229 })
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::auth;
236
237 async fn setup() -> Db {
238 let db = Db::memory().await.unwrap();
239 auth::ensure_core_tables(&db).await.unwrap();
240 db
241 }
242
243 async fn seeded_user(db: &Db) -> i64 {
244 auth::user::create(db, "x@y.co", "pw", auth::ROLE_ADMIN)
245 .await
246 .unwrap()
247 .id
248 }
249
250 #[tokio::test]
251 async fn record_round_trip_returns_through_recent() {
252 let db = setup().await;
253 let uid = seeded_user(&db).await;
254 record(
255 &db,
256 LogEntry {
257 user_id: uid,
258 action_type: ActionType::Create,
259 model_name: "tasks",
260 object_id: 1,
261 ip_address: Some("127.0.0.1"),
262 summary: "Created Task #1: Ship".to_string(),
263 },
264 )
265 .await
266 .unwrap();
267
268 let rs = recent(&db, 10, None, None).await.unwrap();
269 assert_eq!(rs.len(), 1);
270 assert_eq!(rs[0].user_id, uid);
271 assert_eq!(rs[0].user_email.as_deref(), Some("x@y.co"));
272 assert_eq!(rs[0].action_type, "create");
273 assert_eq!(rs[0].model_name, "tasks");
274 assert_eq!(rs[0].object_id, 1);
275 assert_eq!(rs[0].summary, "Created Task #1: Ship");
276 }
277
278 #[tokio::test]
279 async fn recent_filters_by_model() {
280 let db = setup().await;
281 let uid = seeded_user(&db).await;
282 for (model, obj) in [("tasks", 1), ("users", 1), ("tasks", 2)] {
283 record(
284 &db,
285 LogEntry {
286 user_id: uid,
287 action_type: ActionType::Create,
288 model_name: model,
289 object_id: obj,
290 ip_address: None,
291 summary: format!("Created {model} #{obj}"),
292 },
293 )
294 .await
295 .unwrap();
296 }
297 let tasks_only = recent(&db, 10, Some("tasks"), None).await.unwrap();
298 assert_eq!(tasks_only.len(), 2);
299 assert!(tasks_only.iter().all(|a| a.model_name == "tasks"));
300 }
301
302 #[tokio::test]
303 async fn recent_filters_by_action_type() {
304 let db = setup().await;
305 let uid = seeded_user(&db).await;
306 record(
307 &db,
308 LogEntry {
309 user_id: uid,
310 action_type: ActionType::Create,
311 model_name: "tasks",
312 object_id: 1,
313 ip_address: None,
314 summary: "c".into(),
315 },
316 )
317 .await
318 .unwrap();
319 record(
320 &db,
321 LogEntry {
322 user_id: uid,
323 action_type: ActionType::Delete,
324 model_name: "tasks",
325 object_id: 1,
326 ip_address: None,
327 summary: "d".into(),
328 },
329 )
330 .await
331 .unwrap();
332 let deletes = recent(&db, 10, None, Some("delete")).await.unwrap();
333 assert_eq!(deletes.len(), 1);
334 assert_eq!(deletes[0].action_type, "delete");
335 }
336
337 #[tokio::test]
338 async fn for_object_returns_newest_first() {
339 let db = setup().await;
340 let uid = seeded_user(&db).await;
341 record(
342 &db,
343 LogEntry {
344 user_id: uid,
345 action_type: ActionType::Create,
346 model_name: "tasks",
347 object_id: 7,
348 ip_address: None,
349 summary: "first".into(),
350 },
351 )
352 .await
353 .unwrap();
354 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
356 record(
357 &db,
358 LogEntry {
359 user_id: uid,
360 action_type: ActionType::Update,
361 model_name: "tasks",
362 object_id: 7,
363 ip_address: None,
364 summary: "second".into(),
365 },
366 )
367 .await
368 .unwrap();
369 let hist = for_object(&db, "tasks", 7).await.unwrap();
370 assert_eq!(hist.len(), 2);
371 assert_eq!(hist[0].summary, "second");
372 assert_eq!(hist[1].summary, "first");
373 }
374
375 #[tokio::test]
376 async fn record_rejects_missing_user_id() {
377 let db = setup().await;
378 let err = record(
379 &db,
380 LogEntry {
381 user_id: 0,
382 action_type: ActionType::Create,
383 model_name: "tasks",
384 object_id: 1,
385 ip_address: None,
386 summary: "nope".into(),
387 },
388 )
389 .await;
390 assert!(matches!(err, Err(Error::Internal(_))));
391 }
392
393 #[tokio::test]
394 async fn record_rejects_missing_model() {
395 let db = setup().await;
396 let err = record(
397 &db,
398 LogEntry {
399 user_id: 1,
400 action_type: ActionType::Create,
401 model_name: "",
402 object_id: 1,
403 ip_address: None,
404 summary: "nope".into(),
405 },
406 )
407 .await;
408 assert!(matches!(err, Err(Error::Internal(_))));
409 }
410
411 #[tokio::test]
412 async fn record_rejects_missing_object_id() {
413 let db = setup().await;
414 let err = record(
415 &db,
416 LogEntry {
417 user_id: 1,
418 action_type: ActionType::Create,
419 model_name: "tasks",
420 object_id: 0,
421 ip_address: None,
422 summary: "nope".into(),
423 },
424 )
425 .await;
426 assert!(matches!(err, Err(Error::Internal(_))));
427 }
428
429 #[tokio::test]
430 async fn deleting_a_user_cascades_to_their_actions() {
431 let db = setup().await;
432 let uid = seeded_user(&db).await;
433 record(
434 &db,
435 LogEntry {
436 user_id: uid,
437 action_type: ActionType::Create,
438 model_name: "tasks",
439 object_id: 1,
440 ip_address: None,
441 summary: "c".into(),
442 },
443 )
444 .await
445 .unwrap();
446 sqlx::query("DELETE FROM rustio_users WHERE id = ?")
447 .bind(uid)
448 .execute(db.pool())
449 .await
450 .unwrap();
451 let rs = recent(&db, 10, None, None).await.unwrap();
452 assert!(
453 rs.is_empty(),
454 "FK cascade should have removed the action log entry"
455 );
456 }
457}