Skip to main content

allowthem_saas/manage/
logs.rs

1use axum::extract::{Extension, Query};
2use axum::response::Json;
3use serde::{Deserialize, Serialize};
4
5use allowthem_core::AllowThem;
6use allowthem_core::audit::AuditCursor;
7
8use super::{AdminKey, ListResponse, ManageError, ManageState};
9
10#[derive(Deserialize)]
11pub struct LogsQuery {
12    limit: Option<u32>,
13    cursor: Option<String>,
14}
15
16#[derive(Serialize)]
17pub struct AuditLogEntry {
18    pub id: String,
19    pub event_type: String,
20    pub user_id: Option<String>,
21    pub user_email: Option<String>,
22    pub target_id: Option<String>,
23    pub ip_address: Option<String>,
24    pub user_agent: Option<String>,
25    pub detail: Option<String>,
26    pub created_at: String,
27}
28
29pub async fn list(
30    Extension(ath): Extension<AllowThem>,
31    _admin: AdminKey,
32    Query(q): Query<LogsQuery>,
33) -> Result<Json<ListResponse<AuditLogEntry>>, ManageError> {
34    let limit = q.limit.unwrap_or(50).min(200);
35
36    let cursor: Option<AuditCursor> = q
37        .cursor
38        .as_deref()
39        .map(|s| {
40            AuditCursor::decode(s)
41                .ok_or_else(|| ManageError::InvalidRequest("invalid cursor".into()))
42        })
43        .transpose()?;
44
45    let mut entries = ath
46        .db()
47        .list_audit_paginated(limit + 1, cursor.as_ref())
48        .await
49        .map_err(|e| ManageError::Internal(e.to_string()))?;
50
51    let has_more = entries.len() > limit as usize;
52    if has_more {
53        entries.pop();
54    }
55
56    let next_cursor = if has_more {
57        entries.last().map(|e| AuditCursor::from_entry(e).encode())
58    } else {
59        None
60    };
61
62    let items = entries
63        .into_iter()
64        .map(|e| AuditLogEntry {
65            id: e.id.to_string(),
66            event_type: serde_json::to_value(&e.event_type)
67                .ok()
68                .and_then(|v| v.as_str().map(str::to_owned))
69                .unwrap_or_default(),
70            user_id: e.user_id.map(|u| u.to_string()),
71            user_email: e.user_email,
72            target_id: e.target_id,
73            ip_address: e.ip_address,
74            user_agent: e.user_agent,
75            detail: e.detail,
76            created_at: e.created_at.to_rfc3339(),
77        })
78        .collect();
79
80    Ok(Json(ListResponse { items, next_cursor }))
81}
82
83pub fn log_routes() -> axum::Router<ManageState> {
84    use axum::routing::get;
85    axum::Router::new().route("/", get(list))
86}
87
88#[cfg(test)]
89mod tests {
90    use std::path::PathBuf;
91    use std::sync::Arc;
92
93    use axum::Router;
94    use axum::body::{Body, to_bytes};
95    use axum::http::{Request, StatusCode};
96    use chrono::Utc;
97    use tower::ServiceExt;
98
99    use allowthem_core::audit::AuditEvent;
100    use allowthem_core::types::Email;
101    use allowthem_core::{AllowThem, AllowThemBuilder};
102
103    use super::*;
104    use crate::api_keys::{ApiKey, ApiKeyId, ApiKeyScope};
105    use crate::cache::HandleCache;
106    use crate::control_db::ControlDb;
107    use crate::manage::ManageState;
108    use crate::tenants::{TenantBuilderConfig, TenantId};
109    use uuid::Uuid;
110
111    async fn make_handle() -> AllowThem {
112        AllowThemBuilder::new("sqlite::memory:")
113            .cookie_secure(false)
114            .build()
115            .await
116            .unwrap()
117    }
118
119    async fn make_state() -> ManageState {
120        use std::str::FromStr;
121        let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
122            .unwrap()
123            .pragma("foreign_keys", "ON");
124        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
125        let ctrl = Arc::new(ControlDb::new(pool).await.unwrap());
126        ManageState::new(
127            ctrl,
128            HandleCache::new(1),
129            PathBuf::from("/tmp"),
130            Arc::new(TenantBuilderConfig {
131                mfa_key: [0u8; 32],
132                signing_key: [0u8; 32],
133                csrf_key: [0u8; 32],
134                base_domain: "test.example.com".into(),
135                is_production: false,
136                email_sender: None,
137                event_sink: None,
138                event_sink_factory: None,
139                mau_sink: None,
140                email_sender_factory: None,
141            }),
142            1000,
143            Arc::new(crate::dns::MockDnsResolver::new()),
144        )
145    }
146
147    fn make_admin_key() -> ApiKey {
148        ApiKey {
149            id: ApiKeyId::from_uuid(Uuid::nil()),
150            tenant_id: TenantId::from(Uuid::nil()),
151            name: "test".into(),
152            scope: vec![ApiKeyScope::Admin],
153            created_at: Utc::now(),
154            expires_at: None,
155            last_used_at: None,
156        }
157    }
158
159    async fn test_router(handle: AllowThem) -> Router {
160        let state = make_state().await;
161        Router::<ManageState>::new()
162            .merge(log_routes())
163            .layer(axum::Extension(handle))
164            .layer(axum::Extension(make_admin_key()))
165            .with_state(state)
166    }
167
168    async fn create_audit_entry(handle: &AllowThem, event: AuditEvent) {
169        let email = Email::new(format!("log{}@test.com", Uuid::new_v4())).unwrap();
170        let user = handle
171            .db()
172            .create_user(email, "pw123456", None, None)
173            .await
174            .unwrap();
175        handle
176            .db()
177            .log_audit(event, Some(&user.id), None, None, None, None)
178            .await
179            .unwrap();
180    }
181
182    #[tokio::test]
183    async fn list_logs_returns_empty() {
184        let h = make_handle().await;
185        let app = test_router(h).await;
186        let req = Request::get("/").body(Body::empty()).unwrap();
187        let resp = app.oneshot(req).await.unwrap();
188        assert_eq!(resp.status(), StatusCode::OK);
189        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
190        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
191        assert_eq!(json["items"].as_array().unwrap().len(), 0);
192    }
193
194    #[tokio::test]
195    async fn list_logs_cursor_returns_next() {
196        let h = make_handle().await;
197        for _ in 0..5u32 {
198            create_audit_entry(&h, AuditEvent::Login).await;
199        }
200        let app = test_router(h).await;
201        let req = Request::get("/?limit=3").body(Body::empty()).unwrap();
202        let resp = app.oneshot(req).await.unwrap();
203        assert_eq!(resp.status(), StatusCode::OK);
204        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
205        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
206        assert_eq!(json["items"].as_array().unwrap().len(), 3);
207        assert!(!json["next_cursor"].is_null());
208    }
209}