Skip to main content

authx_plugins/admin/
service.rs

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
20/// Admin service — privileged operations.
21///
22/// Callers are responsible for verifying that the *acting* identity has admin
23/// privileges before calling any method.
24pub 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    /// Internal accessor used by the dashboard for advanced admin operations.
50    pub fn storage(&self) -> &S {
51        &self.storage
52    }
53
54    /// Access the event bus for emitting audit events.
55    pub fn events(&self) -> &EventBus {
56        &self.events
57    }
58
59    /// Paginated list of all users ordered by `created_at`.
60    #[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    /// Look up any user by id.
68    #[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    /// Create a user directly (admin provisioning). Does not require an email-password credential.
76    #[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    /// Assign a role to an org member.
109    #[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    /// Soft-ban a user by marking metadata `{"banned": true}`.
139    #[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    /// Lift a ban.
172    #[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    /// Create an impersonation session for `target_user_id`.
219    #[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}