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