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}