use axum::{
Json,
extract::{Query, State},
response::IntoResponse,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{app::AppState, error::ApiError, login::CurrentUser};
pub mod action {
pub const USER_CREATED: &str = "user.created";
pub const USER_UPDATED: &str = "user.updated";
pub const AGENT_ISSUED: &str = "agent.issued";
pub const AGENT_REVOKED: &str = "agent.revoked";
pub const DEPARTMENT_CREATED: &str = "department.created";
pub const DEPARTMENT_UPDATED: &str = "department.updated";
pub const DEPARTMENT_DELETED: &str = "department.deleted";
pub const DEPARTMENT_ASSIGNED: &str = "department.assigned";
pub const LOGIN_SUCCEEDED: &str = "auth.login";
pub const LOGIN_FAILED: &str = "auth.login_failed";
pub const PASSWORD_CHANGED: &str = "auth.password_changed";
pub const SESSIONS_ENDED: &str = "auth.sessions_ended";
}
#[derive(Debug, Default)]
pub struct Entry {
actor_id: Option<Uuid>,
actor_email: Option<String>,
action: String,
target_id: Option<Uuid>,
target_label: Option<String>,
details: Option<serde_json::Value>,
}
impl Entry {
pub fn new(action: &str) -> Self {
Self {
action: action.to_string(),
..Default::default()
}
}
pub fn by(mut self, actor_id: Uuid) -> Self {
self.actor_id = Some(actor_id);
self
}
pub fn by_email(mut self, email: impl Into<String>) -> Self {
self.actor_email = Some(email.into());
self
}
pub fn on(mut self, target_id: Uuid) -> Self {
self.target_id = Some(target_id);
self
}
pub fn labelled(mut self, label: impl Into<String>) -> Self {
self.target_label = Some(label.into());
self
}
pub fn with(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub async fn record(self, pool: &PgPool) {
let result = sqlx::query(
"INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_label, details)
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(self.actor_id)
.bind(self.actor_email.as_deref())
.bind(&self.action)
.bind(self.target_id)
.bind(self.target_label.as_deref())
.bind(self.details.as_ref())
.execute(pool)
.await;
if let Err(error) = result {
tracing::error!(%error, action = %self.action, "failed to write an audit entry");
}
}
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AuditRow {
pub id: i64,
pub actor_id: Option<Uuid>,
pub actor_email: Option<String>,
pub action: String,
pub target_id: Option<Uuid>,
pub target_label: Option<String>,
pub details: Option<serde_json::Value>,
pub at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct AuditQuery {
pub actor_id: Option<Uuid>,
pub target_id: Option<Uuid>,
pub action: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
const MAX_LIMIT: i64 = 500;
const DEFAULT_LIMIT: i64 = 100;
pub async fn list(State(state): State<AppState>, user: CurrentUser, Query(query): Query<AuditQuery>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let offset = query.offset.unwrap_or(0).max(0);
let entries: Vec<AuditRow> = sqlx::query_as(
"SELECT id, actor_id, actor_email, action, target_id, target_label, details, at
FROM audit_log
WHERE ($1::uuid IS NULL OR actor_id = $1)
AND ($2::uuid IS NULL OR target_id = $2)
AND ($3::text IS NULL OR action = $3)
AND ($4::timestamptz IS NULL OR at >= $4)
AND ($5::timestamptz IS NULL OR at <= $5)
ORDER BY at DESC, id DESC
LIMIT $6 OFFSET $7",
)
.bind(query.actor_id)
.bind(query.target_id)
.bind(query.action.as_deref())
.bind(query.since)
.bind(query.until)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await?;
Ok(Json(entries))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_entry_reads_as_a_sentence() {
let actor = Uuid::new_v4();
let target = Uuid::new_v4();
let entry = Entry::new(action::AGENT_ISSUED)
.by(actor)
.by_email("boss@example.test")
.on(target)
.labelled("ivan-laptop");
assert_eq!(entry.action, "agent.issued");
assert_eq!(entry.actor_id, Some(actor));
assert_eq!(entry.target_id, Some(target));
assert_eq!(entry.target_label.as_deref(), Some("ivan-laptop"));
}
#[test]
fn an_entry_without_an_actor_is_allowed() {
let entry = Entry::new(action::USER_CREATED);
assert!(entry.actor_id.is_none() && entry.actor_email.is_none());
}
}