1use chrono::Utc;
2use rand::Rng;
3use tracing::instrument;
4use uuid::Uuid;
5
6use authx_core::{
7 crypto::sha256_hex,
8 error::{AuthError, Result},
9 events::{AuthEvent, EventBus},
10 models::{CreateSession, CreateUser, Session, UpdateUser, User},
11};
12use authx_storage::ports::{AuditLogRepository, OrgRepository, SessionRepository, UserRepository};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum BanStatus {
16 Banned,
17 Active,
18}
19
20pub struct AdminService<S> {
25 storage: S,
26 events: EventBus,
27 session_ttl_secs: i64,
28}
29
30impl<S> AdminService<S>
31where
32 S: UserRepository
33 + SessionRepository
34 + OrgRepository
35 + AuditLogRepository
36 + Clone
37 + Send
38 + Sync
39 + 'static,
40{
41 pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
42 Self {
43 storage,
44 events,
45 session_ttl_secs,
46 }
47 }
48
49 pub fn storage(&self) -> &S {
51 &self.storage
52 }
53
54 pub fn events(&self) -> &EventBus {
56 &self.events
57 }
58
59 #[instrument(skip(self))]
61 pub async fn list_users(&self, offset: u32, limit: u32) -> Result<Vec<User>> {
62 let users = UserRepository::list(&self.storage, offset, limit).await?;
63 tracing::debug!(offset, limit, count = users.len(), "admin: users listed");
64 Ok(users)
65 }
66
67 #[instrument(skip(self), fields(target = %user_id))]
69 pub async fn get_user(&self, user_id: Uuid) -> Result<User> {
70 UserRepository::find_by_id(&self.storage, user_id)
71 .await?
72 .ok_or(AuthError::UserNotFound)
73 }
74
75 #[instrument(skip(self), fields(acting_admin = %admin_id, email = %email))]
77 pub async fn create_user(&self, admin_id: Uuid, email: String) -> Result<User> {
78 let user = UserRepository::create(
79 &self.storage,
80 CreateUser {
81 email: email.clone(),
82 username: None,
83 metadata: None,
84 },
85 )
86 .await?;
87
88 AuditLogRepository::append(
89 &self.storage,
90 authx_core::models::CreateAuditLog {
91 user_id: Some(admin_id),
92 org_id: None,
93 action: "admin.create_user".into(),
94 resource_type: "user".into(),
95 resource_id: Some(user.id.to_string()),
96 ip_address: None,
97 metadata: Some(serde_json::json!({ "email": email })),
98 },
99 )
100 .await?;
101
102 self.events
103 .emit(AuthEvent::UserCreated { user: user.clone() });
104 tracing::info!(admin = %admin_id, user_id = %user.id, "admin: user created");
105 Ok(user)
106 }
107
108 #[instrument(skip(self), fields(acting_admin = %admin_id, org_id = %org_id, user_id = %user_id))]
110 pub async fn set_role(
111 &self,
112 admin_id: Uuid,
113 org_id: Uuid,
114 user_id: Uuid,
115 role_id: Uuid,
116 ) -> Result<authx_core::models::Membership> {
117 let membership =
118 OrgRepository::update_member_role(&self.storage, org_id, user_id, role_id).await?;
119
120 AuditLogRepository::append(
121 &self.storage,
122 authx_core::models::CreateAuditLog {
123 user_id: Some(admin_id),
124 org_id: Some(org_id),
125 action: "admin.set_role".into(),
126 resource_type: "membership".into(),
127 resource_id: Some(user_id.to_string()),
128 ip_address: None,
129 metadata: Some(serde_json::json!({ "role_id": role_id })),
130 },
131 )
132 .await?;
133
134 tracing::info!(admin = %admin_id, org_id = %org_id, user_id = %user_id, role_id = %role_id, "admin: role set");
135 Ok(membership)
136 }
137
138 #[instrument(skip(self), fields(target = %user_id, acting_admin = %admin_id))]
140 pub async fn ban_user(&self, admin_id: Uuid, user_id: Uuid, reason: &str) -> Result<()> {
141 UserRepository::update(
142 &self.storage,
143 user_id,
144 UpdateUser {
145 metadata: Some(serde_json::json!({ "banned": true, "ban_reason": reason })),
146 ..Default::default()
147 },
148 )
149 .await?;
150
151 SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
152
153 AuditLogRepository::append(
154 &self.storage,
155 authx_core::models::CreateAuditLog {
156 user_id: Some(admin_id),
157 org_id: None,
158 action: "admin.ban_user".into(),
159 resource_type: "user".into(),
160 resource_id: Some(user_id.to_string()),
161 ip_address: None,
162 metadata: Some(serde_json::json!({ "reason": reason })),
163 },
164 )
165 .await?;
166
167 tracing::info!(admin = %admin_id, target = %user_id, reason, "user banned");
168 Ok(())
169 }
170
171 #[instrument(skip(self), fields(target = %user_id, acting_admin = %admin_id))]
173 pub async fn unban_user(&self, admin_id: Uuid, user_id: Uuid) -> Result<()> {
174 UserRepository::update(
175 &self.storage,
176 user_id,
177 UpdateUser {
178 metadata: Some(serde_json::json!({ "banned": false })),
179 ..Default::default()
180 },
181 )
182 .await?;
183
184 AuditLogRepository::append(
185 &self.storage,
186 authx_core::models::CreateAuditLog {
187 user_id: Some(admin_id),
188 org_id: None,
189 action: "admin.unban_user".into(),
190 resource_type: "user".into(),
191 resource_id: Some(user_id.to_string()),
192 ip_address: None,
193 metadata: None,
194 },
195 )
196 .await?;
197
198 tracing::info!(admin = %admin_id, target = %user_id, "user unbanned");
199 Ok(())
200 }
201
202 pub async fn ban_status(&self, user_id: Uuid) -> Result<BanStatus> {
203 let user = UserRepository::find_by_id(&self.storage, user_id)
204 .await?
205 .ok_or(AuthError::UserNotFound)?;
206 let banned = user
207 .metadata
208 .get("banned")
209 .and_then(|v| v.as_bool())
210 .unwrap_or(false);
211 Ok(if banned {
212 BanStatus::Banned
213 } else {
214 BanStatus::Active
215 })
216 }
217
218 #[instrument(skip(self), fields(target = %target_id, acting_admin = %admin_id))]
220 pub async fn impersonate(
221 &self,
222 admin_id: Uuid,
223 target_id: Uuid,
224 admin_ip: &str,
225 ) -> Result<(Session, String)> {
226 let raw: [u8; 32] = rand::thread_rng().r#gen();
227 let raw_token = hex::encode(raw);
228 let token_hash = sha256_hex(raw_token.as_bytes());
229
230 let session = SessionRepository::create(
231 &self.storage,
232 CreateSession {
233 user_id: target_id,
234 token_hash,
235 device_info: serde_json::json!({ "impersonated_by": admin_id }),
236 ip_address: format!("impersonation:{admin_id}@{admin_ip}"),
237 org_id: None,
238 expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
239 },
240 )
241 .await?;
242
243 AuditLogRepository::append(
244 &self.storage,
245 authx_core::models::CreateAuditLog {
246 user_id: Some(admin_id),
247 org_id: None,
248 action: "admin.impersonate".into(),
249 resource_type: "session".into(),
250 resource_id: Some(session.id.to_string()),
251 ip_address: Some(admin_ip.to_owned()),
252 metadata: Some(serde_json::json!({ "target_user_id": target_id })),
253 },
254 )
255 .await?;
256
257 tracing::info!(admin = %admin_id, target = %target_id, session_id = %session.id, "impersonation session created");
258 Ok((session, raw_token))
259 }
260
261 pub async fn list_sessions(&self, user_id: Uuid) -> Result<Vec<Session>> {
262 SessionRepository::find_by_user(&self.storage, user_id).await
263 }
264
265 pub async fn revoke_all_sessions(&self, admin_id: Uuid, user_id: Uuid) -> Result<()> {
266 SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
267 self.events.emit(AuthEvent::SignOut {
268 user_id,
269 session_id: Uuid::nil(),
270 });
271 tracing::info!(admin = %admin_id, target = %user_id, "all sessions revoked by admin");
272 Ok(())
273 }
274}