use std::net::IpAddr;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Value, json};
use crate::auth::events::{
AccountDeleted, EmailVerified, LockedOut, LoggedIn, LoggedOut, LoginFailed,
OtherDevicesLoggedOut, PasswordChanged, PasswordReset, ProfileUpdated, Registered,
};
use crate::db::{DateTime, Db, DbValue, Migration, Row, now, sql};
use crate::{AppState, Module, Registry, Result, Routes};
const MIGRATIONS: &[Migration] = &[crate::db::framework_migration!(
"audit",
"00010101000600_create_audit_logs_table"
)];
pub struct Audit;
impl Module for Audit {
fn name(&self) -> &'static str {
"audit"
}
fn migrations(&self) -> &'static [Migration] {
MIGRATIONS
}
fn routes(&self) -> Routes {
Routes::new()
}
fn register(&self, app: &mut Registry) {
app.listen(|e: Registered, s| log(s, Entry::new("auth.registered").user(e.user_id)))
.listen(|e: LoggedIn, s| log(s, Entry::new("auth.login").user(e.user_id).ip_text(e.ip)))
.listen(|e: LoginFailed, s| {
let data = json!({ "email": e.email });
log(s, Entry::new("auth.login_failed").data(data).ip_text(e.ip))
})
.listen(|e: LockedOut, s| {
let data = json!({ "email": e.email, "seconds": e.seconds });
log(s, Entry::new("auth.locked_out").data(data).ip_text(e.ip))
})
.listen(|e: LoggedOut, s| log(s, Entry::new("auth.logout").user(e.user_id)))
.listen(|e: PasswordReset, s| log(s, Entry::new("auth.password_reset").user(e.user_id)))
.listen(|e: PasswordChanged, s| {
log(s, Entry::new("auth.password_changed").user(e.user_id))
})
.listen(|e: EmailVerified, s| log(s, Entry::new("auth.email_verified").user(e.user_id)))
.listen(|e: ProfileUpdated, s| {
let data = json!({ "email_changed": e.email_changed });
log(
s,
Entry::new("auth.profile_updated")
.user(e.user_id)
.data(data),
)
})
.listen(|e: OtherDevicesLoggedOut, s| {
log(
s,
Entry::new("auth.other_devices_logged_out").user(e.user_id),
)
})
.listen(|e: AccountDeleted, s| {
let data = json!({ "email": e.email });
log(
s,
Entry::new("auth.account_deleted")
.user(e.user_id)
.data(data),
)
});
app.command(
"audit:prune",
"Delete audit entries older than --days (default 365)",
|args, state| async move {
let days: u64 = args
.value("--days")
.unwrap_or("365")
.parse()
.map_err(|_| crate::Error::BadRequest("--days must be a number".into()))?;
let pruned = prune(&state.db, Duration::from_secs(days * 24 * 60 * 60)).await?;
println!("Deleted {pruned} audit entries older than {days} days.");
Ok(())
},
);
}
}
async fn log(state: AppState, entry: Entry) -> Result {
record(&state.db, entry).await
}
#[derive(Debug, Clone)]
pub struct Entry {
action: String,
user_id: Option<i64>,
subject: Option<(String, i64)>,
data: Value,
ip: Option<String>,
}
impl Entry {
pub fn new(action: &str) -> Self {
Self {
action: action.to_owned(),
user_id: None,
subject: None,
data: json!({}),
ip: None,
}
}
pub fn user(mut self, user_id: i64) -> Self {
self.user_id = Some(user_id);
self
}
pub fn subject(mut self, kind: &str, id: i64) -> Self {
self.subject = Some((kind.to_owned(), id));
self
}
pub fn data(mut self, data: Value) -> Self {
self.data = data;
self
}
pub fn ip(mut self, ip: Option<IpAddr>) -> Self {
self.ip = ip.map(|ip| ip.to_string());
self
}
fn ip_text(mut self, ip: Option<String>) -> Self {
self.ip = ip;
self
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct AuditLog {
pub id: i64,
pub user_id: Option<i64>,
pub action: String,
pub subject_type: Option<String>,
pub subject_id: Option<i64>,
pub data: Value,
pub ip: Option<String>,
pub created_at: DateTime,
}
fn from_row(row: &Row) -> std::result::Result<AuditLog, crate::db::DbError> {
let data: String = row.try_get("data")?;
Ok(AuditLog {
id: row.try_get("id")?,
user_id: row.try_get("user_id")?,
action: row.try_get("action")?,
subject_type: row.try_get("subject_type")?,
subject_id: row.try_get("subject_id")?,
data: serde_json::from_str(&data).unwrap_or(Value::Null),
ip: row.try_get("ip")?,
created_at: row.try_get("created_at")?,
})
}
pub async fn record(db: &Db, entry: Entry) -> Result {
let (subject_type, subject_id) = match entry.subject {
Some((kind, id)) => (Some(kind), Some(id)),
None => (None, None),
};
sql(
"INSERT INTO audit_logs (user_id, action, subject_type, subject_id, data, ip, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.action)
.bind(subject_type)
.bind(subject_id)
.bind(entry.data.to_string())
.bind(entry.ip)
.bind(now())
.execute(db)
.await?;
Ok(())
}
const COLUMNS: &str = "id, user_id, action, subject_type, subject_id, data, ip, created_at";
async fn fetch(db: &Db, condition: &str, binds: Vec<DbValue>, limit: u32) -> Result<Vec<AuditLog>> {
let rows = sql(format!(
"SELECT {COLUMNS} FROM audit_logs {condition} ORDER BY id DESC LIMIT ?"
))
.bind_all(binds)
.bind(i64::from(limit))
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(from_row)
.collect::<std::result::Result<_, _>>()?)
}
pub async fn latest(db: &Db, limit: u32) -> Result<Vec<AuditLog>> {
fetch(db, "", Vec::new(), limit).await
}
pub async fn for_user(db: &Db, user_id: i64, limit: u32) -> Result<Vec<AuditLog>> {
fetch(
db,
"WHERE user_id = ?",
vec![DbValue::Integer(user_id)],
limit,
)
.await
}
pub async fn for_subject(db: &Db, kind: &str, id: i64, limit: u32) -> Result<Vec<AuditLog>> {
let binds = vec![DbValue::Text(kind.to_owned()), DbValue::Integer(id)];
fetch(
db,
"WHERE subject_type = ? AND subject_id = ?",
binds,
limit,
)
.await
}
pub async fn prune(db: &Db, age: Duration) -> Result<u64> {
let before = now() - chrono::Duration::from_std(age).unwrap_or_default();
Ok(sql("DELETE FROM audit_logs WHERE created_at < ?")
.bind(before)
.execute(db)
.await?)
}