Skip to main content

auth/
admin.rs

1use crate::models::{AuthSessionRecord, AuthUser, AuthUserId};
2use crate::repositories::AuthUserRepository;
3use chrono::{DateTime, Utc};
4use platform_core::{AppError, AppResult, ErrorCode};
5use platform_module::{AdminActionSource, AdminDataSource, AdminListQuery, AdminPage};
6use serde_json::Value;
7use std::sync::Arc;
8
9const REVOKE_SESSION_ACTION: &str = "revoke_session";
10const DISABLE_USER_ACTION: &str = "disable_user";
11const ENABLE_USER_ACTION: &str = "enable_user";
12
13#[derive(Debug)]
14pub struct AuthAdminData {
15    repository: Arc<dyn AuthUserRepository>,
16}
17
18impl AuthAdminData {
19    #[must_use]
20    pub fn new(repository: Arc<dyn AuthUserRepository>) -> Self {
21        Self { repository }
22    }
23}
24
25#[async_trait::async_trait]
26impl AdminDataSource for AuthAdminData {
27    async fn list(&self, entity: &str, query: &AdminListQuery) -> AppResult<AdminPage> {
28        match entity {
29            "users" => {
30                let rows = self
31                    .repository
32                    .list(query.limit.saturating_add(1), query.cursor.as_deref())
33                    .await?;
34                let has_more = rows.len() as i64 > query.limit.max(0);
35                let take = rows.len().min(query.limit.max(0) as usize);
36                let page_rows = &rows[..take];
37                let next_cursor = if has_more {
38                    page_rows.last().map(|user| user.id.0.clone())
39                } else {
40                    None
41                };
42                Ok(AdminPage {
43                    records: page_rows.iter().map(user_to_value).collect(),
44                    next_cursor,
45                })
46            }
47            "sessions" => {
48                let rows = self
49                    .repository
50                    .list_sessions(query.limit.saturating_add(1), query.cursor.as_deref())
51                    .await?;
52                let has_more = rows.len() as i64 > query.limit.max(0);
53                let take = rows.len().min(query.limit.max(0) as usize);
54                let page_rows = &rows[..take];
55                let next_cursor = if has_more {
56                    page_rows.last().map(|session| session.id.clone())
57                } else {
58                    None
59                };
60                Ok(AdminPage {
61                    records: page_rows.iter().map(session_to_value).collect(),
62                    next_cursor,
63                })
64            }
65            other => Err(unknown_entity(other)),
66        }
67    }
68
69    async fn get(&self, entity: &str, id: &str) -> AppResult<Option<Value>> {
70        match entity {
71            "users" => Ok(self
72                .repository
73                .find_by_id(&AuthUserId(id.to_owned()))
74                .await?
75                .as_ref()
76                .map(user_to_value)),
77            "sessions" => Ok(self
78                .repository
79                .find_session_by_id(id)
80                .await?
81                .as_ref()
82                .map(session_to_value)),
83            other => Err(unknown_entity(other)),
84        }
85    }
86}
87
88#[async_trait::async_trait]
89impl AdminActionSource for AuthAdminData {
90    async fn invoke(&self, action: &str, input: Value) -> AppResult<Value> {
91        match action {
92            REVOKE_SESSION_ACTION => {
93                let session_id = input
94                    .get("session_id")
95                    .and_then(Value::as_str)
96                    .filter(|value| !value.is_empty())
97                    .ok_or_else(|| {
98                        AppError::new(ErrorCode::Validation, "session_id is required")
99                    })?;
100                let revoked = self
101                    .repository
102                    .revoke_session_by_id(session_id, Utc::now())
103                    .await?;
104                Ok(serde_json::json!({
105                    "session_id": session_id,
106                    "revoked": revoked,
107                }))
108            }
109            DISABLE_USER_ACTION => {
110                let user_id = action_user_id(&input)?;
111                let reason = optional_string(&input, "reason");
112                let disabled_until = optional_timestamp(&input, "disabled_until")?;
113                if disabled_until.is_some_and(|value| value <= Utc::now()) {
114                    return Err(AppError::new(
115                        ErrorCode::Validation,
116                        "disabled_until must be in the future",
117                    ));
118                }
119                let disabled = self
120                    .repository
121                    .set_user_disabled_at(
122                        &user_id,
123                        Some(Utc::now()),
124                        reason.as_deref(),
125                        disabled_until,
126                    )
127                    .await?;
128                Ok(serde_json::json!({
129                    "disabled": disabled,
130                    "disabled_until": disabled_until,
131                    "reason": reason,
132                    "user_id": user_id.0,
133                }))
134            }
135            ENABLE_USER_ACTION => {
136                let user_id = action_user_id(&input)?;
137                let enabled = self
138                    .repository
139                    .set_user_disabled_at(&user_id, None, None, None)
140                    .await?;
141                Ok(serde_json::json!({
142                    "enabled": enabled,
143                    "user_id": user_id.0,
144                }))
145            }
146            other => Err(unknown_action(other)),
147        }
148    }
149}
150
151fn action_user_id(input: &Value) -> AppResult<AuthUserId> {
152    input
153        .get("user_id")
154        .and_then(Value::as_str)
155        .filter(|value| !value.is_empty())
156        .map(|value| AuthUserId(value.to_owned()))
157        .ok_or_else(|| AppError::new(ErrorCode::Validation, "user_id is required"))
158}
159
160fn optional_string(input: &Value, name: &str) -> Option<String> {
161    input
162        .get(name)
163        .and_then(Value::as_str)
164        .map(str::trim)
165        .filter(|value| !value.is_empty())
166        .map(ToOwned::to_owned)
167}
168
169fn optional_timestamp(input: &Value, name: &str) -> AppResult<Option<DateTime<Utc>>> {
170    let Some(value) = optional_string(input, name) else {
171        return Ok(None);
172    };
173    DateTime::parse_from_rfc3339(&value)
174        .map(|value| Some(value.with_timezone(&Utc)))
175        .map_err(|_| AppError::new(ErrorCode::Validation, format!("{name} must be RFC3339")))
176}
177
178fn unknown_entity(entity: &str) -> AppError {
179    AppError::new(
180        ErrorCode::NotFound,
181        format!("unknown admin entity: {entity}"),
182    )
183}
184
185fn unknown_action(action: &str) -> AppError {
186    AppError::new(
187        ErrorCode::NotFound,
188        format!("unknown admin action: {action}"),
189    )
190}
191
192fn user_to_value(user: &AuthUser) -> Value {
193    serde_json::json!({
194        "id": user.id.0,
195        "is_anonymous": user.is_anonymous,
196        "created_at": user.created_at,
197        "disabled_at": user.disabled_at,
198        "disabled_reason": user.disabled_reason,
199        "disabled_until": user.disabled_until,
200    })
201}
202
203fn session_to_value(session: &AuthSessionRecord) -> Value {
204    serde_json::json!({
205        "id": session.id,
206        "user_id": session.user_id.0,
207        "device_id": session.device_id,
208        "client_ip": session.client_ip,
209        "user_agent": session.user_agent,
210        "created_at": session.created_at,
211        "expires_at": session.expires_at,
212        "revoked_at": session.revoked_at,
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use chrono::Utc;
220
221    #[test]
222    fn user_to_value_keys_match_schema_fields() {
223        let now = Utc::now();
224        let value = user_to_value(&AuthUser {
225            id: AuthUserId("usr_1".to_owned()),
226            is_anonymous: false,
227            created_at: now,
228            disabled_at: None,
229            disabled_reason: None,
230            disabled_until: None,
231        });
232        let object = value.as_object().expect("object");
233        let mut keys = object.keys().collect::<Vec<_>>();
234        keys.sort();
235        assert_eq!(
236            keys,
237            vec![
238                "created_at",
239                "disabled_at",
240                "disabled_reason",
241                "disabled_until",
242                "id",
243                "is_anonymous"
244            ]
245        );
246    }
247
248    #[test]
249    fn session_to_value_keys_match_schema_fields() {
250        let now = Utc::now();
251        let value = session_to_value(&AuthSessionRecord {
252            id: "sess_1".to_owned(),
253            user_id: AuthUserId("usr_1".to_owned()),
254            device_id: Some("device_1".to_owned()),
255            client_ip: Some("203.0.113.7".to_owned()),
256            user_agent: Some("LensoTest/1.0".to_owned()),
257            created_at: now,
258            expires_at: now,
259            revoked_at: None,
260        });
261        let object = value.as_object().expect("object");
262        let mut keys = object.keys().collect::<Vec<_>>();
263        keys.sort();
264        assert_eq!(
265            keys,
266            vec![
267                "client_ip",
268                "created_at",
269                "device_id",
270                "expires_at",
271                "id",
272                "revoked_at",
273                "user_agent",
274                "user_id"
275            ]
276        );
277    }
278}