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