1use axum::{
14 Json,
15 extract::{Query, State},
16 response::IntoResponse,
17};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sqlx::PgPool;
21use uuid::Uuid;
22
23use crate::{app::AppState, error::ApiError, login::CurrentUser};
24
25pub mod action {
31 pub const USER_CREATED: &str = "user.created";
32 pub const USER_UPDATED: &str = "user.updated";
33 pub const AGENT_ISSUED: &str = "agent.issued";
34 pub const AGENT_REVOKED: &str = "agent.revoked";
35 pub const DEPARTMENT_CREATED: &str = "department.created";
36 pub const DEPARTMENT_UPDATED: &str = "department.updated";
37 pub const DEPARTMENT_DELETED: &str = "department.deleted";
38 pub const DEPARTMENT_ASSIGNED: &str = "department.assigned";
39 pub const LOGIN_SUCCEEDED: &str = "auth.login";
40 pub const LOGIN_FAILED: &str = "auth.login_failed";
41 pub const PASSWORD_CHANGED: &str = "auth.password_changed";
42 pub const SESSIONS_ENDED: &str = "auth.sessions_ended";
43 pub const PRIVACY_LEVEL_CHANGED: &str = "privacy.level_changed";
44 pub const CALENDAR_YEAR_REPLACED: &str = "calendar.year_replaced";
45 pub const STANDARD_HOURS_CHANGED: &str = "calendar.standard_hours_changed";
46 pub const WORK_RATE_CHANGED: &str = "calendar.work_rate_changed";
47 pub const ALERT_ACKNOWLEDGED: &str = "alert.acknowledged";
48 pub const ALERT_THRESHOLDS_CHANGED: &str = "alert.thresholds_changed";
49 pub const DEMO_SEEDED: &str = "demo.seeded";
50}
51
52#[derive(Debug, Default)]
57pub struct Entry {
58 actor_id: Option<Uuid>,
59 actor_email: Option<String>,
60 action: String,
61 target_id: Option<Uuid>,
62 target_label: Option<String>,
63 details: Option<serde_json::Value>,
64}
65
66impl Entry {
67 pub fn new(action: &str) -> Self {
68 Self {
69 action: action.to_string(),
70 ..Default::default()
71 }
72 }
73
74 pub fn by(mut self, actor_id: Uuid) -> Self {
76 self.actor_id = Some(actor_id);
77 self
78 }
79
80 pub fn by_email(mut self, email: impl Into<String>) -> Self {
83 self.actor_email = Some(email.into());
84 self
85 }
86
87 pub fn on(mut self, target_id: Uuid) -> Self {
88 self.target_id = Some(target_id);
89 self
90 }
91
92 pub fn labelled(mut self, label: impl Into<String>) -> Self {
94 self.target_label = Some(label.into());
95 self
96 }
97
98 pub fn with(mut self, details: serde_json::Value) -> Self {
101 self.details = Some(details);
102 self
103 }
104
105 pub async fn record(self, pool: &PgPool) {
111 let result = sqlx::query(
112 "INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_label, details)
113 VALUES ($1, $2, $3, $4, $5, $6)",
114 )
115 .bind(self.actor_id)
116 .bind(self.actor_email.as_deref())
117 .bind(&self.action)
118 .bind(self.target_id)
119 .bind(self.target_label.as_deref())
120 .bind(self.details.as_ref())
121 .execute(pool)
122 .await;
123
124 if let Err(error) = result {
125 tracing::error!(%error, action = %self.action, "failed to write an audit entry");
129 }
130 }
131}
132
133#[derive(Debug, Serialize, sqlx::FromRow)]
135pub struct AuditRow {
136 pub id: i64,
137 pub actor_id: Option<Uuid>,
138 pub actor_email: Option<String>,
139 pub action: String,
140 pub target_id: Option<Uuid>,
141 pub target_label: Option<String>,
142 pub details: Option<serde_json::Value>,
143 pub at: DateTime<Utc>,
144}
145
146#[derive(Debug, Deserialize)]
148pub struct AuditQuery {
149 pub actor_id: Option<Uuid>,
151 pub target_id: Option<Uuid>,
153 pub action: Option<String>,
155 pub since: Option<DateTime<Utc>>,
156 pub until: Option<DateTime<Utc>>,
157 pub limit: Option<i64>,
159 pub offset: Option<i64>,
161}
162
163const MAX_LIMIT: i64 = 500;
169const DEFAULT_LIMIT: i64 = 100;
170
171pub async fn list(State(state): State<AppState>, user: CurrentUser, Query(query): Query<AuditQuery>) -> Result<impl IntoResponse, ApiError> {
183 user.require_admin()?;
184
185 let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
186 let offset = query.offset.unwrap_or(0).max(0);
187
188 let entries: Vec<AuditRow> = sqlx::query_as(
189 "SELECT id, actor_id, actor_email, action, target_id, target_label, details, at
190 FROM audit_log
191 WHERE ($1::uuid IS NULL OR actor_id = $1)
192 AND ($2::uuid IS NULL OR target_id = $2)
193 AND ($3::text IS NULL OR action = $3)
194 AND ($4::timestamptz IS NULL OR at >= $4)
195 AND ($5::timestamptz IS NULL OR at <= $5)
196 ORDER BY at DESC, id DESC
197 LIMIT $6 OFFSET $7",
198 )
199 .bind(query.actor_id)
200 .bind(query.target_id)
201 .bind(query.action.as_deref())
202 .bind(query.since)
203 .bind(query.until)
204 .bind(limit)
205 .bind(offset)
206 .fetch_all(&state.pool)
207 .await?;
208
209 Ok(Json(entries))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn an_entry_reads_as_a_sentence() {
218 let actor = Uuid::new_v4();
219 let target = Uuid::new_v4();
220 let entry = Entry::new(action::AGENT_ISSUED)
221 .by(actor)
222 .by_email("boss@example.test")
223 .on(target)
224 .labelled("ivan-laptop");
225
226 assert_eq!(entry.action, "agent.issued");
227 assert_eq!(entry.actor_id, Some(actor));
228 assert_eq!(entry.target_id, Some(target));
229 assert_eq!(entry.target_label.as_deref(), Some("ivan-laptop"));
230 }
231
232 #[test]
233 fn an_entry_without_an_actor_is_allowed() {
234 let entry = Entry::new(action::USER_CREATED);
238 assert!(entry.actor_id.is_none() && entry.actor_email.is_none());
239 }
240}