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    /// Paginated list of all users ordered by `created_at`.
50    #[instrument(skip(self))]
51    pub async fn list_users(&self, offset: u32, limit: u32) -> Result<Vec<User>> {
52        let users = UserRepository::list(&self.storage, offset, limit).await?;
53        tracing::debug!(offset, limit, count = users.len(), "admin: users listed");
54        Ok(users)
55    }
56
57    /// Look up any user by id.
58    #[instrument(skip(self), fields(target = %user_id))]
59    pub async fn get_user(&self, user_id: Uuid) -> Result<User> {
60        UserRepository::find_by_id(&self.storage, user_id)
61            .await?
62            .ok_or(AuthError::UserNotFound)
63    }
64
65    /// Create a user directly (admin provisioning). Does not require an email-password credential.
66    #[instrument(skip(self), fields(acting_admin = %admin_id, email = %email))]
67    pub async fn create_user(&self, admin_id: Uuid, email: String) -> Result<User> {
68        let user = UserRepository::create(
69            &self.storage,
70            CreateUser {
71                email: email.clone(),
72                username: None,
73                metadata: None,
74            },
75        )
76        .await?;
77
78        AuditLogRepository::append(
79            &self.storage,
80            authx_core::models::CreateAuditLog {
81                user_id: Some(admin_id),
82                org_id: None,
83                action: "admin.create_user".into(),
84                resource_type: "user".into(),
85                resource_id: Some(user.id.to_string()),
86                ip_address: None,
87                metadata: Some(serde_json::json!({ "email": email })),
88            },
89        )
90        .await?;
91
92        self.events
93            .emit(AuthEvent::UserCreated { user: user.clone() });
94        tracing::info!(admin = %admin_id, user_id = %user.id, "admin: user created");
95        Ok(user)
96    }
97
98    /// Assign a role to an org member.
99    #[instrument(skip(self), fields(acting_admin = %admin_id, org_id = %org_id, user_id = %user_id))]
100    pub async fn set_role(
101        &self,
102        admin_id: Uuid,
103        org_id: Uuid,
104        user_id: Uuid,
105        role_id: Uuid,
106    ) -> Result<authx_core::models::Membership> {
107        let membership =
108            OrgRepository::update_member_role(&self.storage, org_id, user_id, role_id).await?;
109
110        AuditLogRepository::append(
111            &self.storage,
112            authx_core::models::CreateAuditLog {
113                user_id: Some(admin_id),
114                org_id: Some(org_id),
115                action: "admin.set_role".into(),
116                resource_type: "membership".into(),
117                resource_id: Some(user_id.to_string()),
118                ip_address: None,
119                metadata: Some(serde_json::json!({ "role_id": role_id })),
120            },
121        )
122        .await?;
123
124        tracing::info!(admin = %admin_id, org_id = %org_id, user_id = %user_id, role_id = %role_id, "admin: role set");
125        Ok(membership)
126    }
127
128    /// Soft-ban a user by marking metadata `{"banned": true}`.
129    #[instrument(skip(self), fields(target = %user_id, acting_admin = %admin_id))]
130    pub async fn ban_user(&self, admin_id: Uuid, user_id: Uuid, reason: &str) -> Result<()> {
131        UserRepository::update(
132            &self.storage,
133            user_id,
134            UpdateUser {
135                metadata: Some(serde_json::json!({ "banned": true, "ban_reason": reason })),
136                ..Default::default()
137            },
138        )
139        .await?;
140
141        SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
142
143        AuditLogRepository::append(
144            &self.storage,
145            authx_core::models::CreateAuditLog {
146                user_id: Some(admin_id),
147                org_id: None,
148                action: "admin.ban_user".into(),
149                resource_type: "user".into(),
150                resource_id: Some(user_id.to_string()),
151                ip_address: None,
152                metadata: Some(serde_json::json!({ "reason": reason })),
153            },
154        )
155        .await?;
156
157        tracing::info!(admin = %admin_id, target = %user_id, reason, "user banned");
158        Ok(())
159    }
160
161    /// Lift a ban.
162    #[instrument(skip(self), fields(target = %user_id, acting_admin = %admin_id))]
163    pub async fn unban_user(&self, admin_id: Uuid, user_id: Uuid) -> Result<()> {
164        UserRepository::update(
165            &self.storage,
166            user_id,
167            UpdateUser {
168                metadata: Some(serde_json::json!({ "banned": false })),
169                ..Default::default()
170            },
171        )
172        .await?;
173
174        AuditLogRepository::append(
175            &self.storage,
176            authx_core::models::CreateAuditLog {
177                user_id: Some(admin_id),
178                org_id: None,
179                action: "admin.unban_user".into(),
180                resource_type: "user".into(),
181                resource_id: Some(user_id.to_string()),
182                ip_address: None,
183                metadata: None,
184            },
185        )
186        .await?;
187
188        tracing::info!(admin = %admin_id, target = %user_id, "user unbanned");
189        Ok(())
190    }
191
192    pub async fn ban_status(&self, user_id: Uuid) -> Result<BanStatus> {
193        let user = UserRepository::find_by_id(&self.storage, user_id)
194            .await?
195            .ok_or(AuthError::UserNotFound)?;
196        let banned = user
197            .metadata
198            .get("banned")
199            .and_then(|v| v.as_bool())
200            .unwrap_or(false);
201        Ok(if banned {
202            BanStatus::Banned
203        } else {
204            BanStatus::Active
205        })
206    }
207
208    /// Create an impersonation session for `target_user_id`.
209    #[instrument(skip(self), fields(target = %target_id, acting_admin = %admin_id))]
210    pub async fn impersonate(
211        &self,
212        admin_id: Uuid,
213        target_id: Uuid,
214        admin_ip: &str,
215    ) -> Result<(Session, String)> {
216        let raw: [u8; 32] = rand::thread_rng().gen();
217        let raw_token = hex::encode(raw);
218        let token_hash = sha256_hex(raw_token.as_bytes());
219
220        let session = SessionRepository::create(
221            &self.storage,
222            CreateSession {
223                user_id: target_id,
224                token_hash,
225                device_info: serde_json::json!({ "impersonated_by": admin_id }),
226                ip_address: format!("impersonation:{admin_id}@{admin_ip}"),
227                org_id: None,
228                expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
229            },
230        )
231        .await?;
232
233        AuditLogRepository::append(
234            &self.storage,
235            authx_core::models::CreateAuditLog {
236                user_id: Some(admin_id),
237                org_id: None,
238                action: "admin.impersonate".into(),
239                resource_type: "session".into(),
240                resource_id: Some(session.id.to_string()),
241                ip_address: Some(admin_ip.to_owned()),
242                metadata: Some(serde_json::json!({ "target_user_id": target_id })),
243            },
244        )
245        .await?;
246
247        tracing::info!(admin = %admin_id, target = %target_id, session_id = %session.id, "impersonation session created");
248        Ok((session, raw_token))
249    }
250
251    pub async fn list_sessions(&self, user_id: Uuid) -> Result<Vec<Session>> {
252        SessionRepository::find_by_user(&self.storage, user_id).await
253    }
254
255    pub async fn revoke_all_sessions(&self, admin_id: Uuid, user_id: Uuid) -> Result<()> {
256        SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
257        self.events.emit(AuthEvent::SignOut {
258            user_id,
259            session_id: Uuid::nil(),
260        });
261        tracing::info!(admin = %admin_id, target = %user_id, "all sessions revoked by admin");
262        Ok(())
263    }
264}