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 WEBHOOK_TESTED: &str = "webhook.tested";
50 pub const DEMO_SEEDED: &str = "demo.seeded";
51}
52
53#[derive(Debug, Default)]
58pub struct Entry {
59 actor_id: Option<Uuid>,
60 actor_email: Option<String>,
61 action: String,
62 target_id: Option<Uuid>,
63 target_label: Option<String>,
64 details: Option<serde_json::Value>,
65}
66
67impl Entry {
68 pub fn new(action: &str) -> Self {
69 Self {
70 action: action.to_string(),
71 ..Default::default()
72 }
73 }
74
75 pub fn by(mut self, actor_id: Uuid) -> Self {
77 self.actor_id = Some(actor_id);
78 self
79 }
80
81 pub fn by_email(mut self, email: impl Into<String>) -> Self {
84 self.actor_email = Some(email.into());
85 self
86 }
87
88 pub fn on(mut self, target_id: Uuid) -> Self {
89 self.target_id = Some(target_id);
90 self
91 }
92
93 pub fn labelled(mut self, label: impl Into<String>) -> Self {
95 self.target_label = Some(label.into());
96 self
97 }
98
99 pub fn with(mut self, details: serde_json::Value) -> Self {
102 self.details = Some(details);
103 self
104 }
105
106 pub async fn record(self, pool: &PgPool) {
112 let result = sqlx::query(
113 "INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_label, details)
114 VALUES ($1, $2, $3, $4, $5, $6)",
115 )
116 .bind(self.actor_id)
117 .bind(self.actor_email.as_deref())
118 .bind(&self.action)
119 .bind(self.target_id)
120 .bind(self.target_label.as_deref())
121 .bind(self.details.as_ref())
122 .execute(pool)
123 .await;
124
125 if let Err(error) = result {
126 tracing::error!(%error, action = %self.action, "failed to write an audit entry");
130 }
131 }
132}
133
134#[derive(Debug, Serialize, sqlx::FromRow)]
136pub struct AuditRow {
137 pub id: i64,
138 pub actor_id: Option<Uuid>,
139 pub actor_email: Option<String>,
140 pub action: String,
141 pub target_id: Option<Uuid>,
142 pub target_label: Option<String>,
143 pub details: Option<serde_json::Value>,
144 pub at: DateTime<Utc>,
145}
146
147#[derive(Debug, Deserialize)]
149pub struct AuditQuery {
150 pub actor_id: Option<Uuid>,
152 pub target_id: Option<Uuid>,
154 pub action: Option<String>,
156 pub since: Option<DateTime<Utc>>,
157 pub until: Option<DateTime<Utc>>,
158 pub limit: Option<i64>,
160 pub offset: Option<i64>,
162}
163
164const MAX_LIMIT: i64 = 500;
170const DEFAULT_LIMIT: i64 = 100;
171
172pub async fn list(State(state): State<AppState>, user: CurrentUser, Query(query): Query<AuditQuery>) -> Result<impl IntoResponse, ApiError> {
184 user.require_admin()?;
185
186 let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
187 let offset = query.offset.unwrap_or(0).max(0);
188
189 let entries: Vec<AuditRow> = sqlx::query_as(
190 "SELECT id, actor_id, actor_email, action, target_id, target_label, details, at
191 FROM audit_log
192 WHERE ($1::uuid IS NULL OR actor_id = $1)
193 AND ($2::uuid IS NULL OR target_id = $2)
194 AND ($3::text IS NULL OR action = $3)
195 AND ($4::timestamptz IS NULL OR at >= $4)
196 AND ($5::timestamptz IS NULL OR at <= $5)
197 ORDER BY at DESC, id DESC
198 LIMIT $6 OFFSET $7",
199 )
200 .bind(query.actor_id)
201 .bind(query.target_id)
202 .bind(query.action.as_deref())
203 .bind(query.since)
204 .bind(query.until)
205 .bind(limit)
206 .bind(offset)
207 .fetch_all(&state.pool)
208 .await?;
209
210 Ok(Json(entries))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn an_entry_reads_as_a_sentence() {
219 let actor = Uuid::new_v4();
220 let target = Uuid::new_v4();
221 let entry = Entry::new(action::AGENT_ISSUED)
222 .by(actor)
223 .by_email("boss@example.test")
224 .on(target)
225 .labelled("ivan-laptop");
226
227 assert_eq!(entry.action, "agent.issued");
228 assert_eq!(entry.actor_id, Some(actor));
229 assert_eq!(entry.target_id, Some(target));
230 assert_eq!(entry.target_label.as_deref(), Some("ivan-laptop"));
231 }
232
233 #[test]
234 fn an_entry_without_an_actor_is_allowed() {
235 let entry = Entry::new(action::USER_CREATED);
239 assert!(entry.actor_id.is_none() && entry.actor_email.is_none());
240 }
241}