1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::db::Db;
5use crate::error::AuthError;
6use crate::types::{AuditEntryId, UserId};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
10#[sqlx(rename_all = "snake_case")]
11pub enum AuditEvent {
12 Login,
13 LoginFailed,
14 Logout,
15 Register,
16 PasswordChange,
17 PasswordReset,
18 RoleAssigned,
19 RoleUnassigned,
20 PermissionAssigned,
21 PermissionUnassigned,
22 SessionCreated,
23 SessionExpired,
24 UserUpdated,
25 UserDeleted,
26 MfaEnabled,
27 MfaDisabled,
28 MfaChallengeSuccess,
29 MfaChallengeFailed,
30 OrgCreated,
31 OrgUpdated,
32 OrgDeleted,
33 OrgMemberAdded,
34 OrgMemberRemoved,
35 OrgMemberRoleChanged,
36 OrgOwnershipTransferred,
37 TeamCreated,
38 TeamUpdated,
39 TeamDeleted,
40 TeamMemberAdded,
41 TeamMemberRemoved,
42 TeamMemberRoleChanged,
43 OrgInvitationCreated,
44 OrgInvitationAccepted,
45 OrgInvitationDeclined,
46 OrgInvitationRevoked,
47}
48
49#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
51pub struct AuditEntry {
52 pub id: AuditEntryId,
53 pub event_type: AuditEvent,
54 pub user_id: Option<UserId>,
55 pub target_id: Option<String>,
56 pub ip_address: Option<String>,
57 pub user_agent: Option<String>,
58 pub detail: Option<String>,
59 pub created_at: DateTime<Utc>,
60}
61
62fn event_to_slug(event: &AuditEvent) -> &'static str {
68 match event {
69 AuditEvent::Login => "login",
70 AuditEvent::LoginFailed => "login_failed",
71 AuditEvent::Logout => "logout",
72 AuditEvent::Register => "register",
73 AuditEvent::PasswordChange => "password_change",
74 AuditEvent::PasswordReset => "password_reset",
75 AuditEvent::RoleAssigned => "role_assigned",
76 AuditEvent::RoleUnassigned => "role_unassigned",
77 AuditEvent::PermissionAssigned => "permission_assigned",
78 AuditEvent::PermissionUnassigned => "permission_unassigned",
79 AuditEvent::SessionCreated => "session_created",
80 AuditEvent::SessionExpired => "session_expired",
81 AuditEvent::UserUpdated => "user_updated",
82 AuditEvent::UserDeleted => "user_deleted",
83 AuditEvent::MfaEnabled => "mfa_enabled",
84 AuditEvent::MfaDisabled => "mfa_disabled",
85 AuditEvent::MfaChallengeSuccess => "mfa_challenge_success",
86 AuditEvent::MfaChallengeFailed => "mfa_challenge_failed",
87 AuditEvent::OrgCreated => "org_created",
88 AuditEvent::OrgUpdated => "org_updated",
89 AuditEvent::OrgDeleted => "org_deleted",
90 AuditEvent::OrgMemberAdded => "org_member_added",
91 AuditEvent::OrgMemberRemoved => "org_member_removed",
92 AuditEvent::OrgMemberRoleChanged => "org_member_role_changed",
93 AuditEvent::OrgOwnershipTransferred => "org_ownership_transferred",
94 AuditEvent::TeamCreated => "team_created",
95 AuditEvent::TeamUpdated => "team_updated",
96 AuditEvent::TeamDeleted => "team_deleted",
97 AuditEvent::TeamMemberAdded => "team_member_added",
98 AuditEvent::TeamMemberRemoved => "team_member_removed",
99 AuditEvent::TeamMemberRoleChanged => "team_member_role_changed",
100 AuditEvent::OrgInvitationCreated => "org_invitation_created",
101 AuditEvent::OrgInvitationAccepted => "org_invitation_accepted",
102 AuditEvent::OrgInvitationDeclined => "org_invitation_declined",
103 AuditEvent::OrgInvitationRevoked => "org_invitation_revoked",
104 }
105}
106
107pub struct SearchAuditParams<'a> {
109 pub user_id: Option<UserId>,
110 pub event_type: Option<&'a AuditEvent>,
111 pub is_success: Option<bool>,
112 pub from: Option<DateTime<Utc>>,
113 pub to: Option<DateTime<Utc>>,
114 pub limit: u32,
115 pub offset: u32,
116}
117
118#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
121pub struct AuditListEntry {
122 pub id: AuditEntryId,
123 pub event_type: AuditEvent,
124 pub user_id: Option<UserId>,
125 pub user_email: Option<String>,
126 pub target_id: Option<String>,
127 pub ip_address: Option<String>,
128 pub user_agent: Option<String>,
129 pub detail: Option<String>,
130 pub created_at: DateTime<Utc>,
131}
132
133pub struct SearchAuditResult {
135 pub entries: Vec<AuditListEntry>,
136 pub total: u32,
137}
138
139impl Db {
140 pub async fn log_audit(
145 &self,
146 event_type: AuditEvent,
147 user_id: Option<&UserId>,
148 target_id: Option<&str>,
149 ip_address: Option<&str>,
150 user_agent: Option<&str>,
151 detail: Option<&str>,
152 ) -> Result<(), AuthError> {
153 let id = AuditEntryId::new();
154 sqlx::query(
155 "INSERT INTO allowthem_audit_log
156 (id, event_type, user_id, target_id, ip_address, user_agent, detail)
157 VALUES (?, ?, ?, ?, ?, ?, ?)",
158 )
159 .bind(id)
160 .bind(event_type)
161 .bind(user_id.copied())
162 .bind(target_id)
163 .bind(ip_address)
164 .bind(user_agent)
165 .bind(detail)
166 .execute(self.pool())
167 .await
168 .map_err(AuthError::Database)?;
169 Ok(())
170 }
171
172 pub async fn get_audit_log(
176 &self,
177 user_id: Option<&UserId>,
178 limit: u32,
179 offset: u32,
180 ) -> Result<Vec<AuditEntry>, AuthError> {
181 match user_id {
182 Some(uid) => {
183 sqlx::query_as::<_, AuditEntry>(
184 "SELECT id, event_type, user_id, target_id, ip_address, user_agent, detail, created_at
185 FROM allowthem_audit_log
186 WHERE user_id = ?
187 ORDER BY created_at DESC
188 LIMIT ? OFFSET ?",
189 )
190 .bind(*uid)
191 .bind(limit)
192 .bind(offset)
193 .fetch_all(self.pool())
194 .await
195 .map_err(AuthError::Database)
196 }
197 None => {
198 sqlx::query_as::<_, AuditEntry>(
199 "SELECT id, event_type, user_id, target_id, ip_address, user_agent, detail, created_at
200 FROM allowthem_audit_log
201 ORDER BY created_at DESC
202 LIMIT ? OFFSET ?",
203 )
204 .bind(limit)
205 .bind(offset)
206 .fetch_all(self.pool())
207 .await
208 .map_err(AuthError::Database)
209 }
210 }
211 }
212
213 pub async fn get_audit_log_by_event(
217 &self,
218 event_type: AuditEvent,
219 limit: u32,
220 offset: u32,
221 ) -> Result<Vec<AuditEntry>, AuthError> {
222 sqlx::query_as::<_, AuditEntry>(
223 "SELECT id, event_type, user_id, target_id, ip_address, user_agent, detail, created_at
224 FROM allowthem_audit_log
225 WHERE event_type = ?
226 ORDER BY created_at DESC
227 LIMIT ? OFFSET ?",
228 )
229 .bind(event_type)
230 .bind(limit)
231 .bind(offset)
232 .fetch_all(self.pool())
233 .await
234 .map_err(AuthError::Database)
235 }
236
237 pub async fn last_login_at(&self, user_id: UserId) -> Result<Option<DateTime<Utc>>, AuthError> {
242 sqlx::query_scalar(
243 "SELECT MAX(created_at) FROM allowthem_audit_log \
244 WHERE user_id = ? AND event_type = 'login'",
245 )
246 .bind(user_id)
247 .fetch_one(self.pool())
248 .await
249 .map_err(AuthError::Database)
250 }
251
252 pub async fn search_audit_log(
258 &self,
259 params: SearchAuditParams<'_>,
260 ) -> Result<SearchAuditResult, AuthError> {
261 let mut where_clauses: Vec<String> = Vec::new();
265 let mut string_binds: Vec<String> = Vec::new();
266
267 if params.user_id.is_some() {
268 where_clauses.push("a.user_id = ?".into());
269 }
271
272 if let Some(event) = params.event_type {
273 where_clauses.push("a.event_type = ?".into());
274 string_binds.push(event_to_slug(event).to_string());
275 }
276
277 match params.is_success {
278 Some(true) => {
279 where_clauses.push("a.event_type != 'login_failed'".into());
280 }
281 Some(false) => {
282 where_clauses.push("a.event_type = 'login_failed'".into());
283 }
284 None => {}
285 }
286
287 if let Some(from) = params.from {
288 where_clauses.push("a.created_at >= ?".into());
289 string_binds.push(from.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string());
290 }
291
292 if let Some(to) = params.to {
293 where_clauses.push("a.created_at < ?".into());
294 string_binds.push(to.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string());
295 }
296
297 let where_sql = if where_clauses.is_empty() {
298 String::new()
299 } else {
300 format!("WHERE {}", where_clauses.join(" AND "))
301 };
302
303 let count_sql: &'static str = Box::leak(
305 format!("SELECT COUNT(*) FROM allowthem_audit_log a {where_sql}").into_boxed_str(),
306 );
307 let mut count_query = sqlx::query_scalar::<_, i64>(count_sql);
308 if let Some(uid) = params.user_id {
310 count_query = count_query.bind(uid);
311 }
312 for val in &string_binds {
313 count_query = count_query.bind(val);
314 }
315 let total = count_query
316 .fetch_one(self.pool())
317 .await
318 .map_err(AuthError::Database)? as u32;
319
320 let data_sql: &'static str = Box::leak(
322 format!(
323 "SELECT a.id, a.event_type, a.user_id, u.email AS user_email, \
324 a.target_id, a.ip_address, a.user_agent, a.detail, a.created_at \
325 FROM allowthem_audit_log a \
326 LEFT JOIN allowthem_users u ON a.user_id = u.id \
327 {where_sql} \
328 ORDER BY a.created_at DESC \
329 LIMIT ? OFFSET ?"
330 )
331 .into_boxed_str(),
332 );
333 let mut data_query = sqlx::query_as::<_, AuditListEntry>(data_sql);
334 if let Some(uid) = params.user_id {
335 data_query = data_query.bind(uid);
336 }
337 for val in &string_binds {
338 data_query = data_query.bind(val);
339 }
340 data_query = data_query.bind(params.limit).bind(params.offset);
341
342 let entries = data_query
343 .fetch_all(self.pool())
344 .await
345 .map_err(AuthError::Database)?;
346
347 Ok(SearchAuditResult { entries, total })
348 }
349}