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