1use std::net::IpAddr;
34use std::time::Duration;
35
36use serde::Serialize;
37use serde_json::{Value, json};
38
39use crate::auth::events::{
40 AccountDeleted, EmailVerified, LockedOut, LoggedIn, LoggedOut, LoginFailed,
41 OtherDevicesLoggedOut, PasswordChanged, PasswordReset, ProfileUpdated, Registered,
42};
43use crate::db::{DateTime, Db, DbValue, Migration, Row, now, sql};
44use crate::{AppState, Module, Registry, Result, Routes};
45
46const MIGRATIONS: &[Migration] = &[crate::db::framework_migration!(
47 "audit",
48 "00010101000600_create_audit_logs_table"
49)];
50
51pub struct Audit;
54
55impl Module for Audit {
56 fn name(&self) -> &'static str {
57 "audit"
58 }
59
60 fn migrations(&self) -> &'static [Migration] {
61 MIGRATIONS
62 }
63
64 fn routes(&self) -> Routes {
65 Routes::new()
66 }
67
68 fn register(&self, app: &mut Registry) {
69 app.listen(|e: Registered, s| log(s, Entry::new("auth.registered").user(e.user_id)))
70 .listen(|e: LoggedIn, s| log(s, Entry::new("auth.login").user(e.user_id).ip_text(e.ip)))
71 .listen(|e: LoginFailed, s| {
72 let data = json!({ "email": e.email });
73 log(s, Entry::new("auth.login_failed").data(data).ip_text(e.ip))
74 })
75 .listen(|e: LockedOut, s| {
76 let data = json!({ "email": e.email, "seconds": e.seconds });
77 log(s, Entry::new("auth.locked_out").data(data).ip_text(e.ip))
78 })
79 .listen(|e: LoggedOut, s| log(s, Entry::new("auth.logout").user(e.user_id)))
80 .listen(|e: PasswordReset, s| log(s, Entry::new("auth.password_reset").user(e.user_id)))
81 .listen(|e: PasswordChanged, s| {
82 log(s, Entry::new("auth.password_changed").user(e.user_id))
83 })
84 .listen(|e: EmailVerified, s| log(s, Entry::new("auth.email_verified").user(e.user_id)))
85 .listen(|e: ProfileUpdated, s| {
86 let data = json!({ "email_changed": e.email_changed });
87 log(
88 s,
89 Entry::new("auth.profile_updated")
90 .user(e.user_id)
91 .data(data),
92 )
93 })
94 .listen(|e: OtherDevicesLoggedOut, s| {
95 log(
96 s,
97 Entry::new("auth.other_devices_logged_out").user(e.user_id),
98 )
99 })
100 .listen(|e: AccountDeleted, s| {
101 let data = json!({ "email": e.email });
102 log(
103 s,
104 Entry::new("auth.account_deleted")
105 .user(e.user_id)
106 .data(data),
107 )
108 });
109 app.command(
110 "audit:prune",
111 "Delete audit entries older than --days (default 365)",
112 |args, state| async move {
113 let days: u64 = args
114 .value("--days")
115 .unwrap_or("365")
116 .parse()
117 .map_err(|_| crate::Error::BadRequest("--days must be a number".into()))?;
118 let pruned = prune(&state.db, Duration::from_secs(days * 24 * 60 * 60)).await?;
119 println!("Deleted {pruned} audit entries older than {days} days.");
120 Ok(())
121 },
122 );
123 }
124}
125
126async fn log(state: AppState, entry: Entry) -> Result {
127 record(&state.db, entry).await
128}
129
130#[derive(Debug, Clone)]
132pub struct Entry {
133 action: String,
134 user_id: Option<i64>,
135 subject: Option<(String, i64)>,
136 data: Value,
137 ip: Option<String>,
138}
139
140impl Entry {
141 pub fn new(action: &str) -> Self {
143 Self {
144 action: action.to_owned(),
145 user_id: None,
146 subject: None,
147 data: json!({}),
148 ip: None,
149 }
150 }
151
152 pub fn user(mut self, user_id: i64) -> Self {
154 self.user_id = Some(user_id);
155 self
156 }
157
158 pub fn subject(mut self, kind: &str, id: i64) -> Self {
160 self.subject = Some((kind.to_owned(), id));
161 self
162 }
163
164 pub fn data(mut self, data: Value) -> Self {
166 self.data = data;
167 self
168 }
169
170 pub fn ip(mut self, ip: Option<IpAddr>) -> Self {
172 self.ip = ip.map(|ip| ip.to_string());
173 self
174 }
175
176 fn ip_text(mut self, ip: Option<String>) -> Self {
177 self.ip = ip;
178 self
179 }
180}
181
182#[derive(Debug, Clone, Serialize)]
184#[non_exhaustive]
185pub struct AuditLog {
186 pub id: i64,
188 pub user_id: Option<i64>,
190 pub action: String,
192 pub subject_type: Option<String>,
194 pub subject_id: Option<i64>,
196 pub data: Value,
198 pub ip: Option<String>,
200 pub created_at: DateTime,
202}
203
204fn from_row(row: &Row) -> std::result::Result<AuditLog, crate::db::DbError> {
205 let data: String = row.try_get("data")?;
206 Ok(AuditLog {
207 id: row.try_get("id")?,
208 user_id: row.try_get("user_id")?,
209 action: row.try_get("action")?,
210 subject_type: row.try_get("subject_type")?,
211 subject_id: row.try_get("subject_id")?,
212 data: serde_json::from_str(&data).unwrap_or(Value::Null),
213 ip: row.try_get("ip")?,
214 created_at: row.try_get("created_at")?,
215 })
216}
217
218pub async fn record(db: &Db, entry: Entry) -> Result {
220 let (subject_type, subject_id) = match entry.subject {
221 Some((kind, id)) => (Some(kind), Some(id)),
222 None => (None, None),
223 };
224 sql(
225 "INSERT INTO audit_logs (user_id, action, subject_type, subject_id, data, ip, created_at) \
226 VALUES (?, ?, ?, ?, ?, ?, ?)",
227 )
228 .bind(entry.user_id)
229 .bind(entry.action)
230 .bind(subject_type)
231 .bind(subject_id)
232 .bind(entry.data.to_string())
233 .bind(entry.ip)
234 .bind(now())
235 .execute(db)
236 .await?;
237 Ok(())
238}
239
240const COLUMNS: &str = "id, user_id, action, subject_type, subject_id, data, ip, created_at";
241
242async fn fetch(db: &Db, condition: &str, binds: Vec<DbValue>, limit: u32) -> Result<Vec<AuditLog>> {
243 let rows = sql(format!(
244 "SELECT {COLUMNS} FROM audit_logs {condition} ORDER BY id DESC LIMIT ?"
245 ))
246 .bind_all(binds)
247 .bind(i64::from(limit))
248 .fetch_all(db)
249 .await?;
250 Ok(rows
251 .iter()
252 .map(from_row)
253 .collect::<std::result::Result<_, _>>()?)
254}
255
256pub async fn latest(db: &Db, limit: u32) -> Result<Vec<AuditLog>> {
258 fetch(db, "", Vec::new(), limit).await
259}
260
261pub async fn for_user(db: &Db, user_id: i64, limit: u32) -> Result<Vec<AuditLog>> {
263 fetch(
264 db,
265 "WHERE user_id = ?",
266 vec![DbValue::Integer(user_id)],
267 limit,
268 )
269 .await
270}
271
272pub async fn for_subject(db: &Db, kind: &str, id: i64, limit: u32) -> Result<Vec<AuditLog>> {
274 let binds = vec![DbValue::Text(kind.to_owned()), DbValue::Integer(id)];
275 fetch(
276 db,
277 "WHERE subject_type = ? AND subject_id = ?",
278 binds,
279 limit,
280 )
281 .await
282}
283
284pub async fn prune(db: &Db, age: Duration) -> Result<u64> {
286 let before = now() - chrono::Duration::from_std(age).unwrap_or_default();
287 Ok(sql("DELETE FROM audit_logs WHERE created_at < ?")
288 .bind(before)
289 .execute(db)
290 .await?)
291}