Skip to main content

ironflow_api/routes/
audit_logs.rs

1//! `GET /api/v1/audit-logs` -- List audit log entries (admin only).
2
3use axum::extract::{Query, State};
4use axum::response::IntoResponse;
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7use uuid::Uuid;
8
9use ironflow_auth::extractor::Authenticated;
10#[cfg(feature = "openapi")]
11use ironflow_store::entities::AuditLogEntry;
12use ironflow_store::entities::{AuditLogFilter, EventKind};
13
14use crate::error::ApiError;
15use crate::response::ok_paged;
16use crate::state::AppState;
17
18/// Query parameters for listing audit log entries.
19#[derive(Debug, Deserialize)]
20#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
21pub struct ListAuditLogsQuery {
22    /// Filter by event type (e.g. `run_status_changed`).
23    pub event_type: Option<EventKind>,
24    /// Filter by run ID.
25    pub run_id: Option<Uuid>,
26    /// Filter entries created at or after this timestamp.
27    pub from: Option<DateTime<Utc>>,
28    /// Filter entries created at or before this timestamp.
29    pub to: Option<DateTime<Utc>>,
30    /// Page number (1-based, default: 1).
31    pub page: Option<u32>,
32    /// Items per page (default: 50, max: 100).
33    pub per_page: Option<u32>,
34}
35
36/// List audit log entries with optional filtering and pagination.
37///
38/// Admin-only. Returns a paginated list of persisted domain events
39/// for compliance review and post-mortem debugging.
40#[cfg_attr(
41    feature = "openapi",
42    utoipa::path(
43        get,
44        path = "/api/v1/audit-logs",
45        tags = ["audit"],
46        params(ListAuditLogsQuery),
47        responses(
48            (status = 200, description = "List of audit log entries with pagination", body = Vec<AuditLogEntry>),
49            (status = 401, description = "Unauthorized"),
50            (status = 403, description = "Forbidden - admin only")
51        ),
52        security(("Bearer" = []))
53    )
54)]
55pub async fn list_audit_logs(
56    auth: Authenticated,
57    State(state): State<AppState>,
58    Query(params): Query<ListAuditLogsQuery>,
59) -> Result<impl IntoResponse, ApiError> {
60    if !auth.is_admin() {
61        return Err(ApiError::Forbidden);
62    }
63
64    let page = params.page.unwrap_or(1).max(1);
65    let per_page = params.per_page.unwrap_or(50).min(100);
66
67    let filter = AuditLogFilter {
68        event_type: params.event_type,
69        run_id: params.run_id,
70        from: params.from,
71        to: params.to,
72    };
73
74    let page_result = state.store.list_audit_logs(filter, page, per_page).await?;
75
76    Ok(ok_paged(
77        page_result.items,
78        page,
79        per_page,
80        page_result.total,
81    ))
82}
83
84#[cfg(test)]
85mod tests {
86    use std::sync::Arc;
87
88    use axum::Router;
89    use axum::body::Body;
90    use axum::http::{Request, StatusCode};
91    use axum::routing::get;
92    use http_body_util::BodyExt;
93    use serde_json::{Value as JsonValue, from_slice, json};
94    use tokio::sync::broadcast;
95    use tower::ServiceExt;
96    use uuid::Uuid;
97
98    use ironflow_auth::jwt::JwtConfig;
99    use ironflow_core::providers::claude::ClaudeCodeProvider;
100    use ironflow_engine::engine::Engine;
101    use ironflow_engine::notify::Event;
102    use ironflow_store::entities::{EventKind, NewAuditLogEntry};
103    use ironflow_store::memory::InMemoryStore;
104
105    use super::*;
106    use crate::routes::test_helpers::create_user_auth_header;
107
108    fn new_test_entry(event_type: EventKind, run_id: Option<Uuid>) -> NewAuditLogEntry {
109        NewAuditLogEntry {
110            event_type,
111            payload: json!({}),
112            run_id,
113            step_id: None,
114            user_id: None,
115        }
116    }
117
118    fn test_state() -> AppState {
119        let store = Arc::new(InMemoryStore::new());
120        let provider = Arc::new(ClaudeCodeProvider::new());
121        let engine = Arc::new(Engine::new(store.clone(), provider));
122        let jwt_config = Arc::new(JwtConfig {
123            secret: "test-secret".to_string(),
124            access_token_ttl_secs: 900,
125            refresh_token_ttl_secs: 604800,
126            cookie_domain: None,
127            cookie_secure: false,
128        });
129        let (event_sender, _) = broadcast::channel::<Event>(1);
130        AppState::new(
131            store,
132            engine,
133            jwt_config,
134            "test-worker-token".to_string(),
135            event_sender,
136        )
137    }
138
139    #[tokio::test]
140    async fn empty_list() {
141        let state = test_state();
142        let auth_header = create_user_auth_header(&state, "admin", true).await;
143        let app = Router::new()
144            .route("/", get(list_audit_logs))
145            .with_state(state);
146
147        let req = Request::builder()
148            .uri("/")
149            .header("authorization", auth_header)
150            .body(Body::empty())
151            .unwrap();
152
153        let resp = app.oneshot(req).await.unwrap();
154        assert_eq!(resp.status(), StatusCode::OK);
155
156        let body = resp.into_body().collect().await.unwrap().to_bytes();
157        let json_val: JsonValue = from_slice(&body).unwrap();
158        assert_eq!(json_val["data"].as_array().unwrap().len(), 0);
159        assert_eq!(json_val["meta"]["total"], 0);
160    }
161
162    #[tokio::test]
163    async fn non_admin_gets_403() {
164        let state = test_state();
165        let auth_header = create_user_auth_header(&state, "user", false).await;
166        let app = Router::new()
167            .route("/", get(list_audit_logs))
168            .with_state(state);
169
170        let req = Request::builder()
171            .uri("/")
172            .header("authorization", auth_header)
173            .body(Body::empty())
174            .unwrap();
175
176        let resp = app.oneshot(req).await.unwrap();
177        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
178    }
179
180    #[tokio::test]
181    async fn returns_entries_with_pagination() {
182        let state = test_state();
183        let auth_header = create_user_auth_header(&state, "admin", true).await;
184
185        let kinds = [
186            EventKind::RunCreated,
187            EventKind::RunFailed,
188            EventKind::StepCompleted,
189            EventKind::StepFailed,
190            EventKind::UserSignedIn,
191        ];
192        for kind in kinds {
193            state
194                .store
195                .append_audit_log(new_test_entry(kind, None))
196                .await
197                .unwrap();
198        }
199
200        let app = Router::new()
201            .route("/", get(list_audit_logs))
202            .with_state(state);
203
204        let req = Request::builder()
205            .uri("/?page=1&per_page=2")
206            .header("authorization", auth_header)
207            .body(Body::empty())
208            .unwrap();
209
210        let resp = app.oneshot(req).await.unwrap();
211        assert_eq!(resp.status(), StatusCode::OK);
212
213        let body = resp.into_body().collect().await.unwrap().to_bytes();
214        let json_val: JsonValue = from_slice(&body).unwrap();
215        assert_eq!(json_val["data"].as_array().unwrap().len(), 2);
216        assert_eq!(json_val["meta"]["total"], 5);
217        assert_eq!(json_val["meta"]["page"], 1);
218        assert_eq!(json_val["meta"]["per_page"], 2);
219    }
220
221    #[tokio::test]
222    async fn filters_by_event_type() {
223        let state = test_state();
224        let auth_header = create_user_auth_header(&state, "admin", true).await;
225
226        state
227            .store
228            .append_audit_log(new_test_entry(EventKind::RunCreated, None))
229            .await
230            .unwrap();
231        state
232            .store
233            .append_audit_log(new_test_entry(EventKind::RunFailed, None))
234            .await
235            .unwrap();
236
237        let app = Router::new()
238            .route("/", get(list_audit_logs))
239            .with_state(state);
240
241        let req = Request::builder()
242            .uri("/?event_type=run_created")
243            .header("authorization", auth_header)
244            .body(Body::empty())
245            .unwrap();
246
247        let resp = app.oneshot(req).await.unwrap();
248        let body = resp.into_body().collect().await.unwrap().to_bytes();
249        let json_val: JsonValue = from_slice(&body).unwrap();
250        assert_eq!(json_val["data"].as_array().unwrap().len(), 1);
251        assert_eq!(json_val["data"][0]["event_type"], "run_created");
252    }
253
254    #[tokio::test]
255    async fn filters_by_run_id() {
256        let state = test_state();
257        let auth_header = create_user_auth_header(&state, "admin", true).await;
258        let target_run = Uuid::now_v7();
259
260        state
261            .store
262            .append_audit_log(new_test_entry(EventKind::RunCreated, Some(target_run)))
263            .await
264            .unwrap();
265        state
266            .store
267            .append_audit_log(new_test_entry(EventKind::RunCreated, Some(Uuid::now_v7())))
268            .await
269            .unwrap();
270
271        let app = Router::new()
272            .route("/", get(list_audit_logs))
273            .with_state(state);
274
275        let req = Request::builder()
276            .uri(format!("/?run_id={target_run}"))
277            .header("authorization", auth_header)
278            .body(Body::empty())
279            .unwrap();
280
281        let resp = app.oneshot(req).await.unwrap();
282        let body = resp.into_body().collect().await.unwrap().to_bytes();
283        let json_val: JsonValue = from_slice(&body).unwrap();
284        assert_eq!(json_val["data"].as_array().unwrap().len(), 1);
285    }
286
287    #[tokio::test]
288    async fn per_page_capped_at_100() {
289        let state = test_state();
290        let auth_header = create_user_auth_header(&state, "admin", true).await;
291        let app = Router::new()
292            .route("/", get(list_audit_logs))
293            .with_state(state);
294
295        let req = Request::builder()
296            .uri("/?per_page=500")
297            .header("authorization", auth_header)
298            .body(Body::empty())
299            .unwrap();
300
301        let resp = app.oneshot(req).await.unwrap();
302        let body = resp.into_body().collect().await.unwrap().to_bytes();
303        let json_val: JsonValue = from_slice(&body).unwrap();
304        assert_eq!(json_val["meta"]["per_page"], 100);
305    }
306}