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::{AccessToken, 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
107    fn new_test_entry(event_type: EventKind, run_id: Option<Uuid>) -> NewAuditLogEntry {
108        NewAuditLogEntry {
109            event_type,
110            payload: json!({}),
111            run_id,
112            step_id: None,
113            user_id: None,
114        }
115    }
116
117    fn test_state() -> AppState {
118        let store = Arc::new(InMemoryStore::new());
119        let provider = Arc::new(ClaudeCodeProvider::new());
120        let engine = Arc::new(Engine::new(store.clone(), provider));
121        let jwt_config = Arc::new(JwtConfig {
122            secret: "test-secret".to_string(),
123            access_token_ttl_secs: 900,
124            refresh_token_ttl_secs: 604800,
125            cookie_domain: None,
126            cookie_secure: false,
127        });
128        let (event_sender, _) = broadcast::channel::<Event>(1);
129        AppState::new(
130            store,
131            engine,
132            jwt_config,
133            "test-worker-token".to_string(),
134            event_sender,
135        )
136    }
137
138    fn make_admin_auth_header(state: &AppState) -> String {
139        let user_id = Uuid::now_v7();
140        let token = AccessToken::for_user(user_id, "admin", true, &state.jwt_config).unwrap();
141        format!("Bearer {}", token.0)
142    }
143
144    fn make_user_auth_header(state: &AppState) -> String {
145        let user_id = Uuid::now_v7();
146        let token = AccessToken::for_user(user_id, "user", false, &state.jwt_config).unwrap();
147        format!("Bearer {}", token.0)
148    }
149
150    #[tokio::test]
151    async fn empty_list() {
152        let state = test_state();
153        let auth_header = make_admin_auth_header(&state);
154        let app = Router::new()
155            .route("/", get(list_audit_logs))
156            .with_state(state);
157
158        let req = Request::builder()
159            .uri("/")
160            .header("authorization", auth_header)
161            .body(Body::empty())
162            .unwrap();
163
164        let resp = app.oneshot(req).await.unwrap();
165        assert_eq!(resp.status(), StatusCode::OK);
166
167        let body = resp.into_body().collect().await.unwrap().to_bytes();
168        let json_val: JsonValue = from_slice(&body).unwrap();
169        assert_eq!(json_val["data"].as_array().unwrap().len(), 0);
170        assert_eq!(json_val["meta"]["total"], 0);
171    }
172
173    #[tokio::test]
174    async fn non_admin_gets_403() {
175        let state = test_state();
176        let auth_header = make_user_auth_header(&state);
177        let app = Router::new()
178            .route("/", get(list_audit_logs))
179            .with_state(state);
180
181        let req = Request::builder()
182            .uri("/")
183            .header("authorization", auth_header)
184            .body(Body::empty())
185            .unwrap();
186
187        let resp = app.oneshot(req).await.unwrap();
188        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
189    }
190
191    #[tokio::test]
192    async fn returns_entries_with_pagination() {
193        let state = test_state();
194        let auth_header = make_admin_auth_header(&state);
195
196        let kinds = [
197            EventKind::RunCreated,
198            EventKind::RunFailed,
199            EventKind::StepCompleted,
200            EventKind::StepFailed,
201            EventKind::UserSignedIn,
202        ];
203        for kind in kinds {
204            state
205                .store
206                .append_audit_log(new_test_entry(kind, None))
207                .await
208                .unwrap();
209        }
210
211        let app = Router::new()
212            .route("/", get(list_audit_logs))
213            .with_state(state);
214
215        let req = Request::builder()
216            .uri("/?page=1&per_page=2")
217            .header("authorization", auth_header)
218            .body(Body::empty())
219            .unwrap();
220
221        let resp = app.oneshot(req).await.unwrap();
222        assert_eq!(resp.status(), StatusCode::OK);
223
224        let body = resp.into_body().collect().await.unwrap().to_bytes();
225        let json_val: JsonValue = from_slice(&body).unwrap();
226        assert_eq!(json_val["data"].as_array().unwrap().len(), 2);
227        assert_eq!(json_val["meta"]["total"], 5);
228        assert_eq!(json_val["meta"]["page"], 1);
229        assert_eq!(json_val["meta"]["per_page"], 2);
230    }
231
232    #[tokio::test]
233    async fn filters_by_event_type() {
234        let state = test_state();
235        let auth_header = make_admin_auth_header(&state);
236
237        state
238            .store
239            .append_audit_log(new_test_entry(EventKind::RunCreated, None))
240            .await
241            .unwrap();
242        state
243            .store
244            .append_audit_log(new_test_entry(EventKind::RunFailed, None))
245            .await
246            .unwrap();
247
248        let app = Router::new()
249            .route("/", get(list_audit_logs))
250            .with_state(state);
251
252        let req = Request::builder()
253            .uri("/?event_type=run_created")
254            .header("authorization", auth_header)
255            .body(Body::empty())
256            .unwrap();
257
258        let resp = app.oneshot(req).await.unwrap();
259        let body = resp.into_body().collect().await.unwrap().to_bytes();
260        let json_val: JsonValue = from_slice(&body).unwrap();
261        assert_eq!(json_val["data"].as_array().unwrap().len(), 1);
262        assert_eq!(json_val["data"][0]["event_type"], "run_created");
263    }
264
265    #[tokio::test]
266    async fn filters_by_run_id() {
267        let state = test_state();
268        let auth_header = make_admin_auth_header(&state);
269        let target_run = Uuid::now_v7();
270
271        state
272            .store
273            .append_audit_log(new_test_entry(EventKind::RunCreated, Some(target_run)))
274            .await
275            .unwrap();
276        state
277            .store
278            .append_audit_log(new_test_entry(EventKind::RunCreated, Some(Uuid::now_v7())))
279            .await
280            .unwrap();
281
282        let app = Router::new()
283            .route("/", get(list_audit_logs))
284            .with_state(state);
285
286        let req = Request::builder()
287            .uri(format!("/?run_id={target_run}"))
288            .header("authorization", auth_header)
289            .body(Body::empty())
290            .unwrap();
291
292        let resp = app.oneshot(req).await.unwrap();
293        let body = resp.into_body().collect().await.unwrap().to_bytes();
294        let json_val: JsonValue = from_slice(&body).unwrap();
295        assert_eq!(json_val["data"].as_array().unwrap().len(), 1);
296    }
297
298    #[tokio::test]
299    async fn per_page_capped_at_100() {
300        let state = test_state();
301        let auth_header = make_admin_auth_header(&state);
302        let app = Router::new()
303            .route("/", get(list_audit_logs))
304            .with_state(state);
305
306        let req = Request::builder()
307            .uri("/?per_page=500")
308            .header("authorization", auth_header)
309            .body(Body::empty())
310            .unwrap();
311
312        let resp = app.oneshot(req).await.unwrap();
313        let body = resp.into_body().collect().await.unwrap().to_bytes();
314        let json_val: JsonValue = from_slice(&body).unwrap();
315        assert_eq!(json_val["meta"]["per_page"], 100);
316    }
317}