Skip to main content

allowthem_saas/manage/
users.rs

1use std::collections::HashSet;
2
3use axum::extract::{Extension, Path, Query};
4use axum::http::StatusCode;
5use axum::response::Json;
6use serde::{Deserialize, Serialize};
7
8use allowthem_core::AllowThem;
9use allowthem_core::error::AuthError;
10use allowthem_core::types::{RoleId, UserId};
11use allowthem_core::users::UserCursor;
12
13use super::{AdminKey, ListResponse, ManageError, ManageState};
14
15#[derive(Serialize)]
16pub struct UserResponse {
17    pub id: String,
18    pub email: String,
19    pub username: Option<String>,
20    pub email_verified: bool,
21    pub is_active: bool,
22    pub created_at: String,
23    pub updated_at: String,
24}
25
26impl UserResponse {
27    fn from_user(u: &allowthem_core::types::User) -> Self {
28        Self {
29            id: u.id.to_string(),
30            email: u.email.as_str().to_owned(),
31            username: u.username.as_ref().map(|n| n.as_str().to_owned()),
32            email_verified: u.email_verified,
33            is_active: u.is_active,
34            created_at: u.created_at.to_rfc3339(),
35            updated_at: u.updated_at.to_rfc3339(),
36        }
37    }
38}
39
40#[derive(Deserialize)]
41pub struct ListUsersQuery {
42    limit: Option<u32>,
43    cursor: Option<String>,
44}
45
46#[derive(Deserialize)]
47pub struct PatchUserRequest {
48    is_active: Option<bool>,
49    role_ids: Option<Vec<String>>,
50}
51
52fn map_auth_error(e: AuthError) -> ManageError {
53    match e {
54        AuthError::NotFound => ManageError::NotFound,
55        AuthError::Conflict(_) => ManageError::Conflict,
56        _ => ManageError::Internal(e.to_string()),
57    }
58}
59
60fn parse_user_id(s: &str) -> Result<UserId, ManageError> {
61    s.parse().map_err(|_| ManageError::NotFound)
62}
63
64async fn list(
65    Extension(ath): Extension<AllowThem>,
66    _admin: AdminKey,
67    Query(q): Query<ListUsersQuery>,
68) -> Result<Json<ListResponse<UserResponse>>, ManageError> {
69    let limit = q.limit.unwrap_or(50).min(200);
70    let cursor: Option<UserCursor> = q
71        .cursor
72        .as_deref()
73        .map(|s| {
74            UserCursor::decode(s)
75                .ok_or_else(|| ManageError::InvalidRequest("invalid cursor".into()))
76        })
77        .transpose()?;
78
79    let mut entries = ath
80        .db()
81        .list_users_paginated(limit + 1, cursor.as_ref())
82        .await
83        .map_err(map_auth_error)?;
84
85    let has_more = entries.len() > limit as usize;
86    if has_more {
87        entries.pop();
88    }
89
90    let next_cursor = if has_more {
91        entries.last().map(|e| UserCursor::from_entry(e).encode())
92    } else {
93        None
94    };
95
96    let items = entries
97        .iter()
98        .map(|e| UserResponse {
99            id: e.id.to_string(),
100            email: e.email.as_str().to_owned(),
101            username: e.username.as_ref().map(|n| n.as_str().to_owned()),
102            email_verified: false,
103            is_active: e.is_active,
104            created_at: e.created_at.to_rfc3339(),
105            updated_at: e.created_at.to_rfc3339(),
106        })
107        .collect();
108
109    Ok(Json(ListResponse { items, next_cursor }))
110}
111
112async fn get_one(
113    Extension(ath): Extension<AllowThem>,
114    _admin: AdminKey,
115    Path(id): Path<String>,
116) -> Result<Json<UserResponse>, ManageError> {
117    let uid = parse_user_id(&id)?;
118    let user = ath.db().get_user(uid).await.map_err(map_auth_error)?;
119    Ok(Json(UserResponse::from_user(&user)))
120}
121
122async fn update(
123    Extension(ath): Extension<AllowThem>,
124    _admin: AdminKey,
125    Path(id): Path<String>,
126    Json(req): Json<PatchUserRequest>,
127) -> Result<Json<UserResponse>, ManageError> {
128    let uid = parse_user_id(&id)?;
129
130    if let Some(is_active) = req.is_active {
131        ath.db()
132            .update_user_active(uid, is_active)
133            .await
134            .map_err(map_auth_error)?;
135    }
136
137    if let Some(role_ids) = req.role_ids {
138        let requested: Vec<RoleId> = role_ids
139            .iter()
140            .map(|s| {
141                s.parse()
142                    .map_err(|_| ManageError::InvalidRequest(format!("invalid role_id: {s}")))
143            })
144            .collect::<Result<_, _>>()?;
145
146        let current = ath
147            .db()
148            .get_user_roles(&uid)
149            .await
150            .map_err(map_auth_error)?;
151        let current_ids: HashSet<RoleId> = current.into_iter().map(|r| r.id).collect();
152        let requested_ids: HashSet<RoleId> = requested.into_iter().collect();
153
154        for &id in requested_ids.difference(&current_ids) {
155            ath.db()
156                .assign_role(&uid, &id)
157                .await
158                .map_err(map_auth_error)?;
159        }
160        for &id in current_ids.difference(&requested_ids) {
161            ath.db()
162                .unassign_role(&uid, &id)
163                .await
164                .map_err(map_auth_error)?;
165        }
166    }
167
168    let user = ath.db().get_user(uid).await.map_err(map_auth_error)?;
169    Ok(Json(UserResponse::from_user(&user)))
170}
171
172async fn delete_user(
173    Extension(ath): Extension<AllowThem>,
174    _admin: AdminKey,
175    Path(id): Path<String>,
176) -> Result<StatusCode, ManageError> {
177    let uid = parse_user_id(&id)?;
178    ath.db().delete_user(uid).await.map_err(map_auth_error)?;
179    Ok(StatusCode::NO_CONTENT)
180}
181
182pub fn user_routes() -> axum::Router<ManageState> {
183    use axum::routing::get;
184    axum::Router::new()
185        .route("/", get(list))
186        .route("/{id}", get(get_one).patch(update).delete(delete_user))
187        .route("/{id}/sessions", get(super::sessions::list_for_user))
188}
189
190#[cfg(test)]
191mod tests {
192    use std::path::PathBuf;
193    use std::sync::Arc;
194
195    use axum::Router;
196    use axum::body::{Body, to_bytes};
197    use axum::http::{Request, StatusCode};
198    use chrono::Utc;
199    use tower::ServiceExt;
200    use uuid::Uuid;
201
202    use allowthem_core::types::Email;
203    use allowthem_core::{AllowThem, AllowThemBuilder};
204
205    use super::*;
206    use crate::api_keys::{ApiKey, ApiKeyId, ApiKeyScope};
207    use crate::cache::HandleCache;
208    use crate::control_db::ControlDb;
209    use crate::manage::ManageState;
210    use crate::tenants::{TenantBuilderConfig, TenantId};
211
212    async fn make_handle() -> AllowThem {
213        AllowThemBuilder::new("sqlite::memory:")
214            .cookie_secure(false)
215            .build()
216            .await
217            .unwrap()
218    }
219
220    async fn make_state() -> ManageState {
221        use std::str::FromStr;
222        let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
223            .unwrap()
224            .pragma("foreign_keys", "ON");
225        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
226        let ctrl = Arc::new(ControlDb::new(pool).await.unwrap());
227        ManageState::new(
228            ctrl,
229            HandleCache::new(1),
230            PathBuf::from("/tmp"),
231            Arc::new(TenantBuilderConfig {
232                mfa_key: [0u8; 32],
233                signing_key: [0u8; 32],
234                csrf_key: [0u8; 32],
235                base_domain: "test.example.com".into(),
236                is_production: false,
237                email_sender: None,
238                event_sink: None,
239                event_sink_factory: None,
240                mau_sink: None,
241                email_sender_factory: None,
242            }),
243            1000,
244            Arc::new(crate::dns::MockDnsResolver::new()),
245        )
246    }
247
248    fn make_admin_key() -> ApiKey {
249        ApiKey {
250            id: ApiKeyId::from_uuid(Uuid::nil()),
251            tenant_id: TenantId::from(Uuid::nil()),
252            name: "test".into(),
253            scope: vec![ApiKeyScope::Admin],
254            created_at: Utc::now(),
255            expires_at: None,
256            last_used_at: None,
257        }
258    }
259
260    async fn test_router(handle: AllowThem) -> Router {
261        let state = make_state().await;
262        Router::<ManageState>::new()
263            .merge(user_routes())
264            .layer(axum::Extension(handle))
265            .layer(axum::Extension(make_admin_key()))
266            .with_state(state)
267    }
268
269    async fn create_user(handle: &AllowThem, tag: u32) -> allowthem_core::types::User {
270        let email = Email::new(format!("user{tag}@test.com")).unwrap();
271        handle
272            .db()
273            .create_user(email, "pw123456", None, None)
274            .await
275            .unwrap()
276    }
277
278    #[tokio::test]
279    async fn list_users_returns_empty() {
280        let h = make_handle().await;
281        let app = test_router(h).await;
282        let req = Request::get("/").body(Body::empty()).unwrap();
283        let resp = app.oneshot(req).await.unwrap();
284        assert_eq!(resp.status(), StatusCode::OK);
285        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
286        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
287        assert_eq!(json["items"].as_array().unwrap().len(), 0);
288    }
289
290    #[tokio::test]
291    async fn list_users_cursor_returns_next() {
292        let h = make_handle().await;
293        for i in 0..5u32 {
294            create_user(&h, i).await;
295        }
296        let app = test_router(h).await;
297        let req = Request::get("/?limit=3").body(Body::empty()).unwrap();
298        let resp = app.oneshot(req).await.unwrap();
299        assert_eq!(resp.status(), StatusCode::OK);
300        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
301        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
302        assert_eq!(json["items"].as_array().unwrap().len(), 3);
303        assert!(!json["next_cursor"].is_null());
304    }
305
306    #[tokio::test]
307    async fn get_user_returns_data() {
308        let h = make_handle().await;
309        let user = create_user(&h, 1).await;
310        let app = test_router(h).await;
311        let req = Request::get(format!("/{}", user.id))
312            .body(Body::empty())
313            .unwrap();
314        let resp = app.oneshot(req).await.unwrap();
315        assert_eq!(resp.status(), StatusCode::OK);
316        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
317        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
318        assert_eq!(json["id"], user.id.to_string());
319    }
320
321    #[tokio::test]
322    async fn get_user_not_found_returns_404() {
323        let h = make_handle().await;
324        let app = test_router(h).await;
325        let req = Request::get(format!("/{}", Uuid::nil()))
326            .body(Body::empty())
327            .unwrap();
328        let resp = app.oneshot(req).await.unwrap();
329        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
330    }
331
332    #[tokio::test]
333    async fn patch_user_toggles_active() {
334        let h = make_handle().await;
335        let user = create_user(&h, 2).await;
336        let app = test_router(h).await;
337        let body = serde_json::json!({ "is_active": false }).to_string();
338        let req = Request::patch(format!("/{}", user.id))
339            .header("content-type", "application/json")
340            .body(Body::from(body))
341            .unwrap();
342        let resp = app.oneshot(req).await.unwrap();
343        assert_eq!(resp.status(), StatusCode::OK);
344        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
345        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
346        assert_eq!(json["is_active"], false);
347    }
348
349    #[tokio::test]
350    async fn patch_user_diff_roles() {
351        let h = make_handle().await;
352        let user = create_user(&h, 3).await;
353        let role = h
354            .db()
355            .create_role(&allowthem_core::types::RoleName::new("editor"), None)
356            .await
357            .unwrap();
358        let app = test_router(h).await;
359        let body = serde_json::json!({ "role_ids": [role.id.to_string()] }).to_string();
360        let req = Request::patch(format!("/{}", user.id))
361            .header("content-type", "application/json")
362            .body(Body::from(body))
363            .unwrap();
364        let resp = app.oneshot(req).await.unwrap();
365        assert_eq!(resp.status(), StatusCode::OK);
366    }
367
368    #[tokio::test]
369    async fn delete_user_returns_204() {
370        let h = make_handle().await;
371        let user = create_user(&h, 4).await;
372        let app = test_router(h).await;
373        let req = Request::delete(format!("/{}", user.id))
374            .body(Body::empty())
375            .unwrap();
376        let resp = app.oneshot(req).await.unwrap();
377        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
378    }
379
380    #[tokio::test]
381    async fn delete_user_not_found_returns_404() {
382        let h = make_handle().await;
383        let app = test_router(h).await;
384        let req = Request::delete(format!("/{}", Uuid::nil()))
385            .body(Body::empty())
386            .unwrap();
387        let resp = app.oneshot(req).await.unwrap();
388        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
389    }
390}