kipuka/routes/admin/
audit.rs1use std::sync::Arc;
13
14use axum::Json;
15use axum::extract::{Query, State};
16use axum::http::StatusCode;
17use axum::response::{IntoResponse, Response};
18use serde::{Deserialize, Serialize};
19
20use super::AdminAuth;
21use crate::state::AppState;
22
23#[derive(Deserialize)]
25pub struct ListAuditQuery {
26 pub event_type: Option<String>,
28
29 pub actor: Option<String>,
31
32 #[serde(default = "default_limit")]
34 pub limit: u32,
35
36 #[serde(default)]
38 pub offset: u32,
39}
40
41fn default_limit() -> u32 {
42 50
43}
44
45#[derive(Serialize, sqlx::FromRow)]
47pub struct AuditEventSummary {
48 pub id: i64,
50
51 pub timestamp: String,
53
54 pub event_type: String,
56
57 pub actor: Option<String>,
59
60 pub target: Option<String>,
62
63 pub detail_json: Option<String>,
65
66 pub source_ip: Option<String>,
68}
69
70pub async fn list_audit_events(
76 admin: AdminAuth,
77 Query(query): Query<ListAuditQuery>,
78 State(state): State<Arc<AppState>>,
79) -> Response {
80 tracing::debug!(
82 identity = %admin.identity,
83 event_type = ?query.event_type,
84 actor = ?query.actor,
85 limit = query.limit,
86 offset = query.offset,
87 "listing audit events"
88 );
89
90 let limit = query.limit.min(1000);
91
92 let events = match list_audit_from_db(
93 &state.db_ro,
94 query.event_type.as_deref(),
95 query.actor.as_deref(),
96 limit,
97 query.offset,
98 )
99 .await
100 {
101 Ok(e) => e,
102 Err(e) => {
103 tracing::error!(error = %e, "failed to list audit events from database");
104 return (
105 StatusCode::INTERNAL_SERVER_ERROR,
106 Json(serde_json::json!({
107 "error": "database_error",
108 "detail": "failed to query audit trail"
109 })),
110 )
111 .into_response();
112 }
113 };
114
115 state
118 .record_audit_event_with_actor(
119 "admin_audit_review",
120 &admin.identity,
121 &format!(
122 "count={}, filter_event_type={:?}, filter_actor={:?}",
123 events.len(),
124 query.event_type,
125 query.actor
126 ),
127 )
128 .await;
129
130 (StatusCode::OK, Json(events)).into_response()
131}
132
133async fn list_audit_from_db(
135 db: &sqlx::AnyPool,
136 event_type: Option<&str>,
137 actor: Option<&str>,
138 limit: u32,
139 offset: u32,
140) -> Result<Vec<AuditEventSummary>, String> {
141 let mut conditions = Vec::new();
144 if event_type.is_some() {
145 conditions.push("event_type = ?");
146 }
147 if actor.is_some() {
148 conditions.push("actor = ?");
149 }
150
151 let where_clause = if conditions.is_empty() {
152 String::new()
153 } else {
154 format!("WHERE {}", conditions.join(" AND "))
155 };
156
157 let sql = crate::db::pg_sql_dynamic(format!(
158 "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip \
159 FROM audit_events {where_clause} \
160 ORDER BY id DESC \
161 LIMIT ? OFFSET ?"
162 ));
163
164 let mut q = sqlx::query_as::<_, AuditEventSummary>(sqlx::AssertSqlSafe(sql));
165 if let Some(et) = event_type {
166 q = q.bind(et.to_string());
167 }
168 if let Some(ac) = actor {
169 q = q.bind(ac.to_string());
170 }
171 q = q.bind(limit as i64).bind(offset as i64);
172
173 q.fetch_all(db)
174 .await
175 .map_err(|e| format!("audit query failed: {e}"))
176}