Skip to main content

alibi_plugins/admin/
mod.rs

1pub mod access;
2
3mod callbacks;
4mod cookies;
5mod endpoint;
6mod listing;
7
8pub(super) mod handlers;
9
10pub(super) mod types;
11
12mod validation;
13
14pub use access::RolePermissions;
15use access::{has_permission, is_admin_role, is_admin_user_id};
16use alibi_core::entity::AuthUser;
17use alibi_core::wire::{SessionView, UserView};
18use alibi_core::{AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult};
19pub(crate) use callbacks::BannedUserMessagePolicy;
20pub use callbacks::{AdminBannedUserMessage, AdminBannedUserMessageHandler};
21use handlers::AdminDateOperationError;
22use handlers::{
23    ban_user_core, create_user_core, get_user_core, has_permission_core, impersonate_user_core,
24    list_user_sessions_core, list_users_core, remove_user_core, revoke_user_session_core,
25    revoke_user_sessions_core, set_role_core, set_user_password_core, stop_impersonating_core,
26    unban_user_core, update_user_core,
27};
28use std::collections::HashMap;
29pub use types::{
30    AdminListUsersQuery, AdminUpdateUserRequest, AdminUserView as AdminUserOutput,
31    BanUserRequest as AdminBanUserRequest, CreateUserRequest as AdminCreateUserRequest,
32    HasPermissionRequest as AdminPermissionRequest, PermissionResponse as AdminPermissionResponse,
33    RoleInput as AdminRoleInput, SetRoleRequest as AdminSetRoleRequest,
34    SetUserPasswordRequest as AdminSetUserPasswordRequest, SuccessResponse as AdminSuccessResponse,
35};
36/// Stored session and safe user returned by an administrative session operation.
37pub type AdminImpersonationResponse =
38    types::SessionUserResponse<alibi_core::wire::SessionView, alibi_core::wire::UserView>;
39/// Safe user result returned by admin mutation operations.
40pub type AdminUserResponse = types::UserResponse<AdminUserOutput>;
41/// Safe user page returned by the listUsers server operation.
42pub type AdminListUsersResponse = types::ListUsersResponse<AdminUserOutput>;
43/// Session page returned by the admin listUserSessions server operation.
44pub type AdminUserSessionsResponse = types::ListSessionsResponse<alibi_core::wire::SessionView>;
45use types::{
46    BanUserRequest, CreateUserRequest, HasPermissionRequest, ListUsersQueryParams,
47    RevokeSessionRequest, SetRoleRequest, SetUserPasswordRequest, UserIdRequest,
48};
49
50const MESSAGE_CHANGE_ROLE: &str = "You are not allowed to change users role";
51
52const MESSAGE_CREATE_USERS: &str = "You are not allowed to create users";
53
54const MESSAGE_LIST_USERS: &str = "You are not allowed to list users";
55
56const MESSAGE_LIST_USER_SESSIONS: &str = "You are not allowed to list users sessions";
57
58const MESSAGE_BAN_USERS: &str = "You are not allowed to ban users";
59
60const MESSAGE_IMPERSONATE_USERS: &str = "You are not allowed to impersonate users";
61
62const MESSAGE_REVOKE_USER_SESSIONS: &str = "You are not allowed to revoke users sessions";
63
64const MESSAGE_DELETE_USERS: &str = "You are not allowed to delete users";
65
66const MESSAGE_SET_USER_PASSWORD: &str = "You are not allowed to set users password";
67
68const MESSAGE_GET_USER: &str = "You are not allowed to get user";
69
70const MESSAGE_UPDATE_USERS: &str = "You are not allowed to update users";
71
72/// Admin plugin for user management operations.
73pub struct AdminPlugin {
74    config: AdminConfig,
75}
76
77/// Configuration for the admin plugin.
78#[derive(Debug, Clone, alibi_core::PluginConfig)]
79#[plugin(name = "AdminPlugin")]
80pub struct AdminConfig {
81    /// Default role assigned to new users and role-less permission checks.
82    #[config(default = "user".to_owned())]
83    pub default_role: String,
84    /// Roles treated as "admin" for target-admin checks such as impersonation.
85    /// None uses the default admin role; explicit lists are validated at initialization.
86    #[config(default = None)]
87    pub admin_roles: Option<Vec<String>>,
88    /// Users that always bypass admin permission checks.
89    #[config(default = None)]
90    pub admin_user_ids: Option<Vec<String>>,
91    /// Custom role definitions. When provided, these replace the built-in
92    /// `admin` and `user` role permissions. None uses builtins; Some(empty) grants none.
93    #[config(default = None)]
94    pub roles: Option<HashMap<String, RolePermissions>>,
95    /// Default reason applied when banning a user without an explicit reason.
96    #[config(default = None)]
97    pub default_ban_reason: Option<String>,
98    /// Default ban duration in seconds, including fractions; zero/NaN act as unset.
99    #[config(default = None)]
100    pub default_ban_expires_in: Option<f64>,
101    /// Custom impersonation lifetime in seconds, including fractions; zero/NaN use one hour.
102    #[config(default = None)]
103    pub impersonation_session_duration: Option<f64>,
104    /// Message surfaced to banned users.
105    #[config(default = "You have been banned from this application. Please contact support if you believe this is an error.".to_owned())]
106    pub banned_user_message: String,
107    /// Optional asynchronous message callback over the stored application user.
108    /// When configured, this takes precedence over `banned_user_message`.
109    #[config(skip, default = None)]
110    pub banned_user_message_callback: Option<AdminBannedUserMessageHandler>,
111    /// Whether other admin users may be impersonated.
112    #[config(default = false)]
113    pub allow_impersonating_admins: bool,
114}
115
116alibi_core::impl_auth_plugin! {
117    AdminPlugin, "admin";
118    routes {
119        post "/admin/set-role" => handle_set_role, "admin_set_role";
120        get  "/admin/get-user" => handle_get_user, "admin_get_user";
121        post "/admin/create-user" => handle_create_user, "admin_create_user";
122        post "/admin/update-user" => handle_update_user, "admin_update_user";
123        get  "/admin/list-users" => handle_list_users, "admin_list_users";
124        post "/admin/list-user-sessions" => handle_list_user_sessions, "admin_list_user_sessions";
125        post "/admin/ban-user" => handle_ban_user, "admin_ban_user";
126        post "/admin/unban-user" => handle_unban_user, "admin_unban_user";
127        post "/admin/impersonate-user" => handle_impersonate_user, "admin_impersonate_user";
128        post "/admin/stop-impersonating" => handle_stop_impersonating, "admin_stop_impersonating";
129        post "/admin/revoke-user-session" => handle_revoke_user_session, "admin_revoke_user_session";
130        post "/admin/revoke-user-sessions" => handle_revoke_user_sessions, "admin_revoke_user_sessions";
131        post "/admin/remove-user" => handle_remove_user, "admin_remove_user";
132        post "/admin/set-user-password" => handle_set_user_password, "admin_set_user_password";
133        post "/admin/has-permission" => handle_has_permission, "admin_has_permission";
134    }
135    extra {
136    route_openapi_metadata!(S);
137
138        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
139        fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
140        async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_admin_endpoint(call, ctx).await }
141
142        fn session_fields(&self) -> alibi_core::field_policy::FieldConfigs {
143            std::iter::once(("impersonatedBy".into(), alibi_core::field_policy::FieldConfig::new(serde_json::json!({"type":"string"})).read_only())).collect()
144        }
145
146        async fn on_init(
147            &self,
148            ctx: &mut alibi_core::AuthInitContext<S>,
149        ) -> AuthResult<()> {
150            if let Some(admin_roles) = &self.config.admin_roles {
151                let roles = self.config.roles.clone().unwrap_or_else(access::default_roles);
152                let names: Vec<_> = roles.keys().map(|name| name.to_lowercase()).collect();
153                let invalid: Vec<_> = admin_roles.iter()
154                    .filter(|role| !names.contains(&role.to_lowercase()))
155                    .map(String::as_str).collect();
156                if !invalid.is_empty() {
157                    return Err(AuthError::config(format!(
158                        "Invalid admin roles: {}. Admin roles must be defined in the 'roles' configuration.",
159                        invalid.join(", ")
160                    )));
161                }
162            }
163            if let Some(handler) = &self.config.banned_user_message_callback {
164                handler.validate::<S::User>()?;
165                ctx.extensions.insert(BannedUserMessagePolicy(handler.clone()));
166            }
167            let default_role = self.config.default_role.clone();
168            let validation_enabled = ctx.config.user_validation.is_some();
169            ctx.register_user_create_transform(move |mut input| {
170                if !validation_enabled {_ = input.banned.get_or_insert(false);}
171                _ = input.role.get_or_insert_with(|| default_role.clone());
172                Ok(input)
173            });
174            if validation_enabled {ctx.register_user_creation_adapter_default(|mut input| {
175                _ = input.banned.get_or_insert(false); Ok(input)
176            });}
177            ctx.set_metadata("admin.enabled", serde_json::Value::Bool(true));
178            ctx.set_metadata(
179                "admin.default_role",
180                serde_json::Value::String(self.config.default_role.clone()),
181            );
182            ctx.set_metadata(
183                "admin.banned_user_message",
184                serde_json::Value::String(self.config.banned_user_message.clone()),
185            );
186            Ok(())
187        }
188    }
189}
190
191impl AdminPlugin {
192    /// Configure an awaited message callback over the exact stored user entity.
193    #[must_use]
194    pub fn banned_user_message_callback<U: AuthUser, H: AdminBannedUserMessage<U>>(
195        mut self,
196        handler: H,
197    ) -> Self {
198        self.config.banned_user_message_callback =
199            Some(AdminBannedUserMessageHandler::new::<U, H>(handler));
200        self
201    }
202
203    async fn require_session(
204        &self,
205        req: &AuthRequest,
206        ctx: &AuthContext<impl alibi_core::AuthSchema>,
207    ) -> AuthResult<Option<(UserView, SessionView)>> {
208        match ctx.require_authoritative_cached_session(req).await {
209            Ok((user, session)) => Ok(Some((UserView::from(&user), SessionView::from(&session)))),
210            Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => Ok(None),
211            Err(error) => Err(error),
212        }
213    }
214
215    fn missing_session_response() -> AuthResponse {
216        AuthResponse::new(401).with_header("Content-Type", "application/json")
217    }
218
219    fn authorize(
220        &self,
221        user: &UserView,
222        resource: &str,
223        action: &str,
224        message: &str,
225    ) -> AuthResult<()> {
226        let permissions = HashMap::from([(resource.to_owned(), vec![action.to_owned()])]);
227        if has_permission(
228            Some(user.id.as_str()),
229            user.role.as_deref(),
230            &self.config,
231            &permissions,
232        ) {
233            Ok(())
234        } else {
235            Err(AuthError::forbidden(message))
236        }
237    }
238
239    async fn handle_set_role(
240        &self,
241        req: &AuthRequest,
242        ctx: &AuthContext<impl alibi_core::AuthSchema>,
243    ) -> AuthResult<AuthResponse> {
244        let body: SetRoleRequest = match validation::body(req) {
245            Ok(v) => v,
246            Err(resp) => return Ok(resp),
247        };
248        let Some((user, _session)) = self.require_session(req, ctx).await? else {
249            return Ok(Self::missing_session_response());
250        };
251        self.authorize(&user, "user", "set-role", MESSAGE_CHANGE_ROLE)?;
252        let response = set_role_core(&body, &self.config, ctx).await?;
253        AuthResponse::json(200, &response).map_err(AuthError::from)
254    }
255
256    async fn handle_get_user(
257        &self,
258        req: &AuthRequest,
259        ctx: &AuthContext<impl alibi_core::AuthSchema>,
260    ) -> AuthResult<AuthResponse> {
261        let query = match validation::get_user(req) {
262            Ok(query) => query,
263            Err(response) => return Ok(response),
264        };
265        let Some((user, _session)) = self.require_session(req, ctx).await? else {
266            return Ok(Self::missing_session_response());
267        };
268        self.authorize(&user, "user", "get", MESSAGE_GET_USER)?;
269        let response = get_user_core(&query, ctx).await?;
270        AuthResponse::json(200, &response).map_err(AuthError::from)
271    }
272
273    fn authorize_create_user(&self, user: &UserView, body: &CreateUserRequest) -> AuthResult<()> {
274        self.authorize(user, "user", "create", MESSAGE_CREATE_USERS)?;
275        // The source checks presence after nullish body/data precedence; empty
276        // strings/arrays still request role authority. Validate nested types later.
277        if body.role.is_some()
278            || body
279                .data
280                .as_ref()
281                .is_some_and(|data| data.contains_key("role"))
282        {
283            self.authorize(user, "user", "set-role", MESSAGE_CHANGE_ROLE)?;
284        }
285        if body.data.as_ref().is_some_and(|data| {
286            ["banned", "banReason", "banExpires"]
287                .iter()
288                .any(|key| data.contains_key(*key))
289        }) {
290            self.authorize(user, "user", "ban", MESSAGE_BAN_USERS)?;
291        }
292        Ok(())
293    }
294
295    async fn handle_create_user(
296        &self,
297        req: &AuthRequest,
298        ctx: &AuthContext<impl alibi_core::AuthSchema>,
299    ) -> AuthResult<AuthResponse> {
300        let body: CreateUserRequest = match validation::body(req) {
301            Ok(v) => v,
302            Err(resp) => return Ok(resp),
303        };
304        let Some((user, _session)) = self.require_session(req, ctx).await? else {
305            return Ok(Self::missing_session_response());
306        };
307        self.authorize_create_user(&user, &body)?;
308        let response = create_user_core(&body, &self.config, ctx).await?;
309        AuthResponse::json(200, &response).map_err(AuthError::from)
310    }
311
312    async fn handle_update_user(
313        &self,
314        req: &AuthRequest,
315        ctx: &AuthContext<impl alibi_core::AuthSchema>,
316    ) -> AuthResult<AuthResponse> {
317        let body: AdminUpdateUserRequest = match validation::body(req) {
318            Ok(v) => v,
319            Err(resp) => return Ok(resp),
320        };
321        let Some((user, _session)) = self.require_session(req, ctx).await? else {
322            return Ok(Self::missing_session_response());
323        };
324        self.authorize(&user, "user", "update", MESSAGE_UPDATE_USERS)?;
325        let response = update_user_core(&body, &user, &self.config, ctx).await?;
326        AuthResponse::json(200, &response).map_err(AuthError::from)
327    }
328
329    async fn handle_list_users(
330        &self,
331        req: &AuthRequest,
332        ctx: &AuthContext<impl alibi_core::AuthSchema>,
333    ) -> AuthResult<AuthResponse> {
334        if let Err(response) = validation::list_users(req) {
335            return Ok(response);
336        }
337        let Some((user, _session)) = self.require_session(req, ctx).await? else {
338            return Ok(Self::missing_session_response());
339        };
340        self.authorize(&user, "user", "list", MESSAGE_LIST_USERS)?;
341        let query = ListUsersQueryParams {
342            source_limit: req
343                .query
344                .get("limit")
345                .and_then(|value| alibi_core::utils::javascript::string_to_number(value))
346                .filter(|value| !value.is_nan() && *value != 0.0),
347            source_offset: req
348                .query
349                .get("offset")
350                .and_then(|value| alibi_core::utils::javascript::string_to_number(value))
351                .filter(|value| !value.is_nan() && *value != 0.0),
352            limit: req
353                .query
354                .get("limit")
355                .and_then(|value| value.parse().ok())
356                .filter(|value| *value > 0),
357            offset: req.query.get("offset").and_then(|value| value.parse().ok()),
358            search_field: req.query.get("searchField").cloned(),
359            search_value: req.query.get("searchValue").cloned(),
360            search_operator: req.query.get("searchOperator").cloned(),
361            sort_by: req.query.get("sortBy").cloned(),
362            sort_direction: req.query.get("sortDirection").cloned(),
363            filter_field: req.query.get("filterField").cloned(),
364            filter_value: req.query_values("filterValue").map(|values| match values {
365                [value] => alibi_core::UserFilterValue::Scalar(value.clone()),
366                values => alibi_core::UserFilterValue::Multiple(values.to_vec()),
367            }),
368            filter_operator: req.query.get("filterOperator").cloned(),
369        };
370        let response = list_users_core(&query, ctx).await?;
371        AuthResponse::json(200, &response).map_err(AuthError::from)
372    }
373
374    async fn handle_list_user_sessions(
375        &self,
376        req: &AuthRequest,
377        ctx: &AuthContext<impl alibi_core::AuthSchema>,
378    ) -> AuthResult<AuthResponse> {
379        let body: UserIdRequest = match validation::body(req) {
380            Ok(v) => v,
381            Err(resp) => return Ok(resp),
382        };
383        let Some((user, _session)) = self.require_session(req, ctx).await? else {
384            return Ok(Self::missing_session_response());
385        };
386        self.authorize(&user, "session", "list", MESSAGE_LIST_USER_SESSIONS)?;
387        let response = list_user_sessions_core(&body, ctx).await?;
388        AuthResponse::json(200, &response).map_err(AuthError::from)
389    }
390
391    async fn handle_ban_user(
392        &self,
393        req: &AuthRequest,
394        ctx: &AuthContext<impl alibi_core::AuthSchema>,
395    ) -> AuthResult<AuthResponse> {
396        let body: BanUserRequest = match validation::body(req) {
397            Ok(v) => v,
398            Err(resp) => return Ok(resp),
399        };
400        let Some((user, _session)) = self.require_session(req, ctx).await? else {
401            return Ok(Self::missing_session_response());
402        };
403        self.authorize(&user, "user", "ban", MESSAGE_BAN_USERS)?;
404        let response = match ban_user_core(&body, user.id.as_str(), &self.config, ctx).await {
405            Ok(response) => response,
406            Err(AdminDateOperationError::InvalidDate) => return Ok(AuthResponse::new(500)),
407            Err(AdminDateOperationError::Auth(error)) => return Err(error),
408        };
409        AuthResponse::json(200, &response).map_err(AuthError::from)
410    }
411
412    async fn handle_unban_user(
413        &self,
414        req: &AuthRequest,
415        ctx: &AuthContext<impl alibi_core::AuthSchema>,
416    ) -> AuthResult<AuthResponse> {
417        let body: UserIdRequest = match validation::body(req) {
418            Ok(v) => v,
419            Err(resp) => return Ok(resp),
420        };
421        let Some((user, _session)) = self.require_session(req, ctx).await? else {
422            return Ok(Self::missing_session_response());
423        };
424        self.authorize(&user, "user", "ban", MESSAGE_BAN_USERS)?;
425        let response = unban_user_core(&body, ctx).await?;
426        AuthResponse::json(200, &response).map_err(AuthError::from)
427    }
428
429    async fn handle_impersonate_user(
430        &self,
431        req: &AuthRequest,
432        ctx: &AuthContext<impl alibi_core::AuthSchema>,
433    ) -> AuthResult<AuthResponse> {
434        let body: UserIdRequest = match validation::body(req) {
435            Ok(v) => v,
436            Err(resp) => return Ok(resp),
437        };
438        let Some((user, session)) = self.require_session(req, ctx).await? else {
439            return Ok(Self::missing_session_response());
440        };
441        self.authorize(&user, "user", "impersonate", MESSAGE_IMPERSONATE_USERS)?;
442        let (response, token) = match impersonate_user_core(
443            &body,
444            user.id.as_str(),
445            user.role.as_deref(),
446            req,
447            &self.config,
448            ctx,
449        )
450        .await
451        {
452            Ok(response) => response,
453            Err(AdminDateOperationError::InvalidDate) => return Ok(AuthResponse::new(500)),
454            Err(AdminDateOperationError::Auth(error)) => return Err(error),
455        };
456        let mut auth_response = AuthResponse::json(200, &response)?;
457        for (name, value) in cookies::impersonation_cookie_headers(
458            &ctx.config,
459            &req.headers,
460            &session.token,
461            &token,
462        )? {
463            auth_response.headers.append(name, value);
464        }
465        Ok(auth_response)
466    }
467
468    async fn handle_stop_impersonating(
469        &self,
470        req: &AuthRequest,
471        ctx: &AuthContext<impl alibi_core::AuthSchema>,
472    ) -> AuthResult<AuthResponse> {
473        if let Err(response) = validation::parse(req) {
474            return Ok(response);
475        }
476        let session = match ctx.require_cached_session(req).await {
477            Ok((_, session)) => session,
478            Err(AuthError::Unauthenticated) => return Ok(Self::missing_session_response()),
479            Err(error) => return Err(error),
480        };
481        if session.impersonated_by.is_none() {
482            return Err(AuthError::bad_request("You are not impersonating anyone"));
483        }
484
485        let (response, token, dont_remember) = stop_impersonating_core(&session, req, ctx).await?;
486        let mut auth_response = AuthResponse::json(200, &response)?;
487        for (name, value) in
488            cookies::restoration_cookie_headers(&ctx.config, &token, dont_remember)?
489        {
490            auth_response.headers.append(name, value);
491        }
492        Ok(auth_response)
493    }
494
495    async fn handle_revoke_user_session(
496        &self,
497        req: &AuthRequest,
498        ctx: &AuthContext<impl alibi_core::AuthSchema>,
499    ) -> AuthResult<AuthResponse> {
500        let body: RevokeSessionRequest = match validation::body(req) {
501            Ok(v) => v,
502            Err(resp) => return Ok(resp),
503        };
504        let Some((user, _session)) = self.require_session(req, ctx).await? else {
505            return Ok(Self::missing_session_response());
506        };
507        self.authorize(&user, "session", "revoke", MESSAGE_REVOKE_USER_SESSIONS)?;
508        let response = revoke_user_session_core(&body, ctx).await?;
509        AuthResponse::json(200, &response).map_err(AuthError::from)
510    }
511
512    async fn handle_revoke_user_sessions(
513        &self,
514        req: &AuthRequest,
515        ctx: &AuthContext<impl alibi_core::AuthSchema>,
516    ) -> AuthResult<AuthResponse> {
517        let body: UserIdRequest = match validation::body(req) {
518            Ok(v) => v,
519            Err(resp) => return Ok(resp),
520        };
521        let Some((user, _session)) = self.require_session(req, ctx).await? else {
522            return Ok(Self::missing_session_response());
523        };
524        self.authorize(&user, "session", "revoke", MESSAGE_REVOKE_USER_SESSIONS)?;
525        let response = revoke_user_sessions_core(&body, ctx).await?;
526        AuthResponse::json(200, &response).map_err(AuthError::from)
527    }
528
529    async fn handle_remove_user(
530        &self,
531        req: &AuthRequest,
532        ctx: &AuthContext<impl alibi_core::AuthSchema>,
533    ) -> AuthResult<AuthResponse> {
534        let body: UserIdRequest = match validation::body(req) {
535            Ok(v) => v,
536            Err(resp) => return Ok(resp),
537        };
538        let Some((user, _session)) = self.require_session(req, ctx).await? else {
539            return Ok(Self::missing_session_response());
540        };
541        self.authorize(&user, "user", "delete", MESSAGE_DELETE_USERS)?;
542        let response = remove_user_core(&body, user.id.as_str(), ctx).await?;
543        AuthResponse::json(200, &response).map_err(AuthError::from)
544    }
545
546    async fn handle_set_user_password(
547        &self,
548        req: &AuthRequest,
549        ctx: &AuthContext<impl alibi_core::AuthSchema>,
550    ) -> AuthResult<AuthResponse> {
551        let body: SetUserPasswordRequest = match validation::body(req) {
552            Ok(v) => v,
553            Err(resp) => return Ok(resp),
554        };
555        let Some((user, _session)) = self.require_session(req, ctx).await? else {
556            return Ok(Self::missing_session_response());
557        };
558        self.authorize(&user, "user", "set-password", MESSAGE_SET_USER_PASSWORD)?;
559        let response = set_user_password_core(&body, ctx).await?;
560        AuthResponse::json(200, &response).map_err(AuthError::from)
561    }
562
563    async fn handle_has_permission(
564        &self,
565        req: &AuthRequest,
566        ctx: &AuthContext<impl alibi_core::AuthSchema>,
567    ) -> AuthResult<AuthResponse> {
568        let body: HasPermissionRequest = match validation::body(req) {
569            Ok(v) => v,
570            Err(resp) => return Ok(resp),
571        };
572        if body.permissions.is_none() {
573            return AuthResponse::json(400, &serde_json::json!({"message":"invalid permission check. no permission(s) were passed."})).map_err(AuthError::from);
574        }
575        let Some((user, _session)) = self.require_session(req, ctx).await? else {
576            return Ok(Self::missing_session_response());
577        };
578        let response = has_permission_core(&body, &user, &self.config)?;
579        AuthResponse::json(200, &response).map_err(AuthError::from)
580    }
581}
582
583impl std::fmt::Debug for AdminPlugin {
584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        f.debug_struct("AdminPlugin").finish_non_exhaustive()
586    }
587}
588
589pub(super) fn target_is_admin(
590    user_id: Option<&str>,
591    role: Option<&str>,
592    config: &AdminConfig,
593) -> bool {
594    is_admin_user_id(user_id, config) || is_admin_role(role, config)
595}
596
597// LCOV_EXCL_START
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::test_helpers;
602    use alibi_core::entity::{AuthAccount, AuthSession};
603    use alibi_core::utils::cookie_utils::related_cookie_name;
604    use alibi_core::wire::{SessionView, UserView};
605    use alibi_core::{AuthPlugin, CreateSession, CreateUser, HttpMethod};
606    use chrono::{Duration, Utc};
607    use std::collections::HashMap;
608    use std::sync::Arc;
609
610    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
611
612    async fn create_admin_context() -> (
613        AuthContext<TestSchema>,
614        UserView,
615        SessionView,
616        UserView,
617        SessionView,
618    ) {
619        let ctx = test_helpers::create_test_context().await;
620
621        let admin = test_helpers::create_user(
622            &ctx,
623            CreateUser::new()
624                .with_email("admin@example.com")
625                .with_name("Admin")
626                .with_role("admin"),
627        )
628        .await;
629        let admin_session =
630            test_helpers::create_session(&ctx, admin.id.clone(), Duration::hours(24)).await;
631
632        let user = test_helpers::create_user(
633            &ctx,
634            CreateUser::new()
635                .with_email("user@example.com")
636                .with_name("Regular User")
637                .with_role("user"),
638        )
639        .await;
640        let user_session =
641            test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(24)).await;
642
643        (ctx, admin, admin_session, user, user_session)
644    }
645
646    fn make_request(
647        method: HttpMethod,
648        path: &str,
649        token: &str,
650        body: Option<serde_json::Value>,
651    ) -> AuthRequest {
652        test_helpers::create_auth_json_request_no_query(method, path, Some(token), body)
653    }
654
655    fn json_body(resp: &AuthResponse) -> serde_json::Value {
656        serde_json::from_slice(&resp.body).unwrap()
657    }
658
659    fn set_cookie_value(resp: &AuthResponse, name: &str) -> Option<String> {
660        resp.headers.get_all("Set-Cookie").find_map(|header| {
661            let (cookie_name, remainder) = header.split_once('=')?;
662            if cookie_name != name {
663                return None;
664            }
665            Some(remainder.split(';').next().unwrap_or_default().to_owned())
666        })
667    }
668
669    #[tokio::test]
670    async fn test_custom_admin_role_can_use_permission_engine() {
671        let config = Arc::new(alibi_core::AuthConfig::new(
672            "test-secret-key-at-least-32-chars-long",
673        ));
674        let database = test_helpers::create_test_database().await;
675        let ctx = AuthContext::new(config, Arc::clone(&database));
676
677        let admin = database
678            .create_user(
679                CreateUser::new()
680                    .with_email("superadmin@example.com")
681                    .with_name("Super Admin")
682                    .with_role("superadmin"),
683            )
684            .await
685            .unwrap();
686
687        let admin_session = database
688            .create_session(CreateSession {
689                additional_fields: alibi_core::field_policy::FieldValues::default(),
690                token: None,
691                active_team_id: None,
692                user_id: admin.id.clone(),
693                expires_at: Utc::now() + Duration::hours(24),
694                ip_address: None,
695                user_agent: None,
696                impersonated_by: None,
697                active_organization_id: None,
698            })
699            .await
700            .unwrap();
701
702        _ = database
703            .create_user(
704                CreateUser::new()
705                    .with_email("user@example.com")
706                    .with_name("User")
707                    .with_role("user"),
708            )
709            .await
710            .unwrap();
711
712        let plugin = AdminPlugin::with_config(AdminConfig {
713            admin_roles: Some(vec!["superadmin".to_owned()]),
714            roles: Some(HashMap::from([(
715                "superadmin".to_owned(),
716                RolePermissions::new()
717                    .allow(
718                        "user",
719                        [
720                            "create",
721                            "list",
722                            "set-role",
723                            "ban",
724                            "impersonate",
725                            "delete",
726                            "set-password",
727                            "get",
728                            "update",
729                        ],
730                    )
731                    .allow("session", ["list", "revoke", "delete"]),
732            )])),
733            ..Default::default()
734        });
735
736        let req = make_request(
737            HttpMethod::Get,
738            "/admin/list-users",
739            &admin_session.token,
740            None,
741        );
742
743        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
744        assert_eq!(resp.status, 200);
745        assert_eq!(
746            (*(json_body(&resp))
747                .get("total")
748                .unwrap_or(&serde_json::Value::Null)),
749            2
750        );
751    }
752
753    #[tokio::test]
754    async fn test_ban_revokes_user_sessions() {
755        let (ctx, _admin, admin_session, user, _user_session) = create_admin_context().await;
756        let plugin = AdminPlugin::new();
757
758        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
759        assert_ne!(sessions.len(), 0);
760
761        let req = make_request(
762            HttpMethod::Post,
763            "/admin/ban-user",
764            &admin_session.token,
765            Some(serde_json::json!({
766                "userId": user.id,
767                "banReason": "bad behavior"
768            })),
769        );
770
771        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
772        assert_eq!(resp.status, 200);
773
774        let sessions_2 = ctx.database.get_user_sessions(&user.id).await.unwrap();
775        assert_eq!(sessions_2.len(), 0);
776    }
777
778    #[tokio::test]
779    async fn test_unban_clears_ban_reason_and_expires() {
780        let (ctx, _admin, admin_session, user, _user_session) = create_admin_context().await;
781        let plugin = AdminPlugin::new();
782
783        let req = make_request(
784            HttpMethod::Post,
785            "/admin/ban-user",
786            &admin_session.token,
787            Some(serde_json::json!({
788                "userId": user.id,
789                "banReason": "spam",
790                "banExpiresIn": 3600
791            })),
792        );
793        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
794        assert_eq!(resp.status, 200);
795
796        let req_2 = make_request(
797            HttpMethod::Post,
798            "/admin/unban-user",
799            &admin_session.token,
800            Some(serde_json::json!({
801                "userId": user.id,
802            })),
803        );
804
805        let resp_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
806        assert_eq!(resp_2.status, 200);
807
808        let updated_user = ctx
809            .database
810            .get_user_by_id(&user.id)
811            .await
812            .unwrap()
813            .unwrap();
814        assert_eq!(updated_user.banned, Some(false));
815        assert!(updated_user.ban_reason.is_none());
816        assert!(updated_user.ban_expires.is_none());
817    }
818
819    #[tokio::test]
820    async fn test_impersonation_session_tracks_admin_id() {
821        let (ctx, admin, admin_session, user, _user_session) = create_admin_context().await;
822        let plugin = AdminPlugin::new();
823
824        let req = make_request(
825            HttpMethod::Post,
826            "/admin/impersonate-user",
827            &admin_session.token,
828            Some(serde_json::json!({
829                "userId": user.id,
830            })),
831        );
832
833        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
834        let admin_cookie_name = related_cookie_name(&ctx.config, "admin_session");
835        assert!(
836            set_cookie_value(&resp, &admin_cookie_name).is_some(),
837            "impersonation should emit an admin_session cookie"
838        );
839        let token = (*(*(json_body(&resp))
840            .get("session")
841            .unwrap_or(&serde_json::Value::Null))
842        .get("token")
843        .unwrap_or(&serde_json::Value::Null))
844        .as_str()
845        .unwrap()
846        .to_owned();
847        let session = ctx.database.get_session(&token).await.unwrap().unwrap();
848
849        assert_eq!(session.impersonated_by().unwrap(), admin.id);
850    }
851
852    #[tokio::test]
853    async fn test_stop_impersonating_restores_admin_session() {
854        let (mut ctx, admin, admin_session, user, _user_session) = create_admin_context().await;
855        let plugin = AdminPlugin::new();
856        let mut init =
857            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
858        plugin.on_init(&mut init).await.unwrap();
859        ctx.metadata.extend(init.into_parts().metadata);
860
861        let req = make_request(
862            HttpMethod::Post,
863            "/admin/impersonate-user",
864            &admin_session.token,
865            Some(serde_json::json!({
866                "userId": user.id,
867            })),
868        );
869        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
870        let impersonation_token = (*(*(json_body(&resp))
871            .get("session")
872            .unwrap_or(&serde_json::Value::Null))
873        .get("token")
874        .unwrap_or(&serde_json::Value::Null))
875        .as_str()
876        .unwrap()
877        .to_owned();
878        let admin_cookie_name = related_cookie_name(&ctx.config, "admin_session");
879        let admin_cookie = set_cookie_value(&resp, &admin_cookie_name)
880            .expect("impersonation should set the admin_session cookie");
881
882        let mut req_2 = make_request(
883            HttpMethod::Post,
884            "/admin/stop-impersonating",
885            &impersonation_token,
886            None,
887        );
888        req_2.headers.insert(
889            "cookie".to_owned(),
890            format!(
891                "{}; {admin_cookie_name}={admin_cookie}",
892                req_2
893                    .headers
894                    .get("cookie")
895                    .expect("impersonated browser has a signed session cookie")
896            ),
897        );
898        let resp_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
899        let body = json_body(&resp_2);
900
901        let restored_token = (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
902            .get("token")
903            .unwrap_or(&serde_json::Value::Null))
904        .as_str()
905        .unwrap();
906        assert_eq!(
907            restored_token, admin_session.token,
908            "stop-impersonating should restore the original admin session token"
909        );
910        let restored_session = ctx
911            .database
912            .get_session(restored_token)
913            .await
914            .unwrap()
915            .unwrap();
916        assert_eq!(restored_session.user_id, admin.id);
917        assert!(restored_session.impersonated_by.is_none());
918        assert!(
919            ctx.database
920                .get_session(&impersonation_token)
921                .await
922                .unwrap()
923                .is_none()
924        );
925        let cleared_admin_cookie = resp_2
926            .headers
927            .get_all("Set-Cookie")
928            .find(|header| header.starts_with(&format!("{admin_cookie_name}=")))
929            .expect("stop-impersonating should clear admin_session");
930        assert!(
931            cleared_admin_cookie.contains("Max-Age=0"),
932            "admin_session should be cleared after stop-impersonating"
933        );
934    }
935
936    #[tokio::test]
937    async fn test_list_user_sessions_missing_user_returns_empty_array() {
938        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
939        let plugin = AdminPlugin::new();
940
941        let req = make_request(
942            HttpMethod::Post,
943            "/admin/list-user-sessions",
944            &admin_session.token,
945            Some(serde_json::json!({
946                "userId": "missing-user",
947            })),
948        );
949        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
950
951        assert_eq!(resp.status, 200);
952        assert_eq!(json_body(&resp), serde_json::json!({ "sessions": [] }));
953    }
954
955    #[tokio::test]
956    async fn test_revoke_user_sessions_missing_user_still_succeeds() {
957        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
958        let plugin = AdminPlugin::new();
959
960        let req = make_request(
961            HttpMethod::Post,
962            "/admin/revoke-user-sessions",
963            &admin_session.token,
964            Some(serde_json::json!({
965                "userId": "missing-user",
966            })),
967        );
968        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
969
970        assert_eq!(resp.status, 200);
971        assert_eq!(json_body(&resp), serde_json::json!({ "success": true }));
972    }
973
974    #[tokio::test]
975    async fn test_stop_impersonating_without_impersonated_session_returns_bad_request() {
976        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
977        let plugin = AdminPlugin::new();
978
979        let req = make_request(
980            HttpMethod::Post,
981            "/admin/stop-impersonating",
982            &admin_session.token,
983            None,
984        );
985        let err = plugin.on_request(&req, &ctx).await.unwrap_err();
986        let response = err.to_auth_response();
987        assert_eq!(response.status, 400);
988        assert_eq!(
989            json_body(&response),
990            serde_json::json!({
991                // Upstream throws this via `APIError.fromStatus`, which carries no
992                // code — only errors built from a code constant have one.
993                "message": "You are not impersonating anyone"
994            })
995        );
996    }
997
998    #[tokio::test]
999    async fn test_remove_user_cleans_up_sessions_and_accounts() {
1000        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
1001        let plugin = AdminPlugin::new();
1002
1003        let req = make_request(
1004            HttpMethod::Post,
1005            "/admin/create-user",
1006            &admin_session.token,
1007            Some(serde_json::json!({
1008                "email": "tobedeleted@example.com",
1009                "password": "securepassword123",
1010                "name": "To Be Deleted"
1011            })),
1012        );
1013        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
1014        let user_id = (*(*(json_body(&resp))
1015            .get("user")
1016            .unwrap_or(&serde_json::Value::Null))
1017        .get("id")
1018        .unwrap_or(&serde_json::Value::Null))
1019        .as_str()
1020        .unwrap()
1021        .to_owned();
1022
1023        let accounts = ctx.database.get_user_accounts(&user_id).await.unwrap();
1024        assert_eq!(accounts.len(), 1);
1025
1026        let req_2 = make_request(
1027            HttpMethod::Post,
1028            "/admin/remove-user",
1029            &admin_session.token,
1030            Some(serde_json::json!({
1031                "userId": user_id,
1032            })),
1033        );
1034        let resp_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1035        assert_eq!(resp_2.status, 200);
1036
1037        assert!(
1038            ctx.database
1039                .get_user_by_id(&user_id)
1040                .await
1041                .unwrap()
1042                .is_none()
1043        );
1044        assert_eq!(
1045            ctx.database
1046                .get_user_accounts(&user_id)
1047                .await
1048                .unwrap()
1049                .len(),
1050            0
1051        );
1052    }
1053
1054    #[tokio::test]
1055    async fn test_set_user_password_updates_credential_account() {
1056        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
1057        let plugin = AdminPlugin::new();
1058
1059        let req = make_request(
1060            HttpMethod::Post,
1061            "/admin/create-user",
1062            &admin_session.token,
1063            Some(serde_json::json!({
1064                "email": "pwuser@example.com",
1065                "password": "oldpassword123",
1066                "name": "PW User"
1067            })),
1068        );
1069        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
1070        let user_id = (*(*(json_body(&resp))
1071            .get("user")
1072            .unwrap_or(&serde_json::Value::Null))
1073        .get("id")
1074        .unwrap_or(&serde_json::Value::Null))
1075        .as_str()
1076        .unwrap()
1077        .to_owned();
1078
1079        let before = ctx.database.get_user_accounts(&user_id).await.unwrap();
1080        let old_password = (*(before)
1081            .first()
1082            .expect("persisted rows contain the requested index"))
1083        .password()
1084        .unwrap()
1085        .to_owned();
1086
1087        let req_2 = make_request(
1088            HttpMethod::Post,
1089            "/admin/set-user-password",
1090            &admin_session.token,
1091            Some(serde_json::json!({
1092                "userId": user_id,
1093                "newPassword": "newpassword456"
1094            })),
1095        );
1096        let resp_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1097        assert_eq!(resp_2.status, 200);
1098
1099        let after = ctx.database.get_user_accounts(&user_id).await.unwrap();
1100        let new_password = (*(after)
1101            .first()
1102            .expect("persisted rows contain the requested index"))
1103        .password()
1104        .unwrap()
1105        .to_owned();
1106        assert_ne!(old_password, new_password);
1107    }
1108
1109    #[tokio::test]
1110    async fn test_set_user_password_creates_canonical_credential_when_missing() {
1111        let (ctx, _admin, admin_session, _user, _user_session) = create_admin_context().await;
1112        let plugin = AdminPlugin::new();
1113
1114        let req = make_request(
1115            HttpMethod::Post,
1116            "/admin/create-user",
1117            &admin_session.token,
1118            Some(serde_json::json!({
1119                "email": "passwordless@example.com",
1120                "name": "Passwordless User"
1121            })),
1122        );
1123        let resp = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
1124        let user_id = (*(*(json_body(&resp))
1125            .get("user")
1126            .unwrap_or(&serde_json::Value::Null))
1127        .get("id")
1128        .unwrap_or(&serde_json::Value::Null))
1129        .as_str()
1130        .unwrap()
1131        .to_owned();
1132
1133        let req_2 = make_request(
1134            HttpMethod::Post,
1135            "/admin/set-user-password",
1136            &admin_session.token,
1137            Some(serde_json::json!({
1138                "userId": user_id,
1139                "newPassword": "newpassword456"
1140            })),
1141        );
1142        let resp_2 = plugin.on_request(&req_2, &ctx).await.unwrap().unwrap();
1143        assert_eq!(resp_2.status, 200);
1144
1145        let accounts = ctx.database.get_user_accounts(&user_id).await.unwrap();
1146        assert_eq!(accounts.len(), 1);
1147        let account = accounts
1148            .first()
1149            .expect("password setting creates a credential");
1150        assert_eq!(account.user_id(), user_id);
1151        assert_eq!(account.account_id(), user_id);
1152        assert_eq!(account.provider_id(), "credential");
1153        assert!(
1154            alibi_core::PasswordHasher::verify(
1155                &alibi_core::ScryptHasher,
1156                account.password().unwrap(),
1157                "newpassword456",
1158            )
1159            .await
1160            .unwrap()
1161        );
1162    }
1163
1164    #[tokio::test]
1165    async fn admin_creation_ignores_unconfigured_application_fields_without_reserved_role_input() {
1166        let (ctx, admin, admin_session, _other, other_session) = create_admin_context().await;
1167        let expected = serde_json::json!({"preferences":{"theme":"night","nested":{"$serde_json::private::RawValue":"literal"}}});
1168        let request = make_request(
1169            HttpMethod::Post,
1170            "/admin/create-user",
1171            &admin_session.token,
1172            Some(
1173                serde_json::json!({"email":"metadata-user@fixture.test","name":"Metadata user","data":{"role":"user","preferences":expected.get("preferences").unwrap()}}),
1174            ),
1175        );
1176        let response = AdminPlugin::new()
1177            .on_request(&request, &ctx)
1178            .await
1179            .unwrap()
1180            .unwrap();
1181        assert_eq!(response.status, 200);
1182        let created = ctx
1183            .database
1184            .get_user_by_email("metadata-user@fixture.test")
1185            .await
1186            .unwrap()
1187            .unwrap();
1188        assert_eq!(created.role(), Some("user"));
1189        assert_eq!(created.metadata(), &serde_json::json!({}));
1190        assert_eq!(
1191            json_body(&response)
1192                .get("user")
1193                .and_then(|user| user.get("id"))
1194                .and_then(serde_json::Value::as_str),
1195            Some(created.id.as_str())
1196        );
1197        assert!(
1198            ctx.database
1199                .get_session(&admin_session.token)
1200                .await
1201                .unwrap()
1202                .is_some()
1203        );
1204        assert!(
1205            ctx.database
1206                .get_session(&other_session.token)
1207                .await
1208                .unwrap()
1209                .is_some()
1210        );
1211        assert_eq!(
1212            ctx.database
1213                .get_user_by_id(&admin.id)
1214                .await
1215                .unwrap()
1216                .unwrap()
1217                .role(),
1218            Some("admin")
1219        );
1220    }
1221}
1222// LCOV_EXCL_STOP