allowthem_saas/manage/
logs.rs1use 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}