Skip to main content

renox_core/
audit.rs

1//! An audit trail: who did what, to which record, from where. Opt in with
2//! `App::module(Audit)`, which also records every auth event (logins,
3//! failed logins, lockouts, password and profile changes, deleted
4//! accounts). Record the app's own actions with [`record`]:
5//!
6//! ```
7//! use renox::prelude::*;
8//! use renox::audit::{self, Audit, Entry};
9//!
10//! # fn app() -> App {
11//! App::new().module(Auth::new()).module(Audit)
12//! # }
13//! async fn refund(State(db): State<Db>, user: AuthUser, ClientIp(ip): ClientIp) -> Result<&'static str> {
14//!     // … refund order 42 …
15//!     audit::record(
16//!         &db,
17//!         Entry::new("order.refunded")
18//!             .user(user.id)
19//!             .subject("orders", 42)
20//!             .data(json!({ "amount": 75_000 }))
21//!             .ip(ip),
22//!     )
23//!     .await?;
24//!     let recent = audit::for_subject(&db, "orders", 42, 20).await?; // newest first
25//!     # let _ = recent;
26//!     Ok("refunded")
27//! }
28//! # let _ = (app, refund);
29//! ```
30//!
31//! `rnx audit:prune --days 365` deletes older entries.
32
33use 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
51/// Adds the `audit_logs` table, records auth events, and the
52/// `audit:prune` command.
53pub 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/// One thing that happened, to be recorded with [`record`].
131#[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    /// What happened, e.g. `order.refunded`.
142    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    /// Who did it.
153    pub fn user(mut self, user_id: i64) -> Self {
154        self.user_id = Some(user_id);
155        self
156    }
157
158    /// The record it happened to, e.g. `("orders", 42)`.
159    pub fn subject(mut self, kind: &str, id: i64) -> Self {
160        self.subject = Some((kind.to_owned(), id));
161        self
162    }
163
164    /// Details, e.g. the old and new values.
165    pub fn data(mut self, data: Value) -> Self {
166        self.data = data;
167        self
168    }
169
170    /// Where the request came from (`ClientIp`).
171    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/// A recorded entry.
183#[derive(Debug, Clone, Serialize)]
184#[non_exhaustive]
185pub struct AuditLog {
186    /// The `audit_logs` row id.
187    pub id: i64,
188    /// Who did it; `None` when unknown (e.g. a failed login).
189    pub user_id: Option<i64>,
190    /// What happened, e.g. `auth.login` or `order.refunded`.
191    pub action: String,
192    /// The kind of record it happened to, e.g. `orders`.
193    pub subject_type: Option<String>,
194    /// That record's id.
195    pub subject_id: Option<i64>,
196    /// Details, e.g. the old and new values; `{}` when none were given.
197    pub data: Value,
198    /// Where the request came from, if recorded.
199    pub ip: Option<String>,
200    /// When it was recorded.
201    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
218/// Records `entry` (needs the `Audit` module's table).
219pub 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
256/// The newest `limit` entries.
257pub async fn latest(db: &Db, limit: u32) -> Result<Vec<AuditLog>> {
258    fetch(db, "", Vec::new(), limit).await
259}
260
261/// What `user_id` did, newest first.
262pub 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
272/// What happened to one record, newest first.
273pub 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
284/// Deletes entries older than `age`; returns how many.
285pub 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}