Skip to main content

allowthem_core/
audit.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::db::Db;
5use crate::error::AuthError;
6use crate::types::{AuditEntryId, UserId};
7
8/// Every type of authentication event that can be recorded.
9#[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/// A single record in the audit log.
50#[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
62/// Map an `AuditEvent` variant to its snake_case database value.
63///
64/// `AuditEvent` has `#[sqlx(rename_all = "snake_case")]` but no matching
65/// serde attribute. This function provides the canonical snake_case string
66/// for use in dynamic SQL bind values.
67fn 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
107/// Parameters for searching/filtering audit log entries.
108pub 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/// An audit log entry with the user's email resolved via LEFT JOIN.
119/// Used for admin list display — avoids showing raw UUIDs.
120#[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
133/// Result of a paginated audit log search.
134pub struct SearchAuditResult {
135    pub entries: Vec<AuditListEntry>,
136    pub total: u32,
137}
138
139impl Db {
140    /// Record an audit event.
141    ///
142    /// `user_id` may be `None` for events where no authenticated user is
143    /// involved (e.g. a failed login attempt against an unknown email).
144    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    /// Retrieve audit log entries, optionally filtered by user.
173    ///
174    /// Results are ordered by `created_at` descending (newest first).
175    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    /// Retrieve audit log entries filtered by event type.
214    ///
215    /// Results are ordered by `created_at` descending (newest first).
216    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    /// Get the most recent login timestamp for a user, if any.
238    ///
239    /// Returns `None` if the user has never logged in (no audit entry
240    /// with event_type = 'login' for this user_id).
241    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    /// Search and filter audit log entries with pagination.
253    ///
254    /// Builds a dynamic query with optional filters for user, event type,
255    /// outcome, and date range. LEFT JOINs `allowthem_users` for email
256    /// resolution. Follows the same dynamic-SQL pattern as `search_users`.
257    pub async fn search_audit_log(
258        &self,
259        params: SearchAuditParams<'_>,
260    ) -> Result<SearchAuditResult, AuthError> {
261        // Build WHERE clauses and bind values. user_id is bound separately
262        // because it is a UUID (not a String), and sqlx needs the correct
263        // type for TEXT column comparison in SQLite.
264        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            // Bound separately below — position tracked by clause order
270        }
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        // Count query
304        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        // Bind user_id first (it's the first WHERE clause if present)
309        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        // Data query with LEFT JOIN for user email
321        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}